Compare commits
133 Commits
e5bd739499
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
887a75cc4f | ||
|
|
3f8c9e1456 | ||
|
|
266c3a6dda | ||
|
|
c992554f99 | ||
|
|
6915e64b67 | ||
|
|
eff5cc9330 | ||
|
|
b0afa17a28 | ||
|
|
3512d55503 | ||
|
|
b1b33b6014 | ||
|
|
3c71a233d7 | ||
|
|
f9852751da | ||
|
|
9e06b56a43 | ||
|
|
c8723714af | ||
|
|
57e8c5a34b | ||
|
|
d8f7abff92 | ||
|
|
9723775cb6 | ||
|
|
dd69a6a2e4 | ||
|
|
a5f7bce09f | ||
|
|
467045365d | ||
|
|
a3a1a46afd | ||
|
|
8f6980df13 | ||
|
|
97782a82bc | ||
|
|
dea8d313e4 | ||
|
|
083a51065b | ||
|
|
7bfa16aec4 | ||
|
|
017937bbff | ||
|
|
5b3d4e5f9e | ||
|
|
5123a38ea9 | ||
|
|
c521c43f9a | ||
|
|
708bf95e2a | ||
|
|
eba39b0475 | ||
|
|
839ac72846 | ||
|
|
67863fca44 | ||
|
|
c56a6a9d21 | ||
|
|
7f11b0e8c1 | ||
|
|
d25cf9ba80 | ||
|
|
065066c8f7 | ||
|
|
80591c31a6 | ||
|
|
dd4d196129 | ||
|
|
cc0dc02be0 | ||
|
|
532bd57866 | ||
|
|
543a1c3ee0 | ||
|
|
3134c064e9 | ||
|
|
0a87769561 | ||
|
|
e8d74d4f59 | ||
|
|
152b0bf9b5 | ||
|
|
372da6df5a | ||
|
|
c5285a2e30 | ||
|
|
bd43eecfbe | ||
|
|
2ec3278632 | ||
|
|
6456d8f69a | ||
|
|
a37807aff0 | ||
|
|
cac8cba38a | ||
|
|
28b4039593 | ||
| cbf369b754 | |||
| 5e7d476b59 | |||
| ce97de37bd | |||
|
|
c0dd298e5b | ||
| d235cce510 | |||
| 9e93172199 | |||
|
|
6b5f6bd3ea | ||
|
|
b540bddbe9 | ||
|
|
9ae94babb6 | ||
|
|
8d37021f78 | ||
|
|
dd22e94efe | ||
|
|
b78b965938 | ||
|
|
1c325c108e | ||
|
|
24219f8289 | ||
|
|
5a83f232a6 | ||
|
|
4a05844ee3 | ||
|
|
09acf7afa5 | ||
|
|
b6c9f5b0ca | ||
|
|
c9f0fd0777 | ||
|
|
ad304913e4 | ||
|
|
f8d0a80f17 | ||
|
|
e8c2c5c8ea | ||
|
|
8370125ec0 | ||
|
|
af2996876c | ||
|
|
eb7d563bbd | ||
|
|
716e6e8f31 | ||
|
|
38db257c4a | ||
|
|
eeb60dc51b | ||
|
|
23d3d201f7 | ||
|
|
5cf32a4370 | ||
|
|
6a5275b60b | ||
|
|
9e01a7e7b5 | ||
|
|
694db506b6 | ||
|
|
cb0dc3e271 | ||
|
|
bcb66ef691 | ||
|
|
e7e8d07100 | ||
|
|
48e2f4fc10 | ||
|
|
87c999c676 | ||
|
|
81813530c8 | ||
|
|
ac147c55b0 | ||
|
|
4b16e8587d | ||
|
|
fae56cb2a8 | ||
|
|
3592d2a9d8 | ||
|
|
2b48509a35 | ||
|
|
b6eaba78ba | ||
|
|
a7639f6454 | ||
|
|
0606d9c3aa | ||
|
|
49b22b5f2f | ||
|
|
443fe8504f | ||
|
|
f252fc7707 | ||
|
|
0362d586e5 | ||
|
|
063df4ba03 | ||
|
|
a191cebeed | ||
|
|
9768af0017 | ||
|
|
c0eb1ffa23 | ||
|
|
2f1bc5d1c3 | ||
|
|
b4e36db763 | ||
|
|
0870359ae8 | ||
|
|
6817d3e24a | ||
|
|
7b2b61a228 | ||
|
|
d09a6c1fe3 | ||
|
|
966c1b35e9 | ||
|
|
2abb0c54e4 | ||
|
|
f9081cfcf7 | ||
|
|
8e4a42666c | ||
|
|
345415ee7b | ||
|
|
af72c4cc4e | ||
|
|
7b98be1d8d | ||
|
|
72f4566d91 | ||
|
|
196dd7b78e | ||
|
|
0a4545f042 | ||
|
|
c0e80ab4ce | ||
|
|
6be52fdac1 | ||
|
|
d0d982f4f5 | ||
|
|
a92e9d4c09 | ||
|
|
800c7db76d | ||
|
|
324ddf9b0a | ||
|
|
646cdf6a9b | ||
|
|
8b094691f5 |
86
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,86 @@
|
||||
name: CI
|
||||
|
||||
# Build + test on every PR into main or develop (and on direct pushes to develop).
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
push:
|
||||
branches: [develop]
|
||||
|
||||
# ⚠️ RUNNER LABELS — must match how your Mac runner was registered with act_runner.
|
||||
# Check your runner's labels in Gitea: Settings → Actions → Runners (click the runner).
|
||||
# If it registered as e.g. `macos` or `self-hosted`, change `runs-on` below to match.
|
||||
# `[self-hosted, macOS]` means "a runner that has BOTH labels".
|
||||
jobs:
|
||||
build-and-test:
|
||||
runs-on: [self-hosted, macos]
|
||||
|
||||
env:
|
||||
SCHEME: KisaniCal
|
||||
PROJECT: KisaniCal.xcodeproj
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# The .xcodeproj is generated from project.yml via XcodeGen.
|
||||
# Regenerate it so CI never builds a stale project. No-op if xcodegen isn't installed.
|
||||
- name: Regenerate Xcode project (XcodeGen)
|
||||
run: |
|
||||
if command -v xcodegen >/dev/null 2>&1; then
|
||||
xcodegen generate
|
||||
else
|
||||
echo "xcodegen not found on runner — using committed ${PROJECT}."
|
||||
echo "Install with: brew install xcodegen"
|
||||
fi
|
||||
|
||||
- name: Show toolchain
|
||||
run: |
|
||||
xcodebuild -version
|
||||
xcrun simctl list runtimes | grep -i ios || true
|
||||
|
||||
# Pick a concrete, bootable iOS Simulator (needed for `xcodebuild test`).
|
||||
- name: Select iOS Simulator destination
|
||||
id: sim
|
||||
run: |
|
||||
# First available iPhone simulator on any installed iOS runtime.
|
||||
UDID=$(xcrun simctl list devices available -j | python3 -c "import json,sys,re; d=json.load(sys.stdin)['devices']; devs=[x for k,v in d.items() if re.search('iOS',k) for x in v if x.get('isAvailable') and 'iPhone' in x['name']]; print(devs[0]['udid'] if devs else '')")
|
||||
if [ -z "$UDID" ]; then
|
||||
echo "No available iPhone simulator found on the runner." >&2
|
||||
echo "Install an iOS runtime via Xcode → Settings → Components." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "udid=$UDID" >> "$GITHUB_OUTPUT"
|
||||
echo "Using simulator UDID: $UDID"
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
set -o pipefail
|
||||
xcodebuild build \
|
||||
-project "$PROJECT" \
|
||||
-scheme "$SCHEME" \
|
||||
-configuration Debug \
|
||||
-destination "id=${{ steps.sim.outputs.udid }}" \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
| xcbeautify || xcodebuild build \
|
||||
-project "$PROJECT" \
|
||||
-scheme "$SCHEME" \
|
||||
-configuration Debug \
|
||||
-destination "id=${{ steps.sim.outputs.udid }}" \
|
||||
CODE_SIGNING_ALLOWED=NO
|
||||
|
||||
- name: Test
|
||||
run: |
|
||||
set -o pipefail
|
||||
xcodebuild test \
|
||||
-project "$PROJECT" \
|
||||
-scheme "$SCHEME" \
|
||||
-configuration Debug \
|
||||
-destination "id=${{ steps.sim.outputs.udid }}" \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
| xcbeautify || xcodebuild test \
|
||||
-project "$PROJECT" \
|
||||
-scheme "$SCHEME" \
|
||||
-configuration Debug \
|
||||
-destination "id=${{ steps.sim.outputs.udid }}" \
|
||||
CODE_SIGNING_ALLOWED=NO
|
||||
66
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,66 @@
|
||||
name: Release
|
||||
|
||||
# Runs only when code lands on main (i.e. after a PR is merged).
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
# ⚠️ Same runner-label caveat as ci.yml — adjust `runs-on` to match your registered runner.
|
||||
jobs:
|
||||
archive-and-export:
|
||||
runs-on: [self-hosted, macos]
|
||||
|
||||
env:
|
||||
SCHEME: KisaniCal
|
||||
PROJECT: KisaniCal.xcodeproj
|
||||
# A real distribution build MUST be signed. This requires an Apple
|
||||
# Distribution certificate + provisioning profile installed in the
|
||||
# runner's login keychain (see notes at the bottom of this file).
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Regenerate Xcode project (XcodeGen)
|
||||
run: |
|
||||
if command -v xcodegen >/dev/null 2>&1; then xcodegen generate; fi
|
||||
|
||||
- name: Archive
|
||||
run: |
|
||||
set -o pipefail
|
||||
xcodebuild archive \
|
||||
-project "$PROJECT" \
|
||||
-scheme "$SCHEME" \
|
||||
-configuration Release \
|
||||
-destination 'generic/platform=iOS' \
|
||||
-archivePath "$RUNNER_TEMP/KisaniCal.xcarchive" \
|
||||
-allowProvisioningUpdates
|
||||
|
||||
# Write the App Store Connect API key to disk. xcodebuild's auto-discovery
|
||||
# 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: |
|
||||
set -o pipefail
|
||||
xcodebuild -exportArchive \
|
||||
-archivePath "$RUNNER_TEMP/KisaniCal.xcarchive" \
|
||||
-exportOptionsPlist ExportOptions.plist \
|
||||
-exportPath "$RUNNER_TEMP/export" \
|
||||
-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"
|
||||
|
||||
- name: Clean up API key
|
||||
if: always()
|
||||
run: rm -rf ~/private_keys
|
||||
2
.gitignore
vendored
@@ -1,4 +1,6 @@
|
||||
# Xcode
|
||||
**/xcuserdata/
|
||||
*.xcuserstate
|
||||
*.xcodeproj/xcuserdata/
|
||||
*.xcworkspace/xcuserdata/
|
||||
*.xcworkspace/contents.xcworkspacedata
|
||||
|
||||
61
CONTRIBUTING.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# Contributing to KisaniCal
|
||||
|
||||
## Branch workflow
|
||||
|
||||
```
|
||||
feature/* ─PR→ develop ─PR→ main
|
||||
```
|
||||
|
||||
- **`main`** — protected, release-ready. Never commit or push directly. Every
|
||||
change arrives via a reviewed pull request. Pushes to `main` trigger the
|
||||
release workflow (archive → IPA → TestFlight).
|
||||
- **`develop`** — the default working branch and integration target. CI runs on
|
||||
every PR into it.
|
||||
- **`feature/*`** — short-lived branches for a single change, e.g.
|
||||
`feature/voice-quick-add`, `fix/icloud-restore`. Branch off `develop`.
|
||||
|
||||
## Day-to-day
|
||||
|
||||
```bash
|
||||
git checkout develop
|
||||
git pull
|
||||
git checkout -b feature/my-change
|
||||
|
||||
# ...work, commit...
|
||||
|
||||
git push -u origin feature/my-change
|
||||
# then open a PR in Gitea: feature/my-change → develop
|
||||
```
|
||||
|
||||
When `develop` is ready to ship, open a PR **`develop` → `main`**.
|
||||
|
||||
## Pull request requirements
|
||||
|
||||
A PR into `main` (and `develop`) cannot be merged until:
|
||||
|
||||
1. **CI passes** — the `build-and-test` job in `.gitea/workflows/ci.yml` builds
|
||||
the app and runs `KisaniCalTests` on an iOS Simulator.
|
||||
2. **At least 1 approval** — reviewed and approved.
|
||||
3. **No unresolved change requests** — a rejected review blocks the merge.
|
||||
4. Your branch is **up to date** with the base branch.
|
||||
|
||||
Direct pushes, force-pushes, and deletion of `main` are blocked server-side by
|
||||
Gitea branch protection, and a local `pre-push` hook blocks accidental direct
|
||||
pushes to `main` from this clone.
|
||||
|
||||
## CI / CD
|
||||
|
||||
- **CI** (`.gitea/workflows/ci.yml`) — runs on every PR into `main`/`develop`
|
||||
and on pushes to `develop`. Builds + tests. A red check blocks the merge.
|
||||
- **Release** (`.gitea/workflows/release.yml`) — runs only on push to `main`
|
||||
(i.e. after a PR merges). Archives, exports an IPA via `ExportOptions.plist`,
|
||||
and (once configured) uploads to TestFlight.
|
||||
|
||||
Both workflows run on a **self-hosted macOS runner** with Xcode — there is no
|
||||
Gitea-hosted Mac runner.
|
||||
|
||||
## Versioning
|
||||
|
||||
`MARKETING_VERSION` (e.g. `2.0`) and `CURRENT_PROJECT_VERSION` (build number)
|
||||
live in `project.yml`. The build number must **increase** for every TestFlight
|
||||
upload — App Store Connect rejects a build number it has already seen.
|
||||
45
ExportOptions.plist
Normal file
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
ExportOptions template for `xcodebuild -exportArchive`.
|
||||
|
||||
FILL IN:
|
||||
• teamID — your Apple Developer Team ID. Detected from project.yml
|
||||
(DEVELOPMENT_TEAM): K8BLMMR883 ← pre-filled below, verify it.
|
||||
• method — choose ONE:
|
||||
app-store → TestFlight / App Store submission
|
||||
ad-hoc → distribute to registered UDIDs
|
||||
development → internal dev devices
|
||||
enterprise → in-house (Apple Enterprise Program only)
|
||||
|
||||
Notes:
|
||||
• signingStyle "automatic" lets Xcode manage certs/profiles (pairs with
|
||||
-allowProvisioningUpdates in release.yml). Switch to "manual" if you
|
||||
install a specific distribution profile on the runner.
|
||||
-->
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>method</key>
|
||||
<string>app-store</string>
|
||||
|
||||
<key>teamID</key>
|
||||
<string>K8BLMMR883</string>
|
||||
|
||||
<key>signingStyle</key>
|
||||
<string>automatic</string>
|
||||
|
||||
<key>uploadSymbols</key>
|
||||
<true/>
|
||||
|
||||
<key>uploadBitcode</key>
|
||||
<false/>
|
||||
|
||||
<!-- Optional: pin the app to a specific provisioning profile (manual signing).
|
||||
<key>provisioningProfiles</key>
|
||||
<dict>
|
||||
<key>com.kutesir.KisaniCal</key>
|
||||
<string>YOUR_PROFILE_NAME</string>
|
||||
</dict>
|
||||
-->
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -7,66 +7,193 @@
|
||||
objects = {
|
||||
|
||||
/* 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 */; };
|
||||
096804560A2F0A7D74E64780 /* TaskItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC2AFB4FA765383740767CB /* TaskItem.swift */; };
|
||||
12E42CE8B8E535FAE6268A0C /* AppGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF905C574F34B4EE51A8D21E /* AppGroup.swift */; };
|
||||
13E4B9854F595394FC9D5912 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC5E179A9189B0A8C3F856F6 /* ContentView.swift */; };
|
||||
152AECD42FCF554C582E2DEA /* AnalyticsEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78EEE7568265196447E54D6B /* AnalyticsEngine.swift */; };
|
||||
16FFC465D8646DED9A5C69D1 /* NLRecurrenceParseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BBF7B0464EE05D113396B93 /* NLRecurrenceParseTests.swift */; };
|
||||
1A22EE21460821170E44B1DF /* ExerciseModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5722CC4B59E3939724142710 /* ExerciseModels.swift */; };
|
||||
205846F9651EDCE0D8207358 /* TaskLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0513EB8C7F6B6853B9E93E09 /* TaskLiveActivity.swift */; };
|
||||
2885D000426D063F2125804C /* SpeechRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86906905A4FA7A2384B3E24E /* SpeechRecognizer.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 */; };
|
||||
3AD7F62D231DCFFFFCB31649 /* TutorialManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01A27D42E141DC056D32C1A3 /* TutorialManager.swift */; };
|
||||
3B57EA3600AFE975850DF39A /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89550F2CD19B950CCC6AD37F /* AuthManager.swift */; };
|
||||
3C793FD5DA00D3E9C0D51FEC /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 72FDF9C8DD37134576356B89 /* Assets.xcassets */; };
|
||||
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 */; };
|
||||
497732557745AE9BDA44FB2F /* ShortcutHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 326B77A7B317A7DB44E13EA5 /* ShortcutHandler.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 */; };
|
||||
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 */; };
|
||||
5F91DDE0B64C8AE1F142F434 /* AnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A22FCC3FA1A10F52415E5AF /* AnalyticsService.swift */; };
|
||||
67CE1747E5DB3FDA79D0FDFD /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 106EEF572C6F8990408329F0 /* OnboardingView.swift */; };
|
||||
6921CB73A3257502FF778381 /* SplashView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE2D50B4B4AC227BD21F4B60 /* SplashView.swift */; };
|
||||
6BCBF3902E38FBAA7348840D /* CalendarGridTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74FAA2B41FCEBC7E3F156F0F /* CalendarGridTests.swift */; };
|
||||
703E068364256D6F3F867961 /* ISSUES.md in Resources */ = {isa = PBXBuildFile; fileRef = 92824ED40ECD41EFD4F78BEC /* ISSUES.md */; };
|
||||
754D3DE3CEB998E36E585A61 /* LiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0506183945D16EC443A69651 /* LiveActivityManager.swift */; };
|
||||
79FC1DF6762C6F02D01AB643 /* KisaniCalWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0D6D6740721F01A74E404EB9 /* KisaniCalWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
7CEBC340BFA9238D121946AC /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 57714B79AFFF60DA90EB86E3 /* Preview Assets.xcassets */; };
|
||||
8387093D19FB3397CCB8FEF8 /* WenzaWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF528FCC224EF283F95851AD /* WenzaWatchApp.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 */; };
|
||||
8C9567A3DE3F63A1ECAE84D5 /* TutorialView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA3D5289C5F93484E22DEB63 /* TutorialView.swift */; };
|
||||
8DA396DACE99DC4B7B4A460E /* TaskActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEB07EB89E89383C32ADB34 /* TaskActivityAttributes.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 */; };
|
||||
A1B2C3D4E5F6A7B8C9D0E1F2 /* TutorialManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0B1C2D3E4F5A6B7C8D9E0F1 /* TutorialManager.swift */; };
|
||||
A1DBC4D2F09C02B8CEE6449E /* AddExerciseSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 768D4E314252E2A90A06CCF2 /* AddExerciseSheet.swift */; };
|
||||
A292B0492135BA40F6B6A951 /* CalendarGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FCB2077D9509CB5C0978E58 /* CalendarGrid.swift */; };
|
||||
A3B2D6622A2EE35C8D5A3C9B /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72308FEE0226F45414C04DDD /* SettingsView.swift */; };
|
||||
A9FF93259AE8FF0ABF69D71A /* DesignTokens.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD4A35C0E1270E2E15C03F23 /* DesignTokens.swift */; };
|
||||
B2C3D4E5F6A7B8C9D0E1F2A3 /* TutorialView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C2D3E4F5A6B7C8D9E0F1A2 /* TutorialView.swift */; };
|
||||
B38135962FCAF9CE0037DC41 /* HealthKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B38135952FCAF9CE0037DC41 /* HealthKitManager.swift */; };
|
||||
AC0D814DA54D5EF5E25CEB99 /* WidgetData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61BFF3BA28331A16D0ADE9D0 /* WidgetData.swift */; };
|
||||
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 */; };
|
||||
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 */; };
|
||||
C79C33BE2802E81AA00175CC /* TodayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C786EBC7DF879D64EB28165E /* TodayView.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 */; };
|
||||
D591A72235A53D4038FBC2B4 /* KisaniCalApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001F67ADC72FBAEC03EB7E01 /* KisaniCalApp.swift */; };
|
||||
CC11DD22EE33FF44AA55BB66 /* CloudSyncManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC00DD11EE22FF33AA44BB55 /* CloudSyncManager.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 */; };
|
||||
EE7BCEB45B1F0B91C9D3C1D2 /* TaskContextMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 670A8C6F8243EC973A1BC431 /* TaskContextMenu.swift */; };
|
||||
EF03EC9F974B14D100DD5528 /* NotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93D045FE3DEB1D22D908A29F /* NotificationManager.swift */; };
|
||||
F1A2B3C4D5E6F7A8B9C0D1E2 /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3A4B5C6D7E8F9A0B1C2D3E4 /* AuthManager.swift */; };
|
||||
F5A6B7C8D9E0F1A2B3C4D5E6 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7A8B9C0D1E2F3A4B5C6D7E8 /* AuthView.swift */; };
|
||||
F9B0C1D2E3F4A5B6C7D8E9F0 /* ProfileSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8A9B0C1D2E3F4A5B6C7D8E9 /* ProfileSetupView.swift */; };
|
||||
FC3D7406706ADC96500AB764 /* AnalyticsCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0E5E96C6FC5DFC4E76452BF /* AnalyticsCoordinatorTests.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
BF401FDD105729FB67DF02D6 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = AF6DE7A812408E3742522E90 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 297C0C2BFBA10AB52D5D49CE;
|
||||
remoteInfo = KisaniCal;
|
||||
};
|
||||
D04FCEE0BDF30AEFD1C969B6 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = AF6DE7A812408E3742522E90 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 28B43516AD88946E21D9BFE0;
|
||||
remoteInfo = KisaniCalWidgets;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
B3BE3000B362E4DDC4EE9E76 /* Embed Foundation Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
79FC1DF6762C6F02D01AB643 /* KisaniCalWidgets.appex in Embed Foundation Extensions */,
|
||||
);
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
001F67ADC72FBAEC03EB7E01 /* KisaniCalApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KisaniCalApp.swift; sourceTree = "<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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
4AD014B7E3E30A34E18696A0 /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = "<group>"; };
|
||||
5722CC4B59E3939724142710 /* ExerciseModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExerciseModels.swift; sourceTree = "<group>"; };
|
||||
57714B79AFFF60DA90EB86E3 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
93D045FE3DEB1D22D908A29F /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = "<group>"; };
|
||||
CC00DD11EE22FF33AA44BB55 /* CloudSyncManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudSyncManager.swift; sourceTree = "<group>"; };
|
||||
9BD1F84B4F45B12CB9B9F1B2 /* KisaniCal.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = KisaniCal.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
A0B1C2D3E4F5A6B7C8D9E0F1 /* TutorialManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialManager.swift; sourceTree = "<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; };
|
||||
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>"; };
|
||||
B1C2D3E4F5A6B7C8D9E0F1A2 /* TutorialView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialView.swift; sourceTree = "<group>"; };
|
||||
B38135942FCAF0BD0037DC41 /* KisaniCal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = KisaniCal.entitlements; sourceTree = "<group>"; };
|
||||
B38135952FCAF9CE0037DC41 /* HealthKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthKitManager.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>"; };
|
||||
BC5E179A9189B0A8C3F856F6 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
BD4A35C0E1270E2E15C03F23 /* DesignTokens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DesignTokens.swift; sourceTree = "<group>"; };
|
||||
BE2D50B4B4AC227BD21F4B60 /* SplashView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashView.swift; sourceTree = "<group>"; };
|
||||
C5EF0E7944C7F0763B83BB0F /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; 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>"; };
|
||||
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>"; };
|
||||
DCC2AFB4FA765383740767CB /* TaskItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskItem.swift; sourceTree = "<group>"; };
|
||||
F3A4B5C6D7E8F9A0B1C2D3E4 /* AuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthManager.swift; sourceTree = "<group>"; };
|
||||
F7A8B9C0D1E2F3A4B5C6D7E8 /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = "<group>"; };
|
||||
F8A9B0C1D2E3F4A5B6C7D8E9 /* ProfileSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSetupView.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>"; };
|
||||
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>"; };
|
||||
FEEB07EB89E89383C32ADB34 /* TaskActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskActivityAttributes.swift; sourceTree = "<group>"; };
|
||||
FF528FCC224EF283F95851AD /* WenzaWatchApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WenzaWatchApp.swift; sourceTree = "<group>"; };
|
||||
FF5AFD143B693B77D07FBDA4 /* HealthKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthKitManager.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
068B77B2F01C399C7A430292 /* KisaniCalTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
713DAF7EA146A779E4AC1D1A /* AnalyticsAdapterTests.swift */,
|
||||
A0E5E96C6FC5DFC4E76452BF /* AnalyticsCoordinatorTests.swift */,
|
||||
9DEAB6A9224E02484292362A /* AnalyticsEngineTests.swift */,
|
||||
3E8855698633CC9A1172AEF1 /* AnalyticsStoreTests.swift */,
|
||||
74FAA2B41FCEBC7E3F156F0F /* CalendarGridTests.swift */,
|
||||
9BBF7B0464EE05D113396B93 /* NLRecurrenceParseTests.swift */,
|
||||
D72D757FA29923B6C150B5EE /* RecurrenceTests.swift */,
|
||||
D230156A72AAFBBAB7626629 /* StreakLogicTests.swift */,
|
||||
);
|
||||
path = KisaniCalTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
21B93C269F283F11B415B18C /* Components */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -76,19 +203,42 @@
|
||||
path = Components;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
2217B7EEC45957B820311EC7 /* Shared */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
3A0B871AB37123908CF0CB20 /* RecurrenceRule.swift */,
|
||||
);
|
||||
path = Shared;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
23CBCF100C5EF55E737379CA /* Models */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
0FCB2077D9509CB5C0978E58 /* CalendarGrid.swift */,
|
||||
5722CC4B59E3939724142710 /* ExerciseModels.swift */,
|
||||
DCC2AFB4FA765383740767CB /* TaskItem.swift */,
|
||||
);
|
||||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
48146B56E740528496663D47 /* WenzaWatch */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B4CFFDFE4653A9E901CEF28D /* Info.plist */,
|
||||
35FF274EFF2361B7F3D2D227 /* WenzaWatch.entitlements */,
|
||||
FF528FCC224EF283F95851AD /* WenzaWatchApp.swift */,
|
||||
);
|
||||
path = WenzaWatch;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4D2F2B3472A5A6D99F2FFCD2 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F70DA4746C68CD405435DAB6 /* KisaniCal */,
|
||||
068B77B2F01C399C7A430292 /* KisaniCalTests */,
|
||||
E8D727EEA0C3A4B8006FB087 /* KisaniCalWidgets */,
|
||||
2217B7EEC45957B820311EC7 /* Shared */,
|
||||
48146B56E740528496663D47 /* WenzaWatch */,
|
||||
51BD1B5DEDE9FAD9CA2FF6DA /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
@@ -97,6 +247,9 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9BD1F84B4F45B12CB9B9F1B2 /* KisaniCal.app */,
|
||||
20E29A9264577A89891DAEC1 /* KisaniCalTests.xctest */,
|
||||
0D6D6740721F01A74E404EB9 /* KisaniCalWidgets.appex */,
|
||||
7A9D47B284FD6A217AEF813B /* WenzaWatch.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -104,16 +257,20 @@
|
||||
5E9A0E064E153429180400E6 /* Views */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F7A8B9C0D1E2F3A4B5C6D7E8 /* AuthView.swift */,
|
||||
F8A9B0C1D2E3F4A5B6C7D8E9 /* ProfileSetupView.swift */,
|
||||
B1C2D3E4F5A6B7C8D9E0F1A2 /* TutorialView.swift */,
|
||||
69F0950E6F31016C848B2A63 /* ActivityHistoryView.swift */,
|
||||
768D4E314252E2A90A06CCF2 /* AddExerciseSheet.swift */,
|
||||
1C6CEBCB6041EA41ED482F67 /* AnalyticsView.swift */,
|
||||
4AD014B7E3E30A34E18696A0 /* AuthView.swift */,
|
||||
ADF6CCD95A587E26E30F5712 /* CalendarView.swift */,
|
||||
D44530A77DF12A17E52AAF34 /* MatrixView.swift */,
|
||||
106EEF572C6F8990408329F0 /* OnboardingView.swift */,
|
||||
0C96EC00F6B021DBA3CC1A79 /* ProfileSetupView.swift */,
|
||||
72308FEE0226F45414C04DDD /* SettingsView.swift */,
|
||||
BE2D50B4B4AC227BD21F4B60 /* SplashView.swift */,
|
||||
670A8C6F8243EC973A1BC431 /* TaskContextMenu.swift */,
|
||||
ED5DB5340BEB3EF7A78CA153 /* TodayMenuFeatures.swift */,
|
||||
C786EBC7DF879D64EB28165E /* TodayView.swift */,
|
||||
FA3D5289C5F93484E22DEB63 /* TutorialView.swift */,
|
||||
20DAD771EFCC8B3B1DBD4DD6 /* WorkoutView.swift */,
|
||||
);
|
||||
path = Views;
|
||||
@@ -127,6 +284,20 @@
|
||||
path = "Preview Content";
|
||||
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 */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -135,13 +306,32 @@
|
||||
path = Theme;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E8D727EEA0C3A4B8006FB087 /* KisaniCalWidgets */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
FC17C7BA15BD2E84AE5F77CF /* EventCountdownWidget.swift */,
|
||||
8DC8687EA2FBA9FB2EEE51C6 /* Info.plist */,
|
||||
8E7FC6446D5C5B2A2D9387DA /* KisaniCalWidgets.entitlements */,
|
||||
42650F655DB8B320C7C03929 /* KisaniCalWidgets.swift */,
|
||||
208F82348DEBD9FF7B0DCF17 /* MyTasksWidget.swift */,
|
||||
91A354921717711EB3C0EE8E /* StopCountdownIntent.swift */,
|
||||
0513EB8C7F6B6853B9E93E09 /* TaskLiveActivity.swift */,
|
||||
61BFF3BA28331A16D0ADE9D0 /* WidgetData.swift */,
|
||||
429806CE1021C8DE2EB770CE /* WidgetViews.swift */,
|
||||
);
|
||||
path = KisaniCalWidgets;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
F70DA4746C68CD405435DAB6 /* KisaniCal */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B38135942FCAF0BD0037DC41 /* KisaniCal.entitlements */,
|
||||
72FDF9C8DD37134576356B89 /* Assets.xcassets */,
|
||||
BC5E179A9189B0A8C3F856F6 /* ContentView.swift */,
|
||||
92824ED40ECD41EFD4F78BEC /* ISSUES.md */,
|
||||
1AA7498EFED7692022F3E7E1 /* KisaniCal.entitlements */,
|
||||
001F67ADC72FBAEC03EB7E01 /* KisaniCalApp.swift */,
|
||||
C5EF0E7944C7F0763B83BB0F /* LaunchScreen.storyboard */,
|
||||
C7409CC354029293D424BEDA /* Analytics */,
|
||||
21B93C269F283F11B415B18C /* Components */,
|
||||
FB9BF734B9E493EEB09ACE21 /* Managers */,
|
||||
23CBCF100C5EF55E737379CA /* Models */,
|
||||
@@ -155,12 +345,16 @@
|
||||
FB9BF734B9E493EEB09ACE21 /* Managers */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F3A4B5C6D7E8F9A0B1C2D3E4 /* AuthManager.swift */,
|
||||
B38135952FCAF9CE0037DC41 /* HealthKitManager.swift */,
|
||||
AF905C574F34B4EE51A8D21E /* AppGroup.swift */,
|
||||
89550F2CD19B950CCC6AD37F /* AuthManager.swift */,
|
||||
449C34805DC6B2CB66886544 /* CloudSyncManager.swift */,
|
||||
FF5AFD143B693B77D07FBDA4 /* HealthKitManager.swift */,
|
||||
0506183945D16EC443A69651 /* LiveActivityManager.swift */,
|
||||
93D045FE3DEB1D22D908A29F /* NotificationManager.swift */,
|
||||
CC00DD11EE22FF33AA44BB55 /* CloudSyncManager.swift */,
|
||||
326B77A7B317A7DB44E13EA5 /* ShortcutHandler.swift */,
|
||||
A0B1C2D3E4F5A6B7C8D9E0F1 /* TutorialManager.swift */,
|
||||
86906905A4FA7A2384B3E24E /* SpeechRecognizer.swift */,
|
||||
FEEB07EB89E89383C32ADB34 /* TaskActivityAttributes.swift */,
|
||||
01A27D42E141DC056D32C1A3 /* TutorialManager.swift */,
|
||||
);
|
||||
path = Managers;
|
||||
sourceTree = "<group>";
|
||||
@@ -168,16 +362,35 @@
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
28B43516AD88946E21D9BFE0 /* KisaniCalWidgets */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 63EDFF56CC5312509E567D37 /* Build configuration list for PBXNativeTarget "KisaniCalWidgets" */;
|
||||
buildPhases = (
|
||||
B1361CC76F252F2A0F8D64CD /* Sources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = KisaniCalWidgets;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = KisaniCalWidgets;
|
||||
productReference = 0D6D6740721F01A74E404EB9 /* KisaniCalWidgets.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
297C0C2BFBA10AB52D5D49CE /* KisaniCal */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = B60FDD2C88D1D33CF1EC02B3 /* Build configuration list for PBXNativeTarget "KisaniCal" */;
|
||||
buildPhases = (
|
||||
82EA49FE155821D424C46912 /* Sources */,
|
||||
E1F1A3EE4469B851B972E4AE /* Resources */,
|
||||
B3BE3000B362E4DDC4EE9E76 /* Embed Foundation Extensions */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
8B1A9D9F3CF53BD8088AEC59 /* PBXTargetDependency */,
|
||||
);
|
||||
name = KisaniCal;
|
||||
packageProductDependencies = (
|
||||
@@ -186,6 +399,41 @@
|
||||
productReference = 9BD1F84B4F45B12CB9B9F1B2 /* KisaniCal.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
403D989BDC6268361CFFB479 /* WenzaWatch */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 6F608724A2EAA63171766586 /* Build configuration list for PBXNativeTarget "WenzaWatch" */;
|
||||
buildPhases = (
|
||||
6DEFD550940764CA28D993CE /* Sources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = WenzaWatch;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = WenzaWatch;
|
||||
productReference = 7A9D47B284FD6A217AEF813B /* WenzaWatch.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
75535A64153951A3C35F75FC /* KisaniCalTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = A0CF5E0496CC1077B2417541 /* Build configuration list for PBXNativeTarget "KisaniCalTests" */;
|
||||
buildPhases = (
|
||||
474D88E5A3251407875B5C24 /* Sources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
EEF70F5652B35E046A79A5A3 /* PBXTargetDependency */,
|
||||
);
|
||||
name = KisaniCalTests;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = KisaniCalTests;
|
||||
productReference = 20E29A9264577A89891DAEC1 /* KisaniCalTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
@@ -193,11 +441,23 @@
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastUpgradeCheck = 2620;
|
||||
LastUpgradeCheck = 1500;
|
||||
TargetAttributes = {
|
||||
297C0C2BFBA10AB52D5D49CE = {
|
||||
28B43516AD88946E21D9BFE0 = {
|
||||
DevelopmentTeam = K8BLMMR883;
|
||||
ProvisioningStyle = Automatic;
|
||||
};
|
||||
297C0C2BFBA10AB52D5D49CE = {
|
||||
DevelopmentTeam = K8BLMMR883;
|
||||
ProvisioningStyle = Automatic;
|
||||
};
|
||||
403D989BDC6268361CFFB479 = {
|
||||
DevelopmentTeam = K8BLMMR883;
|
||||
ProvisioningStyle = Automatic;
|
||||
};
|
||||
75535A64153951A3C35F75FC = {
|
||||
DevelopmentTeam = K8BLMMR883;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = EF1C4F3BDA4AA7E008FC2CDE /* Build configuration list for PBXProject "KisaniCal" */;
|
||||
@@ -215,6 +475,9 @@
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
297C0C2BFBA10AB52D5D49CE /* KisaniCal */,
|
||||
75535A64153951A3C35F75FC /* KisaniCalTests */,
|
||||
28B43516AD88946E21D9BFE0 /* KisaniCalWidgets */,
|
||||
403D989BDC6268361CFFB479 /* WenzaWatch */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@@ -225,6 +488,8 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
3C793FD5DA00D3E9C0D51FEC /* Assets.xcassets in Resources */,
|
||||
703E068364256D6F3F867961 /* ISSUES.md in Resources */,
|
||||
45AA93D76970B39DB8BA6A5B /* LaunchScreen.storyboard in Resources */,
|
||||
7CEBC340BFA9238D121946AC /* Preview Assets.xcassets in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -232,39 +497,109 @@
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
474D88E5A3251407875B5C24 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
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 */,
|
||||
16FFC465D8646DED9A5C69D1 /* NLRecurrenceParseTests.swift in Sources */,
|
||||
4FB79BBC1EE0F6C7EF844B02 /* RecurrenceTests.swift in Sources */,
|
||||
E135F5118E1D006F03AE2178 /* StreakLogicTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
6DEFD550940764CA28D993CE /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
8387093D19FB3397CCB8FEF8 /* WenzaWatchApp.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
82EA49FE155821D424C46912 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
F1A2B3C4D5E6F7A8B9C0D1E2 /* AuthManager.swift in Sources */,
|
||||
F5A6B7C8D9E0F1A2B3C4D5E6 /* AuthView.swift in Sources */,
|
||||
F9B0C1D2E3F4A5B6C7D8E9F0 /* ProfileSetupView.swift in Sources */,
|
||||
A1B2C3D4E5F6A7B8C9D0E1F2 /* TutorialManager.swift in Sources */,
|
||||
B2C3D4E5F6A7B8C9D0E1F2A3 /* TutorialView.swift in Sources */,
|
||||
D1F2189D03CFF743A534A0C6 /* ActivityHistoryView.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 */,
|
||||
3B57EA3600AFE975850DF39A /* AuthManager.swift in Sources */,
|
||||
ECEAA5CF7309E5993D12B571 /* AuthView.swift in Sources */,
|
||||
A292B0492135BA40F6B6A951 /* CalendarGrid.swift in Sources */,
|
||||
9070521B1D36A5551976C275 /* CalendarView.swift in Sources */,
|
||||
06CA0F336E64D9F6D56F7472 /* CloudSyncManager.swift in Sources */,
|
||||
13E4B9854F595394FC9D5912 /* ContentView.swift in Sources */,
|
||||
A9FF93259AE8FF0ABF69D71A /* DesignTokens.swift in Sources */,
|
||||
1A22EE21460821170E44B1DF /* ExerciseModels.swift in Sources */,
|
||||
CBE7295BF5ADE08FE93AFAAF /* FloatingTabState.swift in Sources */,
|
||||
BD0DB4B0AA8A63D124EDFF2C /* HealthKitManager.swift in Sources */,
|
||||
D591A72235A53D4038FBC2B4 /* KisaniCalApp.swift in Sources */,
|
||||
754D3DE3CEB998E36E585A61 /* LiveActivityManager.swift in Sources */,
|
||||
BEAFF968632A34C70B11C5AC /* MatrixView.swift in Sources */,
|
||||
B38135962FCAF9CE0037DC41 /* HealthKitManager.swift in Sources */,
|
||||
CC11DD22EE33FF44AA55BB66 /* CloudSyncManager.swift in Sources */,
|
||||
EF03EC9F974B14D100DD5528 /* NotificationManager.swift in Sources */,
|
||||
EF03EC9F974B14D100DD5528 /* NotificationManager.swift in Sources */,
|
||||
67CE1747E5DB3FDA79D0FDFD /* OnboardingView.swift in Sources */,
|
||||
5EF4A5B6CE91ADA0CCF72D0D /* ProfileSetupView.swift in Sources */,
|
||||
30D155A1314CBD4BB1744B29 /* RecurrenceRule.swift in Sources */,
|
||||
A3B2D6622A2EE35C8D5A3C9B /* SettingsView.swift in Sources */,
|
||||
8BE9D3D650B0F06169EC7048 /* SharedComponents.swift in Sources */,
|
||||
497732557745AE9BDA44FB2F /* ShortcutHandler.swift in Sources */,
|
||||
2885D000426D063F2125804C /* SpeechRecognizer.swift in Sources */,
|
||||
6921CB73A3257502FF778381 /* SplashView.swift in Sources */,
|
||||
32C63D81925FBFE51CAE1FB7 /* TaskActivityAttributes.swift in Sources */,
|
||||
EE7BCEB45B1F0B91C9D3C1D2 /* TaskContextMenu.swift in Sources */,
|
||||
096804560A2F0A7D74E64780 /* TaskItem.swift in Sources */,
|
||||
83EA218392952885C97144D1 /* TodayMenuFeatures.swift in Sources */,
|
||||
C79C33BE2802E81AA00175CC /* TodayView.swift in Sources */,
|
||||
3AD7F62D231DCFFFFCB31649 /* TutorialManager.swift in Sources */,
|
||||
8C9567A3DE3F63A1ECAE84D5 /* TutorialView.swift in Sources */,
|
||||
C69A21EC244513185A1F59BD /* WorkoutAnalyticsAdapter.swift in Sources */,
|
||||
9E3F966F6B6AF2E4F8109E51 /* WorkoutView.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
B1361CC76F252F2A0F8D64CD /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
12E42CE8B8E535FAE6268A0C /* AppGroup.swift in Sources */,
|
||||
552E2F85B01C9CC437D383B5 /* EventCountdownWidget.swift in Sources */,
|
||||
C7767143D9617ECA04ED1935 /* KisaniCalWidgets.swift in Sources */,
|
||||
3E9DE1CF20BAC479805DF940 /* MyTasksWidget.swift in Sources */,
|
||||
CD3B0C436EA2D013FC3A6B5B /* RecurrenceRule.swift in Sources */,
|
||||
5D0E254AB5D195AFDE09FABF /* StopCountdownIntent.swift in Sources */,
|
||||
8DA396DACE99DC4B7B4A460E /* TaskActivityAttributes.swift in Sources */,
|
||||
205846F9651EDCE0D8207358 /* TaskLiveActivity.swift in Sources */,
|
||||
AC0D814DA54D5EF5E25CEB99 /* WidgetData.swift in Sources */,
|
||||
55F00C433F853F7B54F136B3 /* WidgetViews.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
8B1A9D9F3CF53BD8088AEC59 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 28B43516AD88946E21D9BFE0 /* KisaniCalWidgets */;
|
||||
targetProxy = D04FCEE0BDF30AEFD1C969B6 /* PBXContainerItemProxy */;
|
||||
};
|
||||
EEF70F5652B35E046A79A5A3 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 297C0C2BFBA10AB52D5D49CE /* KisaniCal */;
|
||||
targetProxy = BF401FDD105729FB67DF02D6 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
0CEA6637F45C32A237FBA20D /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
@@ -274,27 +609,26 @@
|
||||
CODE_SIGN_ENTITLEMENTS = KisaniCal/KisaniCal.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 8;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Kisani Cal";
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
|
||||
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Kisani Cal shows your calendar events alongside tasks.";
|
||||
INFOPLIST_KEY_NSCalendarsUsageDescription = "Kisani Cal shows your calendar events alongside tasks.";
|
||||
INFOPLIST_KEY_NSHealthShareUsageDescription = "KisaniCal reads your steps, active calories, resting heart rate, and workout history to display your fitness stats.";
|
||||
INFOPLIST_KEY_NSHealthUpdateUsageDescription = "KisaniCal saves your completed strength training workouts to Apple Health.";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Wenza;
|
||||
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Wenza shows your calendar events alongside tasks.";
|
||||
INFOPLIST_KEY_NSCalendarsUsageDescription = "Wenza shows your calendar events alongside tasks.";
|
||||
INFOPLIST_KEY_NSHealthShareUsageDescription = "Wenza reads your steps, energy, heart rate, and workouts to show health stats on your dashboard.";
|
||||
INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Wenza saves workouts you complete to the Health app.";
|
||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Wenza uses your microphone to convert speech into task text.";
|
||||
INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "Wenza uses speech recognition so you can add tasks by voice.";
|
||||
INFOPLIST_KEY_NSSupportsLiveActivities = YES;
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
||||
INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDefault;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
NEW_SETTING = "";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.kutesir.KisaniCal;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_VERSION = 5.9;
|
||||
@@ -302,6 +636,25 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
1092C43DF580318C5090D54B /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.kutesir.KisaniCalTests;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_VERSION = 5.9;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/KisaniCal.app/KisaniCal";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
20E23A89279CC26778E21856 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
@@ -336,11 +689,11 @@
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 10;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = K8BLMMR883;
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
@@ -350,17 +703,52 @@
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
MARKETING_VERSION = 2.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
4F022C5E53C268A49F093011 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.kutesir.KisaniCalTests;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_VERSION = 5.9;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/KisaniCal.app/KisaniCal";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
B60AA89AB378D4EA773989AA /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = KisaniCalWidgets/KisaniCalWidgets.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = KisaniCalWidgets/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.kutesir.KisaniCal.KisaniCalWidgets;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_VERSION = 5.9;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
BD527AA54887489CC665FB9D /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
@@ -395,11 +783,11 @@
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 10;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = K8BLMMR883;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
@@ -415,18 +803,70 @@
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
MARKETING_VERSION = 2.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
D3A1329FCB6BBA1E8CC25FFC /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = WenzaWatch/WenzaWatch.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = WenzaWatch/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Wenza;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.kutesir.KisaniCal.watchkitapp;
|
||||
SDKROOT = watchos;
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_VERSION = 5.9;
|
||||
TARGETED_DEVICE_FAMILY = 4;
|
||||
WATCHOS_DEPLOYMENT_TARGET = 10.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
DF56CDD73E9B177D57AE17FB /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = KisaniCalWidgets/KisaniCalWidgets.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = KisaniCalWidgets/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.kutesir.KisaniCal.KisaniCalWidgets;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_VERSION = 5.9;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
F00C7E0C784B90820717D614 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = WenzaWatch/WenzaWatch.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = WenzaWatch/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Wenza;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.kutesir.KisaniCal.watchkitapp;
|
||||
SDKROOT = watchos;
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_VERSION = 5.9;
|
||||
TARGETED_DEVICE_FAMILY = 4;
|
||||
WATCHOS_DEPLOYMENT_TARGET = 10.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
F364512BEB70ECB7CB83FBFE /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
@@ -435,27 +875,26 @@
|
||||
CODE_SIGN_ENTITLEMENTS = KisaniCal/KisaniCal.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 8;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Kisani Cal";
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
|
||||
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Kisani Cal shows your calendar events alongside tasks.";
|
||||
INFOPLIST_KEY_NSCalendarsUsageDescription = "Kisani Cal shows your calendar events alongside tasks.";
|
||||
INFOPLIST_KEY_NSHealthShareUsageDescription = "KisaniCal reads your steps, active calories, resting heart rate, and workout history to display your fitness stats.";
|
||||
INFOPLIST_KEY_NSHealthUpdateUsageDescription = "KisaniCal saves your completed strength training workouts to Apple Health.";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Wenza;
|
||||
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Wenza shows your calendar events alongside tasks.";
|
||||
INFOPLIST_KEY_NSCalendarsUsageDescription = "Wenza shows your calendar events alongside tasks.";
|
||||
INFOPLIST_KEY_NSHealthShareUsageDescription = "Wenza reads your steps, energy, heart rate, and workouts to show health stats on your dashboard.";
|
||||
INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Wenza saves workouts you complete to the Health app.";
|
||||
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Wenza uses your microphone to convert speech into task text.";
|
||||
INFOPLIST_KEY_NSSpeechRecognitionUsageDescription = "Wenza uses speech recognition so you can add tasks by voice.";
|
||||
INFOPLIST_KEY_NSSupportsLiveActivities = YES;
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
||||
INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDefault;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
NEW_SETTING = "";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.kutesir.KisaniCal;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_VERSION = 5.9;
|
||||
@@ -466,6 +905,33 @@
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
63EDFF56CC5312509E567D37 /* Build configuration list for PBXNativeTarget "KisaniCalWidgets" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
DF56CDD73E9B177D57AE17FB /* Debug */,
|
||||
B60AA89AB378D4EA773989AA /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
6F608724A2EAA63171766586 /* Build configuration list for PBXNativeTarget "WenzaWatch" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
F00C7E0C784B90820717D614 /* Debug */,
|
||||
D3A1329FCB6BBA1E8CC25FFC /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
A0CF5E0496CC1077B2417541 /* Build configuration list for PBXNativeTarget "KisaniCalTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
4F022C5E53C268A49F093011 /* Debug */,
|
||||
1092C43DF580318C5090D54B /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
B60FDD2C88D1D33CF1EC02B3 /* Build configuration list for PBXNativeTarget "KisaniCal" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2620"
|
||||
version = "1.3">
|
||||
LastUpgradeVersion = "1500"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
buildImplicitDependencies = "YES"
|
||||
runPostActionsOnFailure = "NO">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
@@ -26,7 +27,8 @@
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
onlyGenerateCoverageForSpecifiedTargets = "NO">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
@@ -37,7 +39,20 @@
|
||||
</BuildableReference>
|
||||
</MacroExpansion>
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO"
|
||||
parallelizable = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "75535A64153951A3C35F75FC"
|
||||
BuildableName = "KisaniCalTests.xctest"
|
||||
BlueprintName = "KisaniCalTests"
|
||||
ReferencedContainer = "container:KisaniCal.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
<CommandLineArguments>
|
||||
</CommandLineArguments>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
@@ -59,6 +74,8 @@
|
||||
ReferencedContainer = "container:KisaniCal.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<CommandLineArguments>
|
||||
</CommandLineArguments>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
@@ -76,6 +93,8 @@
|
||||
ReferencedContainer = "container:KisaniCal.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<CommandLineArguments>
|
||||
</CommandLineArguments>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
|
||||
76
KisaniCal/Analytics/AnalyticsCoordinator.swift
Normal file
@@ -0,0 +1,76 @@
|
||||
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() }
|
||||
}
|
||||
58
KisaniCal/Analytics/AnalyticsDeepLink.swift
Normal file
@@ -0,0 +1,58 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
330
KisaniCal/Analytics/AnalyticsEngine.swift
Normal file
@@ -0,0 +1,330 @@
|
||||
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) }
|
||||
}
|
||||
}
|
||||
171
KisaniCal/Analytics/AnalyticsModels.swift
Normal file
@@ -0,0 +1,171 @@
|
||||
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 }
|
||||
}
|
||||
190
KisaniCal/Analytics/AnalyticsService.swift
Normal file
@@ -0,0 +1,190 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
161
KisaniCal/Analytics/AnalyticsStore.swift
Normal file
@@ -0,0 +1,161 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
93
KisaniCal/Analytics/WorkoutAnalyticsAdapter.swift
Normal file
@@ -0,0 +1,93 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 3.7 KiB |
@@ -40,7 +40,10 @@ private struct TabBarScrollModifier: ViewModifier {
|
||||
}
|
||||
)
|
||||
.onPreferenceChange(ScrollYKey.self) { y in
|
||||
tabState.report(scrollY: y)
|
||||
// Defer off the current view-update cycle: this callback can fire during
|
||||
// layout, and mutating tabState's @Published state there triggers
|
||||
// "Modifying state during view update".
|
||||
Task { @MainActor in tabState.report(scrollY: y) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,25 @@ 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 {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
struct ContentView: View {
|
||||
@StateObject private var taskVM = TaskViewModel()
|
||||
@@ -10,8 +11,14 @@ struct ContentView: View {
|
||||
@AppStorage("showWorkoutTab") private var showWorkoutTab = true
|
||||
@AppStorage("hasOnboarded") private var hasOnboarded = false
|
||||
@State private var showOnboarding = false
|
||||
@State private var selectedTab = 0
|
||||
@State private var selectedTab = {
|
||||
#if DEBUG
|
||||
if let t = ProcessInfo.processInfo.environment["KISANI_INITIAL_TAB"], let i = Int(t) { return i }
|
||||
#endif
|
||||
return 0
|
||||
}()
|
||||
@State private var showQuickAddTask = false
|
||||
@State private var analyticsLink: AnalyticsDeepLink?
|
||||
@StateObject private var tabState = FloatingTabState()
|
||||
@ObservedObject private var shortcuts = ShortcutHandler.shared
|
||||
@ObservedObject private var tutorial = TutorialManager.shared
|
||||
@@ -67,6 +74,14 @@ struct ContentView: View {
|
||||
|
||||
private var isIPad: Bool { UIDevice.current.userInterfaceIdiom == .pad }
|
||||
|
||||
/// Picks up the "+" tapped on the My Tasks widget and opens the add-task sheet.
|
||||
private func consumeWidgetAddTask() {
|
||||
guard UserDefaults.kisani.bool(forKey: "kisani.widget.openAddTask") else { return }
|
||||
UserDefaults.kisani.removeObject(forKey: "kisani.widget.openAddTask")
|
||||
selectedTab = 0
|
||||
showQuickAddTask = true
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if isIPad {
|
||||
@@ -84,21 +99,51 @@ struct ContentView: View {
|
||||
}
|
||||
.onAppear {
|
||||
if !hasOnboarded { showOnboarding = true }
|
||||
workoutVM.resetSetsForNewDayIfNeeded()
|
||||
workoutVM.checkPendingMissed()
|
||||
CloudSyncManager.shared.restoreSettings()
|
||||
CloudSyncManager.shared.backupSettings()
|
||||
HealthKitManager.shared.bootstrap()
|
||||
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
|
||||
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.
|
||||
Task {
|
||||
await workoutVM.syncFromHealthKit()
|
||||
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
|
||||
await AnalyticsService.shared.refreshHealthReference()
|
||||
AnalyticsService.shared.reconcile(using: workoutVM)
|
||||
}
|
||||
}
|
||||
.onChange(of: scenePhase) { phase in
|
||||
if phase == .active {
|
||||
taskVM.reload()
|
||||
consumeWidgetAddTask()
|
||||
workoutVM.resetSetsForNewDayIfNeeded()
|
||||
workoutVM.checkPendingHealthConfirm()
|
||||
workoutVM.checkPendingMissed()
|
||||
CloudSyncManager.shared.backupSettings()
|
||||
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
|
||||
Task { await workoutVM.syncFromHealthKit() }
|
||||
let generated = AnalyticsService.shared.reconcile(using: workoutVM)
|
||||
NotificationManager.shared.scheduleAnalyticsReports(generated)
|
||||
Task {
|
||||
await workoutVM.syncFromHealthKit()
|
||||
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: taskVM.tasks) { _ in
|
||||
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
.sheet(item: $workoutVM.askCompensate) { missed in
|
||||
CompensationSheet(missed: missed)
|
||||
.environmentObject(workoutVM)
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
.onChange(of: workoutVM.doneSets) { _ in
|
||||
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
|
||||
@@ -125,6 +170,20 @@ struct ContentView: View {
|
||||
.presentationDetents([.height(150)])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
.sheet(item: $analyticsLink) { link in
|
||||
AnalyticsView(initialLink: link)
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
#if DEBUG
|
||||
.onAppear {
|
||||
// Test hook: auto-open Quick Add (used to verify the voice→field binding
|
||||
// on the simulator, which has no microphone). Compiled out of Release.
|
||||
if ProcessInfo.processInfo.environment["KISANI_AUTOADD"] == "1" {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { showQuickAddTask = true }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
.onChange(of: showWorkoutTab) { _ in ShortcutHandler.registerShortcuts() }
|
||||
}
|
||||
|
||||
@@ -199,7 +258,7 @@ struct ContentView: View {
|
||||
if showWorkoutTab { Label("Workout", systemImage: "dumbbell").tag(3) }
|
||||
Label("Settings", systemImage: "gearshape").tag(4)
|
||||
}
|
||||
.navigationTitle("Kisani Cal")
|
||||
.navigationTitle("Wenza")
|
||||
.listStyle(.sidebar)
|
||||
} detail: {
|
||||
switch iPadSelection ?? 0 {
|
||||
|
||||
2692
KisaniCal/ISSUES.md
Normal file
@@ -10,5 +10,9 @@
|
||||
</array>
|
||||
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
||||
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.kutesir.KisaniCal</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
24
KisaniCal/LaunchScreen.storyboard
Normal file
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="22154" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
|
||||
<dependencies>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22130"/>
|
||||
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<scene sceneID="EHf-IW-A2E">
|
||||
<objects>
|
||||
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
|
||||
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
|
||||
<viewLayoutGuide key="safeArea" id="6Tk-OE-BBY"/>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="53" y="375"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
</document>
|
||||
10
KisaniCal/Managers/AppGroup.swift
Normal file
@@ -0,0 +1,10 @@
|
||||
import Foundation
|
||||
|
||||
let kisaniAppGroupID = "group.com.kutesir.KisaniCal"
|
||||
|
||||
extension UserDefaults {
|
||||
/// Shared container readable by both the main app and the widget extension.
|
||||
static var kisani: UserDefaults {
|
||||
UserDefaults(suiteName: kisaniAppGroupID) ?? .standard
|
||||
}
|
||||
}
|
||||
@@ -27,8 +27,13 @@ final class AuthManager: NSObject, ObservableObject {
|
||||
override init() {
|
||||
super.init()
|
||||
currentUser = loadUser()
|
||||
#if DEBUG
|
||||
if currentUser == nil, ProcessInfo.processInfo.environment["KISANI_PREVIEW_AUTH"] == "1" {
|
||||
currentUser = AppUser(id: "preview-user", email: "preview@kisani.app", displayName: "Preview")
|
||||
}
|
||||
#endif
|
||||
if let uid = currentUser?.id {
|
||||
UserDefaults.standard.set(uid, forKey: Self.activeUserIdKey)
|
||||
UserDefaults.kisani.set(uid, forKey: Self.activeUserIdKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +80,7 @@ final class AuthManager: NSObject, ObservableObject {
|
||||
|
||||
func signOut() {
|
||||
currentUser = nil
|
||||
UserDefaults.standard.removeObject(forKey: Self.activeUserIdKey)
|
||||
UserDefaults.kisani.removeObject(forKey: Self.activeUserIdKey)
|
||||
deleteKeychain(key: keychainUser)
|
||||
}
|
||||
|
||||
@@ -83,7 +88,7 @@ final class AuthManager: NSObject, ObservableObject {
|
||||
|
||||
private func persist(_ user: AppUser) {
|
||||
currentUser = user
|
||||
UserDefaults.standard.set(user.id, forKey: Self.activeUserIdKey)
|
||||
UserDefaults.kisani.set(user.id, forKey: Self.activeUserIdKey)
|
||||
if let data = try? JSONEncoder().encode(user) {
|
||||
saveKeychain(key: keychainUser, data: data)
|
||||
}
|
||||
|
||||
@@ -49,10 +49,10 @@ final class CloudSyncManager {
|
||||
/// Copy iCloud value into UserDefaults ONLY when UserDefaults has no value yet.
|
||||
/// This safely restores data after a fresh install or new build without overwriting live data.
|
||||
func restoreIfMissing(key: String) {
|
||||
guard UserDefaults.standard.object(forKey: key) == nil,
|
||||
guard UserDefaults.kisani.object(forKey: key) == nil,
|
||||
let icloudValue = kv.object(forKey: key)
|
||||
else { return }
|
||||
UserDefaults.standard.set(icloudValue, forKey: key)
|
||||
UserDefaults.kisani.set(icloudValue, forKey: key)
|
||||
}
|
||||
|
||||
// MARK: - Bulk helpers
|
||||
@@ -60,7 +60,7 @@ final class CloudSyncManager {
|
||||
/// Backup all small settings keys from UserDefaults → iCloud
|
||||
func backupSettings() {
|
||||
for key in settingsKeys {
|
||||
if let val = UserDefaults.standard.object(forKey: key) {
|
||||
if let val = UserDefaults.kisani.object(forKey: key) {
|
||||
kv.set(val, forKey: key)
|
||||
}
|
||||
}
|
||||
@@ -84,8 +84,18 @@ final class CloudSyncManager {
|
||||
else { return }
|
||||
|
||||
for key in changedKeys {
|
||||
if let val = kv.object(forKey: key) {
|
||||
UserDefaults.standard.set(val, forKey: key)
|
||||
guard let val = kv.object(forKey: key) else { continue }
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,11 @@ final class HealthKitManager: ObservableObject {
|
||||
|
||||
var isAvailable: Bool { HKHealthStore.isHealthDataAvailable() }
|
||||
|
||||
// Persists across launches: HealthKit can't report read-authorization status,
|
||||
// so we remember that the user opted in and re-establish on launch.
|
||||
private let connectedKey = "kisani.health.connected"
|
||||
var isConnected: Bool { UserDefaults.kisani.bool(forKey: connectedKey) }
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Auth
|
||||
@@ -23,6 +28,7 @@ final class HealthKitManager: ObservableObject {
|
||||
do {
|
||||
try await store.requestAuthorization(toShare: shareTypes, read: readTypes)
|
||||
authorized = true
|
||||
UserDefaults.kisani.set(true, forKey: connectedKey)
|
||||
await refresh()
|
||||
return true
|
||||
} catch {
|
||||
@@ -30,6 +36,23 @@ final class HealthKitManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-establish authorization on app launch if the user previously connected Health.
|
||||
func bootstrap() {
|
||||
guard isAvailable, isConnected else { return }
|
||||
authorized = true
|
||||
Task { await refresh() }
|
||||
}
|
||||
|
||||
/// Turn the integration off: hides the dashboard and stops Health reads.
|
||||
func disconnect() {
|
||||
authorized = false
|
||||
UserDefaults.kisani.set(false, forKey: connectedKey)
|
||||
stepsToday = 0
|
||||
activeCaloriesToday = 0
|
||||
restingHeartRate = 0
|
||||
workoutsThisWeek = 0
|
||||
}
|
||||
|
||||
// MARK: - Refresh all stats
|
||||
|
||||
func refresh() async {
|
||||
@@ -137,6 +160,51 @@ 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
|
||||
|
||||
private var readTypes: Set<HKObjectType> {
|
||||
|
||||
75
KisaniCal/Managers/LiveActivityManager.swift
Normal file
@@ -0,0 +1,75 @@
|
||||
import Foundation
|
||||
import ActivityKit
|
||||
|
||||
/// Starts / ends a Live Activity for a task. iOS 16.1+ only; no-ops otherwise.
|
||||
enum LiveActivityManager {
|
||||
|
||||
/// Toggle: if the task already has a Live Activity, end it; else start one.
|
||||
static func toggle(for task: TaskItem) {
|
||||
guard #available(iOS 16.1, *) else { return }
|
||||
if isActive(taskID: task.id.uuidString) {
|
||||
end(taskID: task.id.uuidString)
|
||||
} else {
|
||||
start(for: task)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a Live Activity is currently running for this task.
|
||||
@available(iOS 16.1, *)
|
||||
static func isActive(taskID: String) -> Bool {
|
||||
Activity<TaskActivityAttributes>.activities.contains { $0.attributes.taskID == taskID }
|
||||
}
|
||||
|
||||
@available(iOS 16.1, *)
|
||||
static func start(for task: TaskItem) {
|
||||
guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
|
||||
let attributes = TaskActivityAttributes(
|
||||
taskID: task.id.uuidString,
|
||||
title: task.title,
|
||||
categoryRaw: task.category.rawValue
|
||||
)
|
||||
let state = TaskActivityAttributes.ContentState(
|
||||
dueDate: task.dueDate,
|
||||
isComplete: task.isComplete,
|
||||
priorityRaw: task.priority.rawValue
|
||||
)
|
||||
do {
|
||||
_ = try Activity.request(attributes: attributes, contentState: state, pushType: nil)
|
||||
} catch {
|
||||
print("LiveActivity start failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Push updated state to a running activity (e.g. after edit/complete).
|
||||
@available(iOS 16.1, *)
|
||||
static func update(for task: TaskItem) {
|
||||
let state = TaskActivityAttributes.ContentState(
|
||||
dueDate: task.dueDate,
|
||||
isComplete: task.isComplete,
|
||||
priorityRaw: task.priority.rawValue
|
||||
)
|
||||
Task {
|
||||
for activity in Activity<TaskActivityAttributes>.activities
|
||||
where activity.attributes.taskID == task.id.uuidString {
|
||||
await activity.update(using: state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.1, *)
|
||||
static func end(taskID: String) {
|
||||
Task {
|
||||
for activity in Activity<TaskActivityAttributes>.activities
|
||||
where activity.attributes.taskID == taskID {
|
||||
// end(_:dismissalPolicy:) needs iOS 16.2; this file supports
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import SwiftUI
|
||||
import UserNotifications
|
||||
import WidgetKit
|
||||
|
||||
final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable {
|
||||
static let shared = NotificationManager()
|
||||
@@ -14,8 +15,15 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
|
||||
|
||||
private static let categoryId = "TASK_REMINDER"
|
||||
private static let actionMarkId = "MARK_COMPLETE"
|
||||
private static let taskSnoozePrefix = "TASK_SNOOZE_"
|
||||
private static let confirmCatId = "WORKOUT_CONFIRM"
|
||||
private static let logWorkoutActId = "LOG_WORKOUT"
|
||||
private static let missedWorkoutActId = "MISSED_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() {
|
||||
super.init()
|
||||
@@ -26,21 +34,40 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
|
||||
|
||||
private func registerCategories() {
|
||||
let markAction = UNNotificationAction(identifier: Self.actionMarkId, title: "Mark Complete", options: [])
|
||||
let taskCat = UNNotificationCategory(identifier: Self.categoryId, actions: [markAction],
|
||||
let taskSnoozeActions = [
|
||||
UNNotificationAction(identifier: Self.taskSnoozePrefix + "15", title: "Snooze 15 min", options: []),
|
||||
UNNotificationAction(identifier: Self.taskSnoozePrefix + "30", title: "Snooze 30 min", options: []),
|
||||
UNNotificationAction(identifier: Self.taskSnoozePrefix + "60", title: "Snooze 1 hour", options: []),
|
||||
UNNotificationAction(identifier: Self.taskSnoozePrefix + "120", title: "Snooze 2 hours", options: [])
|
||||
]
|
||||
let taskCat = UNNotificationCategory(identifier: Self.categoryId, actions: [markAction] + taskSnoozeActions,
|
||||
intentIdentifiers: [], options: .customDismissAction)
|
||||
|
||||
let logAction = UNNotificationAction(identifier: Self.logWorkoutActId,
|
||||
title: "Yes, log it ✓", options: [])
|
||||
let confirmCat = UNNotificationCategory(identifier: Self.confirmCatId, actions: [logAction],
|
||||
title: "Yes, I completed it", options: [])
|
||||
let missedAction = UNNotificationAction(identifier: Self.missedWorkoutActId,
|
||||
title: "No, I didn't", options: [])
|
||||
let snoozeAction = UNNotificationAction(identifier: Self.snoozeWorkoutActId,
|
||||
title: "Remind me in 1 hour", options: [])
|
||||
let confirmCat = UNNotificationCategory(identifier: Self.confirmCatId,
|
||||
actions: [logAction, missedAction, snoozeAction],
|
||||
intentIdentifiers: [], options: .customDismissAction)
|
||||
|
||||
center.setNotificationCategories([taskCat, confirmCat])
|
||||
let habitDoneAction = UNNotificationAction(identifier: Self.habitDoneActId, title: "Done", options: [])
|
||||
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)
|
||||
private func markTaskComplete(taskId: String, userId: String) {
|
||||
let key = "kisani.tasks.v2.\(userId)"
|
||||
guard let data = UserDefaults.standard.data(forKey: key),
|
||||
guard let data = UserDefaults.kisani.data(forKey: key),
|
||||
var tasks = try? JSONDecoder().decode([TaskItem].self, from: data),
|
||||
let uuid = UUID(uuidString: taskId),
|
||||
let idx = tasks.firstIndex(where: { $0.id == uuid })
|
||||
@@ -48,10 +75,37 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
|
||||
tasks[idx].isComplete = true
|
||||
tasks[idx].completedAt = Date()
|
||||
if let encoded = try? JSONEncoder().encode(tasks) {
|
||||
UserDefaults.standard.set(encoded, forKey: key)
|
||||
UserDefaults.kisani.set(encoded, forKey: key)
|
||||
CloudSyncManager.shared.push(data: encoded, forKey: key) // sync to iCloud / Watch
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
}
|
||||
|
||||
private func snoozeTask(taskId: String, userId: String, minutes: Int, content: UNNotificationContent) {
|
||||
let key = "kisani.tasks.v2.\(userId)"
|
||||
let fireDate = Date().addingTimeInterval(TimeInterval(minutes * 60))
|
||||
if let data = UserDefaults.kisani.data(forKey: key),
|
||||
var tasks = try? JSONDecoder().decode([TaskItem].self, from: data),
|
||||
let uuid = UUID(uuidString: taskId),
|
||||
let idx = tasks.firstIndex(where: { $0.id == uuid }) {
|
||||
tasks[idx].reminderDate = fireDate
|
||||
if let encoded = try? JSONEncoder().encode(tasks) {
|
||||
UserDefaults.kisani.set(encoded, forKey: key)
|
||||
CloudSyncManager.shared.push(data: encoded, forKey: key) // sync to iCloud / Watch
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
}
|
||||
|
||||
let mutable = content.mutableCopy() as! UNMutableNotificationContent
|
||||
mutable.categoryIdentifier = Self.categoryId
|
||||
mutable.userInfo = content.userInfo
|
||||
center.add(UNNotificationRequest(
|
||||
identifier: "kisani.task.snooze.\(taskId).\(minutes).\(UUID().uuidString)",
|
||||
content: mutable,
|
||||
trigger: UNTimeIntervalNotificationTrigger(timeInterval: TimeInterval(minutes * 60), repeats: false)
|
||||
))
|
||||
}
|
||||
|
||||
// MARK: - Permission
|
||||
|
||||
func requestPermission() {
|
||||
@@ -77,20 +131,388 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
|
||||
let schedule = workoutVM.schedule
|
||||
let programs = workoutVM.programs
|
||||
let progress = workoutVM.progress
|
||||
let recorded = workoutVM.isTodayRecorded
|
||||
let tasks = taskVM.tasks
|
||||
// Live urgency (computed Matrix quadrant), not the stored fallback, so notification
|
||||
// copy reflects tasks that have become urgent as their dates approached.
|
||||
let urgentIds = Set(tasks.filter { taskVM.displayQuadrant($0) == .urgent }.map { $0.id })
|
||||
let calEvents = CalendarStore.shared.upcomingEventNotifs(days: 7)
|
||||
let compensations = workoutVM.compensations
|
||||
|
||||
center.getNotificationSettings { [weak self] settings in
|
||||
guard let self else { return }
|
||||
let ok = settings.authorizationStatus == .authorized
|
||||
|| settings.authorizationStatus == .provisional
|
||||
guard ok else { return }
|
||||
self.scheduleWorkout(schedule: schedule, programs: programs, progress: progress)
|
||||
self.scheduleWorkout(schedule: schedule, programs: programs, progress: progress, recordedToday: recorded)
|
||||
self.scheduleWorkoutConfirm(schedule: schedule, programs: programs)
|
||||
self.scheduleTasks(snapshot: tasks)
|
||||
self.scheduleCompensations(compensations, programs: programs)
|
||||
self.scheduleTasks(snapshot: tasks, urgentIds: urgentIds)
|
||||
self.scheduleCalendarEvents(snapshot: calEvents)
|
||||
self.schedulePeriodMilestones()
|
||||
self.scheduleHabitReminders()
|
||||
self.updateBadge(snapshot: tasks)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Period milestones ("one more week / month / year passed")
|
||||
|
||||
private static let periodIds = ["kisani.period.day", "kisani.period.week", "kisani.period.month", "kisani.period.year"]
|
||||
|
||||
/// Recurring nudges at the close of each week, month, and year. Fixed identifiers
|
||||
/// + remove-then-add keep them de-duplicated across reschedules. Disabled (and
|
||||
/// cleared) when the user turns off the "Period milestones" setting.
|
||||
private func schedulePeriodMilestones() {
|
||||
center.removePendingNotificationRequests(withIdentifiers: Self.periodIds)
|
||||
guard UserDefaults.kisani.object(forKey: "kisani.periodMilestones") as? Bool ?? true else { return }
|
||||
|
||||
func add(_ id: String, _ title: String, _ body: String, _ comps: DateComponents) {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = title
|
||||
content.body = body
|
||||
content.sound = .default
|
||||
center.add(UNNotificationRequest(
|
||||
identifier: id, content: content,
|
||||
trigger: UNCalendarNotificationTrigger(dateMatching: comps, repeats: true)))
|
||||
}
|
||||
|
||||
// Every midnight → a new day began
|
||||
var day = DateComponents(); day.hour = 0; day.minute = 0
|
||||
add(Self.periodIds[0], "Today", "One more day passed.", day)
|
||||
|
||||
// Start of the week (Mon 00:00) → the week that just ended
|
||||
var week = DateComponents(); week.weekday = 2; week.hour = 0; week.minute = 0
|
||||
add(Self.periodIds[1], "This Week", "One more week passed.", week)
|
||||
|
||||
// 1st of the month, 00:00 → the month that just ended
|
||||
var month = DateComponents(); month.day = 1; month.hour = 0; month.minute = 0
|
||||
add(Self.periodIds[2], "This Month", "One more month passed.", month)
|
||||
|
||||
// Jan 1, 00:00 → the year that just ended
|
||||
var year = DateComponents(); year.month = 1; year.day = 1; year.hour = 0; year.minute = 0
|
||||
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
|
||||
|
||||
/// Posts a quiet "✓ Done. Repeats …" confirmation when a recurring task's
|
||||
/// occurrence is completed, telling the user when it next comes around.
|
||||
func notifyRecurringCompleted(title: String, nextOccurrence: Date?) {
|
||||
center.getNotificationSettings { [weak self] settings in
|
||||
guard let self,
|
||||
settings.authorizationStatus == .authorized
|
||||
|| settings.authorizationStatus == .provisional else { return }
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = title
|
||||
content.body = nextOccurrence.map { "Done. Repeats \(Self.relativeRepeat(to: $0))." } ?? "Done."
|
||||
content.sound = nil // quiet confirmation, not an alert
|
||||
self.center.add(UNNotificationRequest(
|
||||
identifier: "kisani.recurdone.\(UUID().uuidString)",
|
||||
content: content,
|
||||
trigger: UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)))
|
||||
}
|
||||
}
|
||||
|
||||
/// "tomorrow", "in 1 week", "in 3 days" … relative to today.
|
||||
static func relativeRepeat(to date: Date) -> String {
|
||||
let cal = Calendar.current
|
||||
let days = cal.dateComponents([.day], from: cal.startOfDay(for: Date()),
|
||||
to: cal.startOfDay(for: date)).day ?? 0
|
||||
switch days {
|
||||
case ..<1: return "again today"
|
||||
case 1: return "tomorrow"
|
||||
case 2...6: return "in \(days) days"
|
||||
case 7: return "in 1 week"
|
||||
default:
|
||||
if days % 7 == 0 { let w = days / 7; return "in \(w) week\(w == 1 ? "" : "s")" }
|
||||
if days % 30 == 0 { let m = days / 30; return "in \(m) month\(m == 1 ? "" : "s")" }
|
||||
return "in \(days) days"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Compensation workout reminders (one-off, on chosen rest days)
|
||||
|
||||
private func scheduleCompensations(_ comps: [String: String], programs: [WorkoutProgram]) {
|
||||
center.getPendingNotificationRequests { [weak self] pending in
|
||||
guard let self else { return }
|
||||
let old = pending.filter { $0.identifier.hasPrefix("kisani.workout.comp.") }.map { $0.identifier }
|
||||
self.center.removePendingNotificationRequests(withIdentifiers: old)
|
||||
|
||||
let ud = UserDefaults.kisani
|
||||
let rHour = ud.object(forKey: "workoutHour") as? Int ?? 18
|
||||
let rMinute = ud.object(forKey: "workoutMinute") as? Int ?? 0
|
||||
let fmt = DateFormatter(); fmt.dateFormat = "yyyy-MM-dd"
|
||||
let cal = Calendar.current
|
||||
|
||||
for (dateStr, pidStr) in comps {
|
||||
guard let day = fmt.date(from: dateStr), day > Date().addingTimeInterval(-86400),
|
||||
let pid = UUID(uuidString: pidStr),
|
||||
let program = programs.first(where: { $0.id == pid }) else { continue }
|
||||
var comps = cal.dateComponents([.year, .month, .day], from: day)
|
||||
comps.hour = rHour; comps.minute = rMinute
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = "\(program.name) — Compensation"
|
||||
content.body = "Missed workout recovery — time to make it up!"
|
||||
content.sound = .default
|
||||
content.categoryIdentifier = Self.confirmCatId
|
||||
content.userInfo = ["deeplink": "workout"]
|
||||
self.center.add(UNNotificationRequest(
|
||||
identifier: "kisani.workout.comp.\(dateStr)",
|
||||
content: content,
|
||||
trigger: UNCalendarNotificationTrigger(dateMatching: comps, repeats: false)
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Calendar event notifications
|
||||
|
||||
/// Notifies for visible calendar events: at start time ("on time") plus each
|
||||
/// alarm the user set on the event. Capped to stay under iOS's 64-notification
|
||||
/// limit. Recurring instances stay unique via their start time in the id.
|
||||
private func scheduleCalendarEvents(snapshot: [CalEventNotif]) {
|
||||
center.getPendingNotificationRequests { [weak self] pending in
|
||||
guard let self else { return }
|
||||
let old = pending.filter { $0.identifier.hasPrefix("kisani.calevent.") }.map { $0.identifier }
|
||||
self.center.removePendingNotificationRequests(withIdentifiers: old)
|
||||
|
||||
let now = Date()
|
||||
let cal = Calendar.current
|
||||
var budget = 20 // leave headroom under the 64 pending-notification ceiling
|
||||
|
||||
for ev in snapshot where budget > 0 {
|
||||
var fireTimes: [Date] = []
|
||||
if ev.isAllDay {
|
||||
var c = cal.dateComponents([.year, .month, .day], from: ev.start)
|
||||
c.hour = 9; c.minute = 0
|
||||
if let d = cal.date(from: c) { fireTimes.append(d) } // 9am morning-of
|
||||
} else {
|
||||
fireTimes.append(ev.start) // on time
|
||||
for off in ev.alarmOffsets { fireTimes.append(ev.start.addingTimeInterval(off)) }
|
||||
}
|
||||
|
||||
let times = Array(Set(fireTimes)).filter { $0 > now }.sorted()
|
||||
for (i, t) in times.enumerated() where budget > 0 {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = ev.title
|
||||
content.body = self.calEventBody(fire: t, start: ev.start, isAllDay: ev.isAllDay)
|
||||
content.sound = .default
|
||||
content.userInfo = ["deeplink": "calendar"]
|
||||
let comps = cal.dateComponents([.year, .month, .day, .hour, .minute], from: t)
|
||||
let stamp = Int(ev.start.timeIntervalSince1970)
|
||||
self.center.add(UNNotificationRequest(
|
||||
identifier: "kisani.calevent.\(ev.id).\(stamp).\(i)",
|
||||
content: content,
|
||||
trigger: UNCalendarNotificationTrigger(dateMatching: comps, repeats: false)
|
||||
))
|
||||
budget -= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func calEventBody(fire: Date, start: Date, isAllDay: Bool) -> String {
|
||||
if isAllDay { return "Today" }
|
||||
let delta = start.timeIntervalSince(fire)
|
||||
if delta <= 60 { return "Starting now" }
|
||||
let mins = Int((delta / 60).rounded())
|
||||
if mins < 60 { return "Starts in \(mins) min" }
|
||||
let hrs = mins / 60
|
||||
return "Starts in \(hrs)h\(mins % 60 == 0 ? "" : " \(mins % 60)m")"
|
||||
}
|
||||
|
||||
// MARK: - Badge
|
||||
|
||||
private func updateBadge(snapshot: [TaskItem]) {
|
||||
@@ -105,9 +527,10 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
|
||||
|
||||
// MARK: - Workout reminders + check-in
|
||||
|
||||
private func scheduleWorkout(schedule: [Int: UUID], programs: [WorkoutProgram], progress: Double) {
|
||||
let ud = UserDefaults.standard
|
||||
let existingIds = (1...7).flatMap { ["\(workoutPrefix).\($0)", "\(checkInPrefix).\($0)"] }
|
||||
private func scheduleWorkout(schedule: [Int: UUID], programs: [WorkoutProgram], progress: Double, recordedToday: Bool) {
|
||||
let ud = UserDefaults.kisani
|
||||
let markCompleteId = "kisani.workout.markcomplete"
|
||||
let existingIds = (1...7).flatMap { ["\(workoutPrefix).\($0)", "\(checkInPrefix).\($0)"] } + [markCompleteId]
|
||||
center.removePendingNotificationRequests(withIdentifiers: existingIds)
|
||||
|
||||
let reminderOn = ud.object(forKey: "workoutReminderEnabled") as? Bool ?? true
|
||||
@@ -118,15 +541,20 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
|
||||
let cMinute = ud.object(forKey: "checkInMinute") as? Int ?? 30
|
||||
let todayWD = Calendar.current.component(.weekday, from: Date())
|
||||
|
||||
// In-app completion vs. "recorded" (in-app OR detected from Apple Health).
|
||||
let inAppDoneToday = progress >= 1.0
|
||||
|
||||
for (weekday, pid) in schedule {
|
||||
guard let program = programs.first(where: { $0.id == pid }) else { continue }
|
||||
let doneToday = weekday == todayWD && progress >= 1.0
|
||||
// Suppress "time to work out" if today is already done by either path.
|
||||
let doneToday = weekday == todayWD && (inAppDoneToday || recordedToday)
|
||||
|
||||
if reminderOn && !doneToday {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = program.name
|
||||
content.body = "Time to work out — let's go!"
|
||||
content.sound = .default
|
||||
content.categoryIdentifier = Self.confirmCatId // Yes / No / Snooze
|
||||
content.userInfo = ["deeplink": "workout"]
|
||||
var comps = DateComponents()
|
||||
comps.weekday = weekday; comps.hour = rHour; comps.minute = rMinute
|
||||
@@ -140,8 +568,9 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
|
||||
if checkInOn {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = "\(program.name) — check in"
|
||||
content.body = doneToday ? "Great work today!" : "Did you finish? Mark your sets complete."
|
||||
content.body = doneToday ? "Great work today!" : "Have you done this workout?"
|
||||
content.sound = .default
|
||||
content.categoryIdentifier = Self.confirmCatId // Yes / No / Snooze
|
||||
content.userInfo = ["deeplink": "workout"]
|
||||
var comps = DateComponents()
|
||||
comps.weekday = weekday; comps.hour = cHour; comps.minute = cMinute
|
||||
@@ -152,12 +581,34 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// Mark-complete nudge: Apple Health recorded a workout today (streak is safe),
|
||||
// but the user hasn't marked their exercises complete in the app yet.
|
||||
if recordedToday && !inAppDoneToday {
|
||||
let cal = Calendar.current
|
||||
var fireComps = cal.dateComponents([.year, .month, .day], from: Date())
|
||||
fireComps.hour = rHour; fireComps.minute = rMinute
|
||||
var fire = cal.date(from: fireComps) ?? Date()
|
||||
if fire <= Date() { fire = Date().addingTimeInterval(3600) } // reminder time passed → nudge in 1h
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = "Mark your workout complete"
|
||||
content.body = "We logged today's workout from Apple Health — mark your exercises complete to update your log."
|
||||
content.sound = .default
|
||||
content.userInfo = ["deeplink": "workout"]
|
||||
let trigComps = cal.dateComponents([.year, .month, .day, .hour, .minute], from: fire)
|
||||
center.add(UNNotificationRequest(
|
||||
identifier: markCompleteId,
|
||||
content: content,
|
||||
trigger: UNCalendarNotificationTrigger(dateMatching: trigComps, repeats: false)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Workout completion confirmation
|
||||
|
||||
private func scheduleWorkoutConfirm(schedule: [Int: UUID], programs: [WorkoutProgram]) {
|
||||
let ud = UserDefaults.standard
|
||||
let ud = UserDefaults.kisani
|
||||
guard ud.object(forKey: "workoutConfirmEnabled") as? Bool ?? false else {
|
||||
center.removePendingNotificationRequests(withIdentifiers: (1...7).map { "\(confirmPrefix).\($0)" })
|
||||
return
|
||||
@@ -186,7 +637,7 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
|
||||
|
||||
// MARK: - Per-task reminders + evening check-in
|
||||
|
||||
private func scheduleTasks(snapshot: [TaskItem]) {
|
||||
private func scheduleTasks(snapshot: [TaskItem], urgentIds: Set<UUID>) {
|
||||
let center = self.center
|
||||
|
||||
center.getPendingNotificationRequests { [weak self] pending in
|
||||
@@ -217,26 +668,32 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
guard fireDate > now else { continue }
|
||||
// A one-off reminder in the past is dropped; a constant (daily-repeating)
|
||||
// reminder still schedules so it keeps nudging on its next occurrence.
|
||||
guard task.constantReminder || fireDate > now else { continue }
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = task.title
|
||||
content.body = self.notifBody(for: task)
|
||||
content.body = self.notifBody(for: task, isUrgent: urgentIds.contains(task.id))
|
||||
content.sound = .default
|
||||
content.categoryIdentifier = Self.categoryId
|
||||
content.userInfo = ["taskId": task.id.uuidString, "deeplink": "tasks"]
|
||||
|
||||
let trigComps = cal.dateComponents([.year, .month, .day, .hour, .minute], from: fireDate)
|
||||
// Constant reminders re-fire daily (at the reminder time) until the
|
||||
// task is completed; one-off reminders match the full date.
|
||||
let trigComps = task.constantReminder
|
||||
? cal.dateComponents([.hour, .minute], from: fireDate)
|
||||
: cal.dateComponents([.year, .month, .day, .hour, .minute], from: fireDate)
|
||||
center.add(UNNotificationRequest(
|
||||
identifier: "kisani.task.\(task.id.uuidString)",
|
||||
content: content,
|
||||
trigger: UNCalendarNotificationTrigger(dateMatching: trigComps, repeats: false)
|
||||
trigger: UNCalendarNotificationTrigger(dateMatching: trigComps, repeats: task.constantReminder)
|
||||
))
|
||||
}
|
||||
|
||||
// No count in body — repeating notifications bake in the value
|
||||
// at schedule time and go stale until the app is next opened.
|
||||
guard snapshot.contains(where: { !$0.isComplete && $0.quadrant == .urgent }) else { return }
|
||||
guard snapshot.contains(where: { !$0.isComplete && urgentIds.contains($0.id) }) else { return }
|
||||
let ec = UNMutableNotificationContent()
|
||||
ec.title = "Evening check-in"
|
||||
ec.body = "You still have urgent tasks open — tap to review."
|
||||
@@ -251,12 +708,12 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
|
||||
}
|
||||
}
|
||||
|
||||
private func notifBody(for task: TaskItem) -> String {
|
||||
private func notifBody(for task: TaskItem, isUrgent: Bool) -> String {
|
||||
switch task.category {
|
||||
case .birthday: return "Birthday today"
|
||||
case .domain: return "Domain renewal coming up"
|
||||
case .annual: return "Annual reminder"
|
||||
default: return task.quadrant == .urgent ? "Urgent — tap to mark complete" : "Task reminder"
|
||||
default: return isUrgent ? "Urgent — tap to mark complete" : "Task reminder"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -277,20 +734,59 @@ extension NotificationManager: UNUserNotificationCenterDelegate {
|
||||
didReceive response: UNNotificationResponse,
|
||||
withCompletionHandler completionHandler: @escaping () -> Void
|
||||
) {
|
||||
let userId = UserDefaults.standard.string(forKey: AuthManager.activeUserIdKey) ?? "shared"
|
||||
let userId = UserDefaults.kisani.string(forKey: AuthManager.activeUserIdKey) ?? "shared"
|
||||
let info = response.notification.request.content.userInfo
|
||||
let deeplink = info["deeplink"] as? String
|
||||
let taskId = info["taskId"] as? String
|
||||
|
||||
// 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 {
|
||||
|
||||
case Self.actionMarkId:
|
||||
if let taskId { markTaskComplete(taskId: taskId, userId: userId) }
|
||||
|
||||
case let action where action.hasPrefix(Self.taskSnoozePrefix):
|
||||
if let taskId,
|
||||
let minutes = Int(action.replacingOccurrences(of: Self.taskSnoozePrefix, with: "")) {
|
||||
snoozeTask(taskId: taskId, userId: userId, minutes: minutes,
|
||||
content: response.notification.request.content)
|
||||
}
|
||||
|
||||
case Self.logWorkoutActId:
|
||||
// "Yes, log it" from workout confirm notification — store date for WorkoutViewModel to pick up on next foreground
|
||||
let fmt = DateFormatter(); fmt.dateFormat = "yyyy-MM-dd"
|
||||
UserDefaults.standard.set(fmt.string(from: Date()), forKey: "kisani.workout.pendingConfirm")
|
||||
UserDefaults.kisani.set(fmt.string(from: Date()), forKey: "kisani.workout.pendingConfirm")
|
||||
|
||||
case Self.missedWorkoutActId:
|
||||
// "No, I didn't" — mark the day missed; the app asks about compensation on next open.
|
||||
let fmt = DateFormatter(); fmt.dateFormat = "yyyy-MM-dd"
|
||||
UserDefaults.kisani.set(fmt.string(from: Date()), forKey: "kisani.workout.pendingMissed")
|
||||
|
||||
case Self.snoozeWorkoutActId:
|
||||
// Re-deliver this same check-in in one hour.
|
||||
let content = response.notification.request.content.mutableCopy() as! UNMutableNotificationContent
|
||||
center.add(UNNotificationRequest(
|
||||
identifier: "kisani.workout.snooze.\(UUID().uuidString)",
|
||||
content: content,
|
||||
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:
|
||||
if let taskId { markTaskComplete(taskId: taskId, userId: userId) }
|
||||
|
||||
171
KisaniCal/Managers/SpeechRecognizer.swift
Normal file
@@ -0,0 +1,171 @@
|
||||
import Foundation
|
||||
import Speech
|
||||
import AVFoundation
|
||||
|
||||
@MainActor
|
||||
final class SpeechRecognizer: ObservableObject {
|
||||
@Published var transcript = ""
|
||||
@Published var isRecording = false
|
||||
@Published var error: String?
|
||||
|
||||
private var recognizer: SFSpeechRecognizer?
|
||||
private var audioEngine = AVAudioEngine()
|
||||
private var request: SFSpeechAudioBufferRecognitionRequest?
|
||||
private var task: SFSpeechRecognitionTask?
|
||||
|
||||
init() {
|
||||
recognizer = SFSpeechRecognizer(locale: .current)
|
||||
}
|
||||
|
||||
func toggle() {
|
||||
isRecording ? stop() : start()
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard !isRecording else { return }
|
||||
error = nil
|
||||
print("[VOICE] Button tapped")
|
||||
|
||||
SFSpeechRecognizer.requestAuthorization { [weak self] status in
|
||||
DispatchQueue.main.async {
|
||||
guard let self else { return }
|
||||
switch status {
|
||||
case .authorized:
|
||||
print("[VOICE] Permission granted — speech recognition")
|
||||
self.requestMicThenRecord()
|
||||
case .denied, .restricted:
|
||||
self.error = "Speech recognition access denied. Enable it in Settings → Wenza."
|
||||
print("[VOICE] Permission denied — speech recognition")
|
||||
case .notDetermined:
|
||||
self.error = "Speech recognition permission not yet determined."
|
||||
print("[VOICE] Permission not determined")
|
||||
@unknown default: break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func requestMicThenRecord() {
|
||||
AVAudioSession.sharedInstance().requestRecordPermission { [weak self] granted in
|
||||
DispatchQueue.main.async {
|
||||
guard let self else { return }
|
||||
if granted {
|
||||
print("[VOICE] Permission granted — microphone")
|
||||
self.startEngine()
|
||||
} else {
|
||||
self.error = "Microphone access denied. Enable it in Settings → Wenza."
|
||||
print("[VOICE] Permission denied — microphone")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startEngine() {
|
||||
// Defensive teardown: a tap left over from a previous (failed) attempt makes
|
||||
// installTap throw "already has a tap" → "Could not start recording".
|
||||
audioEngine.inputNode.removeTap(onBus: 0)
|
||||
if audioEngine.isRunning { audioEngine.stop() }
|
||||
audioEngine.reset()
|
||||
|
||||
do {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
// Release any session left active by a prior attempt — a stuck-active
|
||||
// session causes error 561017449 ('!pri', insufficientPriority) and can
|
||||
// also block the keyboard's own dictation from grabbing the mic.
|
||||
try? session.setActive(false, options: .notifyOthersOnDeactivation)
|
||||
// Plain .record/.default is the least-exclusive recording config; the
|
||||
// earlier .measurement + .duckOthers combo is more prone to priority denials.
|
||||
try session.setCategory(.record, mode: .default)
|
||||
try session.setActive(true, options: .notifyOthersOnDeactivation)
|
||||
|
||||
guard let recognizer, recognizer.isAvailable else {
|
||||
error = "Speech recognition is not available right now."
|
||||
print("[VOICE] Recognizer unavailable")
|
||||
stopEngine() // release the session we just activated
|
||||
return
|
||||
}
|
||||
|
||||
// Use the input node's actual hardware format. A 0-sample-rate format
|
||||
// (no usable input — e.g. a simulator with no audio input) would make
|
||||
// installTap throw, so fail with a clear message instead.
|
||||
let node = audioEngine.inputNode
|
||||
let fmt = node.inputFormat(forBus: 0)
|
||||
guard fmt.sampleRate > 0, fmt.channelCount > 0 else {
|
||||
error = "No microphone input available on this device."
|
||||
print("[VOICE] Invalid input format: \(fmt)")
|
||||
stopEngine() // release the session we just activated
|
||||
return
|
||||
}
|
||||
|
||||
let req = SFSpeechAudioBufferRecognitionRequest()
|
||||
req.shouldReportPartialResults = true
|
||||
self.request = req
|
||||
|
||||
task = recognizer.recognitionTask(with: req) { [weak self] result, err in
|
||||
DispatchQueue.main.async {
|
||||
guard let self else { return }
|
||||
if let result {
|
||||
let text = result.bestTranscription.formattedString
|
||||
self.transcript = text
|
||||
print("[VOICE] Transcript received: \"\(text)\"")
|
||||
}
|
||||
if result?.isFinal == true {
|
||||
print("[VOICE] Recognition finished (final)")
|
||||
self.stopEngine()
|
||||
} else if let err {
|
||||
let code = (err as NSError).code
|
||||
// Code 301 = "no speech" — not an error worth surfacing
|
||||
if code != 301 {
|
||||
print("[VOICE] Recognition error \(code): \(err.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
node.installTap(onBus: 0, bufferSize: 1024, format: fmt) { [weak self] buf, _ in
|
||||
self?.request?.append(buf)
|
||||
}
|
||||
|
||||
audioEngine.prepare()
|
||||
try audioEngine.start()
|
||||
isRecording = true
|
||||
print("[VOICE] Recording started")
|
||||
|
||||
} catch {
|
||||
let ns = error as NSError
|
||||
// 561017449 = '!pri' = AVAudioSession insufficientPriority: another app/
|
||||
// process holds the mic, or the audio system is in a stuck state.
|
||||
if ns.code == 561017449 {
|
||||
self.error = "Microphone is busy. Close other audio apps (calls, Voice Memos), then try again. If it persists, restart your phone."
|
||||
} else {
|
||||
self.error = "Could not start recording. (\(ns.code))"
|
||||
}
|
||||
print("[VOICE] Engine start failed: \(ns.domain) \(ns.code) — \(error.localizedDescription)")
|
||||
stopEngine()
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
print("[VOICE] Recording stopped")
|
||||
stopEngine()
|
||||
}
|
||||
|
||||
private func stopEngine() {
|
||||
if audioEngine.isRunning { audioEngine.stop() }
|
||||
audioEngine.inputNode.removeTap(onBus: 0)
|
||||
request?.endAudio()
|
||||
task?.finish()
|
||||
request = nil
|
||||
task = nil
|
||||
isRecording = false
|
||||
// Always release the audio session, even on a failed/partial start — a
|
||||
// session left active denies the mic to us and to keyboard dictation.
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
}
|
||||
|
||||
func reset() {
|
||||
stopEngine()
|
||||
transcript = ""
|
||||
error = nil
|
||||
}
|
||||
}
|
||||
18
KisaniCal/Managers/TaskActivityAttributes.swift
Normal file
@@ -0,0 +1,18 @@
|
||||
import Foundation
|
||||
import ActivityKit
|
||||
|
||||
// Shared between the app (which starts/ends the activity) and the widget
|
||||
// extension (which renders it). Added to both targets in project.yml.
|
||||
@available(iOS 16.1, *)
|
||||
struct TaskActivityAttributes: ActivityAttributes {
|
||||
public struct ContentState: Codable, Hashable {
|
||||
var dueDate: Date?
|
||||
var isComplete: Bool
|
||||
var priorityRaw: String
|
||||
}
|
||||
|
||||
// Static identity for the life of the activity.
|
||||
var taskID: String
|
||||
var title: String
|
||||
var categoryRaw: String
|
||||
}
|
||||
@@ -23,7 +23,7 @@ final class TutorialManager: ObservableObject {
|
||||
|
||||
let steps: [TutorialStep] = [
|
||||
.init(title: "Add Tasks with NLP",
|
||||
body: "Type naturally — \"dentist tomorrow 3pm\" or \"mum's birthday on 8th June every year\" — Kisani parses the date, time, and recurrence for you.",
|
||||
body: "Type naturally — \"dentist tomorrow 3pm\" or \"mum's birthday on 8th June every year\" — Wenza parses the date, time, and recurrence for you.",
|
||||
icon: "sparkles",
|
||||
anchorIndex: 0),
|
||||
.init(title: "Calendar",
|
||||
@@ -62,7 +62,7 @@ final class TutorialManager: ObservableObject {
|
||||
body: "Everything due today lives here — overdue items appear at the top so nothing slips through. Tap any task to open it.",
|
||||
icon: "checkmark.circle"),
|
||||
.init(title: "Add Tasks Instantly",
|
||||
body: "Tap + and type naturally. \"Doctor Wednesday 2pm\" or \"Neo's birthday 15 June yearly\" — Kisani handles dates, times, and recurrence.",
|
||||
body: "Tap + and type naturally. \"Doctor Wednesday 2pm\" or \"Neo's birthday 15 June yearly\" — Wenza handles dates, times, and recurrence.",
|
||||
icon: "sparkles"),
|
||||
.init(title: "Quick Actions",
|
||||
body: "Long-press a task to pin, postpone, or move it to a matrix quadrant. Swipe left to delete.",
|
||||
@@ -104,4 +104,24 @@ final class TutorialManager: ObservableObject {
|
||||
else { withAnimation(KisaniSpring.entrance) { workoutStep += 1 } }
|
||||
}
|
||||
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 } }
|
||||
}
|
||||
|
||||
37
KisaniCal/Models/CalendarGrid.swift
Normal file
@@ -0,0 +1,37 @@
|
||||
import Foundation
|
||||
|
||||
/// Pure, testable calendar-grid math. Kept free of SwiftUI/state so the month-grid
|
||||
/// generation and weekday header can be unit-tested directly.
|
||||
///
|
||||
/// Correctness contract: every cell is a real `Date`, columns respect the supplied
|
||||
/// calendar's `firstWeekday`, and the weekday header is derived from the SAME
|
||||
/// `firstWeekday` so the two can never drift out of alignment.
|
||||
enum CalendarGrid {
|
||||
|
||||
/// The days to render for the month containing `date`, including the leading days
|
||||
/// from the previous month and trailing days from the next month needed to fill
|
||||
/// complete weeks. Column placement follows `calendar.firstWeekday`.
|
||||
static func monthGrid(for date: Date, calendar: Calendar) -> [Date] {
|
||||
guard
|
||||
let interval = calendar.dateInterval(of: .month, for: date),
|
||||
let firstWeek = calendar.dateInterval(of: .weekOfMonth, for: interval.start),
|
||||
let lastWeek = calendar.dateInterval(of: .weekOfMonth, for: interval.end - 1)
|
||||
else { return [] }
|
||||
var days: [Date] = []
|
||||
var cur = firstWeek.start
|
||||
while cur < lastWeek.end {
|
||||
days.append(cur)
|
||||
guard let next = calendar.date(byAdding: .day, value: 1, to: cur) else { break }
|
||||
cur = next
|
||||
}
|
||||
return days
|
||||
}
|
||||
|
||||
/// Single-letter weekday header rotated to the calendar's `firstWeekday`.
|
||||
/// Sunday-first → ["S","M","T","W","T","F","S"]; Monday-first → ["M","T","W","T","F","S","S"].
|
||||
static func weekdaySymbols(calendar: Calendar) -> [String] {
|
||||
let syms = ["S", "M", "T", "W", "T", "F", "S"] // index 0 = Sunday
|
||||
let first = calendar.firstWeekday - 1 // firstWeekday is 1...7 (1 = Sunday)
|
||||
return (0..<7).map { syms[(first + $0) % 7] }
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,62 @@ struct WorkoutProgram: Identifiable, Codable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Per-day Workout History
|
||||
|
||||
/// A snapshot of what was logged on a given day, captured before the daily reset.
|
||||
struct WorkoutDayLog: Identifiable, Codable {
|
||||
var id: String { date } // "yyyy-MM-dd"
|
||||
let date: String
|
||||
let programName: String
|
||||
let programEmoji: String
|
||||
let exercises: [LoggedExercise]
|
||||
|
||||
var totalSets: Int { exercises.reduce(0) { $0 + $1.sets.count } }
|
||||
var doneSets: Int { exercises.reduce(0) { $0 + $1.sets.filter(\.done).count } }
|
||||
/// Total training volume in kg for completed sets (Σ weight × reps).
|
||||
var volume: Double {
|
||||
exercises.reduce(0) { sum, ex in
|
||||
sum + ex.sets.reduce(0) { $0 + ($1.done ? $1.weight * Double($1.reps) : 0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Workout stats aggregation
|
||||
|
||||
enum StatPeriod: String, CaseIterable, Identifiable {
|
||||
case day = "Day", week = "Week", month = "Month"
|
||||
var id: String { rawValue }
|
||||
var calComponent: Calendar.Component {
|
||||
switch self { case .day: return .day; case .week: return .weekOfYear; case .month: return .month }
|
||||
}
|
||||
}
|
||||
|
||||
struct WorkoutStat {
|
||||
var workouts: Int = 0
|
||||
var sets: Int = 0
|
||||
var volume: Double = 0
|
||||
}
|
||||
|
||||
struct StatBucket: Identifiable {
|
||||
let id = UUID()
|
||||
let label: String
|
||||
let stat: WorkoutStat
|
||||
}
|
||||
|
||||
struct LoggedExercise: Identifiable, Codable {
|
||||
var id = UUID()
|
||||
let name: String
|
||||
let sfSymbol: String
|
||||
let sets: [LoggedSet]
|
||||
var doneSets: Int { sets.filter(\.done).count }
|
||||
}
|
||||
|
||||
struct LoggedSet: Codable {
|
||||
let weight: Double
|
||||
let reps: Int
|
||||
let done: Bool
|
||||
}
|
||||
|
||||
// MARK: - Exercise Template Library
|
||||
enum ExerciseMuscle: String, CaseIterable, Identifiable {
|
||||
var id: String { rawValue }
|
||||
@@ -177,25 +233,89 @@ class WorkoutViewModel: ObservableObject {
|
||||
@Published var activeProgramId: UUID
|
||||
@Published var activeSections: [WorkoutSection]
|
||||
@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 compensations: [String: String] = [:] // rest-day "yyyy-MM-dd" → programId
|
||||
@Published var askCompensate: MissedDay? = nil // triggers the compensation sheet
|
||||
@Published var history: [WorkoutDayLog] = []
|
||||
|
||||
struct MissedDay: Identifiable {
|
||||
let id: String // "yyyy-MM-dd" of the missed day
|
||||
let programId: UUID? // what was scheduled that day
|
||||
let programName: String
|
||||
}
|
||||
@Published var restTimerSeconds: Int = 60
|
||||
@Published var restTimerEnabled: Bool = false
|
||||
@Published var restCountdown: Int = 0
|
||||
@Published var restTimerTotal: Int = 0
|
||||
@Published var sessionStartDate: Date? = nil
|
||||
|
||||
/// Lifetime tally of workout days achieved. Per the user's model this only ever
|
||||
/// grows — a missed day, an off week, even a fully-blank week never reduce it.
|
||||
/// A 4/5 week still contributes its 4; there is no "break." Not a consecutive
|
||||
/// streak: it's the sum of everything you've actually done.
|
||||
var streakDays: Int {
|
||||
Self.totalAchievedWorkoutDays(workoutDates)
|
||||
}
|
||||
|
||||
/// Count of distinct workout days ever logged (unit-tested in StreakLogicTests).
|
||||
static func totalAchievedWorkoutDays(_ workoutDates: [String]) -> Int {
|
||||
Set(workoutDates).count
|
||||
}
|
||||
|
||||
// MARK: - Streak-banner breakdowns (this week / this year / comparison)
|
||||
|
||||
/// Workout days in the week `offset` weeks from now (0 = current, -1 = last).
|
||||
private func workoutCount(inWeekOffset offset: Int) -> Int {
|
||||
let cal = Calendar.current
|
||||
let fmt = iso; let dateSet = Set(workoutDates)
|
||||
var streak = 0
|
||||
var check = cal.startOfDay(for: Date())
|
||||
if !dateSet.contains(fmt.string(from: check)) {
|
||||
check = cal.date(byAdding: .day, value: -1, to: check)!
|
||||
guard let ref = cal.date(byAdding: .weekOfYear, value: offset, to: Date()),
|
||||
let wk = cal.dateInterval(of: .weekOfYear, for: ref) else { return 0 }
|
||||
let set = Set(workoutDates)
|
||||
return (0..<7).reduce(0) { acc, i in
|
||||
guard let d = cal.date(byAdding: .day, value: i, to: wk.start) else { return acc }
|
||||
return acc + (set.contains(iso.string(from: d)) ? 1 : 0)
|
||||
}
|
||||
while dateSet.contains(fmt.string(from: check)) {
|
||||
streak += 1
|
||||
check = cal.date(byAdding: .day, value: -1, to: check)!
|
||||
}
|
||||
|
||||
var workoutsThisWeek: Int { workoutCount(inWeekOffset: 0) }
|
||||
var workoutsLastWeek: Int { workoutCount(inWeekOffset: -1) }
|
||||
|
||||
var workoutsThisYear: Int {
|
||||
let cal = Calendar.current
|
||||
let year = cal.component(.year, from: Date())
|
||||
return Set(workoutDates).filter {
|
||||
guard let d = iso.date(from: $0) else { return false }
|
||||
return cal.component(.year, from: d) == year
|
||||
}.count
|
||||
}
|
||||
|
||||
/// 7 flags for the current week (calendar week order) — true if worked out that day.
|
||||
var currentWeekDays: [Bool] {
|
||||
let cal = Calendar.current
|
||||
guard let wk = cal.dateInterval(of: .weekOfYear, for: Date()) else {
|
||||
return Array(repeating: false, count: 7)
|
||||
}
|
||||
return streak
|
||||
let set = Set(workoutDates)
|
||||
return (0..<7).map { i in
|
||||
guard let d = cal.date(byAdding: .day, value: i, to: wk.start) else { return false }
|
||||
return set.contains(iso.string(from: d))
|
||||
}
|
||||
}
|
||||
|
||||
/// 12 flags for the current year — true if ≥1 workout that month.
|
||||
var currentYearMonths: [Bool] {
|
||||
let cal = Calendar.current
|
||||
let year = cal.component(.year, from: Date())
|
||||
var months = Array(repeating: false, count: 12)
|
||||
for s in workoutDates {
|
||||
guard let d = iso.date(from: s), cal.component(.year, from: d) == year else { continue }
|
||||
let m = cal.component(.month, from: d) - 1
|
||||
if (0..<12).contains(m) { months[m] = true }
|
||||
}
|
||||
return months
|
||||
}
|
||||
|
||||
private let iso: DateFormatter = {
|
||||
@@ -208,6 +328,11 @@ class WorkoutViewModel: ObservableObject {
|
||||
private let kSchedule: String
|
||||
private let kActivePid: String
|
||||
private let kWorkoutDates: String
|
||||
private let kManualWorkoutDates: String
|
||||
private let kLastActiveDay: String
|
||||
private let kWorkoutHistory: String
|
||||
private let kMissedDates: String
|
||||
private let kCompensations: String
|
||||
|
||||
init() {
|
||||
let uid = AuthManager.shared.currentUser?.id ?? "shared"
|
||||
@@ -215,15 +340,25 @@ class WorkoutViewModel: ObservableObject {
|
||||
self.kSchedule = "kisani.workout.schedule.\(uid)"
|
||||
self.kActivePid = "kisani.workout.activePid.\(uid)"
|
||||
self.kWorkoutDates = "kisani.workout.completedDates.\(uid)"
|
||||
self.kManualWorkoutDates = "kisani.workout.manualDates.\(uid)"
|
||||
self.kLastActiveDay = "kisani.workout.lastActiveDay.\(uid)"
|
||||
self.kWorkoutHistory = "kisani.workout.history.\(uid)"
|
||||
self.kMissedDates = "kisani.workout.missedDates.\(uid)"
|
||||
self.kCompensations = "kisani.workout.compensations.\(uid)"
|
||||
|
||||
// Restore all workout keys from iCloud if missing locally (fresh install / new build)
|
||||
// Restore all workout keys from iCloud if missing locally (fresh install / new build).
|
||||
// Must cover EVERY key save() pushes, or that state is lost on reinstall/update.
|
||||
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.programs.\(uid)")
|
||||
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.schedule.\(uid)")
|
||||
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.activePid.\(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.missedDates.\(uid)")
|
||||
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.compensations.\(uid)")
|
||||
|
||||
// ── Attempt to restore persisted state ──
|
||||
if let data = UserDefaults.standard.data(forKey: kPrograms),
|
||||
if let data = UserDefaults.kisani.data(forKey: kPrograms),
|
||||
let saved = try? JSONDecoder().decode([WorkoutProgram].self, from: data),
|
||||
!saved.isEmpty {
|
||||
|
||||
@@ -254,7 +389,7 @@ class WorkoutViewModel: ObservableObject {
|
||||
: saved
|
||||
|
||||
var sched: [Int: UUID] = [:]
|
||||
if let dict = UserDefaults.standard.dictionary(forKey: kSchedule) as? [String: String] {
|
||||
if let dict = UserDefaults.kisani.dictionary(forKey: kSchedule) as? [String: String] {
|
||||
for (k, v) in dict {
|
||||
if let day = Int(k), let uid = UUID(uuidString: v) { sched[day] = uid }
|
||||
}
|
||||
@@ -262,7 +397,7 @@ class WorkoutViewModel: ObservableObject {
|
||||
schedule = sched
|
||||
|
||||
var pid = saved[0].id
|
||||
if let str = UserDefaults.standard.string(forKey: kActivePid),
|
||||
if let str = UserDefaults.kisani.string(forKey: kActivePid),
|
||||
let uid = UUID(uuidString: str),
|
||||
saved.contains(where: { $0.id == uid }) { pid = uid }
|
||||
activeProgramId = pid
|
||||
@@ -283,27 +418,165 @@ class WorkoutViewModel: ObservableObject {
|
||||
activeProgramId = blank.id
|
||||
activeSections = blank.sections
|
||||
}
|
||||
workoutDates = UserDefaults.standard.stringArray(forKey: kWorkoutDates) ?? []
|
||||
workoutDates = UserDefaults.kisani.stringArray(forKey: kWorkoutDates) ?? []
|
||||
manualWorkoutDates = UserDefaults.kisani.stringArray(forKey: kManualWorkoutDates) ?? []
|
||||
missedDates = UserDefaults.kisani.stringArray(forKey: kMissedDates) ?? []
|
||||
compensations = (UserDefaults.kisani.dictionary(forKey: kCompensations) as? [String: String]) ?? [:]
|
||||
if let hData = UserDefaults.kisani.data(forKey: kWorkoutHistory),
|
||||
let saved = try? JSONDecoder().decode([WorkoutDayLog].self, from: hData) {
|
||||
history = saved
|
||||
}
|
||||
resetSetsForNewDayIfNeeded()
|
||||
}
|
||||
|
||||
private func save() {
|
||||
if let data = try? JSONEncoder().encode(programs) {
|
||||
UserDefaults.standard.set(data, forKey: kPrograms)
|
||||
UserDefaults.kisani.set(data, forKey: kPrograms)
|
||||
CloudSyncManager.shared.push(data: data, forKey: kPrograms)
|
||||
}
|
||||
let schedDict = schedule.reduce(into: [String: String]()) { $0[String($1.key)] = $1.value.uuidString }
|
||||
UserDefaults.standard.set(schedDict, forKey: kSchedule)
|
||||
UserDefaults.standard.set(activeProgramId.uuidString, forKey: kActivePid)
|
||||
UserDefaults.standard.set(workoutDates, forKey: kWorkoutDates)
|
||||
UserDefaults.kisani.set(schedDict, forKey: kSchedule)
|
||||
UserDefaults.kisani.set(activeProgramId.uuidString, forKey: kActivePid)
|
||||
UserDefaults.kisani.set(workoutDates, forKey: kWorkoutDates)
|
||||
UserDefaults.kisani.set(manualWorkoutDates, forKey: kManualWorkoutDates)
|
||||
UserDefaults.kisani.set(missedDates, forKey: kMissedDates)
|
||||
UserDefaults.kisani.set(compensations, forKey: kCompensations)
|
||||
CloudSyncManager.shared.push(value: missedDates, forKey: kMissedDates)
|
||||
CloudSyncManager.shared.push(value: compensations, forKey: kCompensations)
|
||||
if let hData = try? JSONEncoder().encode(history) {
|
||||
UserDefaults.kisani.set(hData, forKey: kWorkoutHistory)
|
||||
CloudSyncManager.shared.push(data: hData, forKey: kWorkoutHistory)
|
||||
}
|
||||
CloudSyncManager.shared.push(value: schedDict, forKey: kSchedule)
|
||||
CloudSyncManager.shared.push(value: activeProgramId.uuidString, forKey: kActivePid)
|
||||
CloudSyncManager.shared.push(value: workoutDates, forKey: kWorkoutDates)
|
||||
CloudSyncManager.shared.push(value: manualWorkoutDates, forKey: kManualWorkoutDates)
|
||||
}
|
||||
|
||||
// MARK: - Daily Reset
|
||||
/// A fresh calendar day starts a clean log. If the checkbox state currently on
|
||||
/// screen belongs to an earlier day, wipe every set's `isDone` so re-running the
|
||||
/// same program on back-to-back days (e.g. Shoulders on Mon AND Tue) starts
|
||||
/// unchecked. Streak history (`workoutDates`) is never touched here.
|
||||
/// Safe to call repeatedly — it only does work when the day actually changes.
|
||||
func resetSetsForNewDayIfNeeded() {
|
||||
let today = iso.string(from: Date())
|
||||
let last = UserDefaults.kisani.string(forKey: kLastActiveDay)
|
||||
guard last != today else { return }
|
||||
if let last = last { // skip on the very first launch
|
||||
snapshotDay(last) // save what was logged before wiping it
|
||||
clearAllDoneSets()
|
||||
applyScheduledProgramForToday() // follow the weekly schedule on a new day
|
||||
}
|
||||
UserDefaults.kisani.set(today, forKey: kLastActiveDay)
|
||||
}
|
||||
|
||||
/// History newest-first, for the history view.
|
||||
var historySorted: [WorkoutDayLog] { history.sorted { $0.date > $1.date } }
|
||||
|
||||
/// Capture the active program's completed work for `dayKey` into history.
|
||||
/// Only records days where at least one set was done; updates in place.
|
||||
func snapshotDay(_ dayKey: String) {
|
||||
let logged: [LoggedExercise] = activeSections.flatMap { section in
|
||||
section.exercises.map { ex in
|
||||
LoggedExercise(name: ex.name, sfSymbol: ex.sfSymbol,
|
||||
sets: ex.sets.map { LoggedSet(weight: $0.weight, reps: $0.reps, done: $0.isDone) })
|
||||
}
|
||||
}
|
||||
guard logged.contains(where: { $0.doneSets > 0 }) else { return }
|
||||
let log = WorkoutDayLog(date: dayKey,
|
||||
programName: activeProgram?.name ?? "Workout",
|
||||
programEmoji: activeProgram?.emoji ?? "dumbbell.fill",
|
||||
exercises: logged)
|
||||
history.removeAll { $0.date == dayKey }
|
||||
history.append(log)
|
||||
save()
|
||||
}
|
||||
|
||||
// MARK: - Stats (daily / weekly / monthly performance)
|
||||
|
||||
/// History logs whose date falls inside `interval`.
|
||||
private func logs(in interval: DateInterval) -> [WorkoutDayLog] {
|
||||
history.filter { guard let d = iso.date(from: $0.date) else { return false }
|
||||
return interval.contains(d) }
|
||||
}
|
||||
|
||||
/// The calendar interval for `period`, shifted back by `offset` periods (0 = current).
|
||||
func statInterval(_ period: StatPeriod, offset: Int) -> DateInterval {
|
||||
let cal = Calendar.current
|
||||
let anchor = cal.date(byAdding: period.calComponent, value: offset, to: Date()) ?? Date()
|
||||
return cal.dateInterval(of: period.calComponent, for: anchor)
|
||||
?? DateInterval(start: cal.startOfDay(for: anchor), duration: 86400)
|
||||
}
|
||||
|
||||
/// Aggregate workout stats for one period (offset 0 = current, -1 = previous).
|
||||
func stat(_ period: StatPeriod, offset: Int = 0) -> WorkoutStat {
|
||||
let logs = logs(in: statInterval(period, offset: offset))
|
||||
return WorkoutStat(
|
||||
workouts: logs.count,
|
||||
sets: logs.reduce(0) { $0 + $1.doneSets },
|
||||
volume: logs.reduce(0) { $0 + $1.volume }
|
||||
)
|
||||
}
|
||||
|
||||
/// A series of `count` recent periods, oldest → newest, for charting.
|
||||
func statBuckets(_ period: StatPeriod, count: Int) -> [StatBucket] {
|
||||
let fmt = DateFormatter()
|
||||
switch period {
|
||||
case .day: fmt.dateFormat = "EEE"
|
||||
case .week: fmt.dateFormat = "MMM d"
|
||||
case .month: fmt.dateFormat = "MMM"
|
||||
}
|
||||
return (0..<count).reversed().map { back in
|
||||
let interval = statInterval(period, offset: -back)
|
||||
return StatBucket(label: fmt.string(from: interval.start), stat: stat(period, offset: -back))
|
||||
}
|
||||
}
|
||||
|
||||
/// The program assigned to today in the weekly schedule (nil = rest day).
|
||||
var todaysScheduledProgram: WorkoutProgram? { program(for: Date()) }
|
||||
|
||||
/// True when a schedule exists but today has no program — a rest day.
|
||||
/// A compensation scheduled for today counts as a workout day, not rest.
|
||||
var isRestDayToday: Bool {
|
||||
!schedule.isEmpty && program(for: Date()) == nil
|
||||
}
|
||||
|
||||
/// On a new day, make the active program match the day's schedule so the
|
||||
/// Workout tab shows today's workout instead of yesterday's. Rest days leave
|
||||
/// the active program untouched (the view shows a rest state instead).
|
||||
private func applyScheduledProgramForToday() {
|
||||
guard let scheduled = todaysScheduledProgram, scheduled.id != activeProgramId else { return }
|
||||
activeProgramId = scheduled.id
|
||||
activeSections = scheduled.sections
|
||||
save()
|
||||
}
|
||||
|
||||
/// Unchecks every set across all programs and clears the in-progress session.
|
||||
private func clearAllDoneSets() {
|
||||
for pi in programs.indices {
|
||||
for si in programs[pi].sections.indices {
|
||||
for ei in programs[pi].sections[si].exercises.indices {
|
||||
for setIdx in programs[pi].sections[si].exercises[ei].sets.indices {
|
||||
programs[pi].sections[si].exercises[ei].sets[setIdx].isDone = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
activeSections = programs.first { $0.id == activeProgramId }?.sections ?? activeSections
|
||||
sessionStartDate = nil
|
||||
save()
|
||||
}
|
||||
|
||||
func logWorkoutCompleted(on date: Date = Date()) {
|
||||
let key = iso.string(from: date)
|
||||
snapshotDay(key) // record today's session in history
|
||||
guard !workoutDates.contains(key) else { return }
|
||||
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()
|
||||
let start = sessionStartDate ?? date.addingTimeInterval(-3600)
|
||||
Task { await HealthKitManager.shared.saveWorkout(start: start, end: date) }
|
||||
@@ -326,8 +599,8 @@ class WorkoutViewModel: ObservableObject {
|
||||
@MainActor
|
||||
func checkPendingHealthConfirm() {
|
||||
let key = "kisani.workout.pendingConfirm"
|
||||
guard let dateStr = UserDefaults.standard.string(forKey: key) else { return }
|
||||
UserDefaults.standard.removeObject(forKey: key)
|
||||
guard let dateStr = UserDefaults.kisani.string(forKey: key) else { return }
|
||||
UserDefaults.kisani.removeObject(forKey: key)
|
||||
guard !workoutDates.contains(dateStr) else { return }
|
||||
workoutDates.append(dateStr)
|
||||
save()
|
||||
@@ -363,6 +636,108 @@ class WorkoutViewModel: ObservableObject {
|
||||
var doneSets: Int { activeSections.reduce(0) { $0 + $1.doneSets } }
|
||||
var progress: Double { totalSets > 0 ? Double(doneSets) / Double(totalSets) : 0 }
|
||||
|
||||
/// True if today already counts toward the streak — either marked complete in-app
|
||||
/// or detected from Apple Health (both land in `workoutDates`).
|
||||
var isTodayRecorded: Bool { workoutDates.contains(iso.string(from: Date())) }
|
||||
|
||||
/// Whether a workout was actually completed on a specific calendar date.
|
||||
/// Per-date (from `workoutDates`), NOT the program's live set state — so
|
||||
/// finishing one occurrence never marks other days of the same program.
|
||||
func isWorkoutComplete(on date: Date) -> Bool {
|
||||
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)
|
||||
|
||||
enum WorkoutDayStatus { case none, pending, done, missed }
|
||||
|
||||
func isWorkoutMissed(on date: Date) -> Bool { missedDates.contains(iso.string(from: date)) }
|
||||
func isCompensation(on date: Date) -> Bool { compensations[iso.string(from: date)] != nil }
|
||||
|
||||
/// Status of the workout scheduled on a specific date. Done wins over missed.
|
||||
func workoutStatus(on date: Date) -> WorkoutDayStatus {
|
||||
guard program(for: date) != nil else { return .none }
|
||||
if isWorkoutComplete(on: date) { return .done }
|
||||
if isWorkoutMissed(on: date) { return .missed }
|
||||
return .pending
|
||||
}
|
||||
|
||||
/// Mark one specific day's workout done — never touches other occurrences.
|
||||
func markWorkoutDone(on date: Date) {
|
||||
let key = iso.string(from: date)
|
||||
missedDates.removeAll { $0 == key }
|
||||
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).
|
||||
func markWorkoutMissed(on date: Date) {
|
||||
let key = iso.string(from: date)
|
||||
workoutDates.removeAll { $0 == key }
|
||||
if !missedDates.contains(key) { missedDates.append(key) }
|
||||
save()
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Compensation (recover a missed workout on a rest day)
|
||||
|
||||
/// Upcoming rest days (no scheduled program, no compensation yet), starting tomorrow.
|
||||
func upcomingRestDays(within days: Int = 14) -> [Date] {
|
||||
let cal = Calendar.current
|
||||
let today = cal.startOfDay(for: Date())
|
||||
return (1...days).compactMap { off -> Date? in
|
||||
guard let d = cal.date(byAdding: .day, value: off, to: today) else { return nil }
|
||||
let weekday = cal.component(.weekday, from: d)
|
||||
guard schedule[weekday] == nil, compensations[iso.string(from: d)] == nil else { return nil }
|
||||
return d
|
||||
}
|
||||
}
|
||||
|
||||
func scheduleCompensation(programId: UUID, on date: Date) {
|
||||
compensations[iso.string(from: date)] = programId.uuidString
|
||||
save()
|
||||
}
|
||||
|
||||
func removeCompensation(on date: Date) {
|
||||
compensations.removeValue(forKey: iso.string(from: date))
|
||||
save()
|
||||
}
|
||||
|
||||
/// Begin the "compensate for this missed day?" flow for a given date.
|
||||
func promptCompensation(forMissed date: Date) {
|
||||
let prog = program(for: date)
|
||||
askCompensate = MissedDay(id: iso.string(from: date),
|
||||
programId: prog?.id,
|
||||
programName: prog?.name ?? "Workout")
|
||||
}
|
||||
|
||||
/// Picks up "No, I missed it" tapped on a workout notification (set in background).
|
||||
@MainActor
|
||||
func checkPendingMissed() {
|
||||
let key = "kisani.workout.pendingMissed"
|
||||
guard let dateStr = UserDefaults.kisani.string(forKey: key) else { return }
|
||||
UserDefaults.kisani.removeObject(forKey: key)
|
||||
guard let date = iso.date(from: dateStr) else { return }
|
||||
markWorkoutMissed(on: date)
|
||||
promptCompensation(forMissed: date)
|
||||
}
|
||||
|
||||
// MARK: - Program management
|
||||
func switchProgram(to id: UUID) {
|
||||
syncBack()
|
||||
@@ -400,6 +775,12 @@ class WorkoutViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
func program(for date: Date) -> WorkoutProgram? {
|
||||
// A compensation scheduled on this (rest) day takes the slot.
|
||||
if let pidStr = compensations[iso.string(from: date)],
|
||||
let pid = UUID(uuidString: pidStr),
|
||||
let prog = programs.first(where: { $0.id == pid }) {
|
||||
return prog
|
||||
}
|
||||
let weekday = Calendar.current.component(.weekday, from: date)
|
||||
guard let pid = schedule[weekday] else { return nil }
|
||||
return programs.first { $0.id == pid }
|
||||
@@ -448,14 +829,50 @@ class WorkoutViewModel: ObservableObject {
|
||||
else { return }
|
||||
let wasMarkedDone = !activeSections[si].exercises[ei].sets[setIdx].isDone
|
||||
activeSections[si].exercises[ei].sets[setIdx].isDone.toggle()
|
||||
// The current checkbox state now belongs to today.
|
||||
UserDefaults.kisani.set(iso.string(from: Date()), forKey: kLastActiveDay)
|
||||
if sessionStartDate == nil, activeSections[si].exercises[ei].sets[setIdx].isDone {
|
||||
sessionStartDate = Date()
|
||||
}
|
||||
syncBack()
|
||||
if doneSets == totalSets && totalSets > 0 { logWorkoutCompleted() }
|
||||
// Ticking the last box no longer silently logs the day as done — the
|
||||
// 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() }
|
||||
}
|
||||
|
||||
/// Mark every set of one exercise done/undone (long-press quick action).
|
||||
func setExerciseDone(sectionId: UUID, exerciseId: UUID, done: Bool) {
|
||||
guard let si = activeSections.firstIndex(where: { $0.id == sectionId }),
|
||||
let ei = activeSections[si].exercises.firstIndex(where: { $0.id == exerciseId })
|
||||
else { return }
|
||||
for idx in activeSections[si].exercises[ei].sets.indices {
|
||||
activeSections[si].exercises[ei].sets[idx].isDone = done
|
||||
}
|
||||
if done, sessionStartDate == nil { sessionStartDate = Date() }
|
||||
syncBack()
|
||||
// See toggleSet: no longer auto-logs the day — "Finish" is explicit.
|
||||
}
|
||||
|
||||
/// Mark every set of the active workout done/undone — the "Check/Clear all
|
||||
/// sets" quick action. Ticking every box no longer auto-logs the day as
|
||||
/// done on its own; "Finish Workout" on the main card is the single,
|
||||
/// confirmable action that counts today.
|
||||
func setAllSetsDone(_ done: Bool) {
|
||||
for si in activeSections.indices {
|
||||
for ei in activeSections[si].exercises.indices {
|
||||
for idx in activeSections[si].exercises[ei].sets.indices {
|
||||
activeSections[si].exercises[ei].sets[idx].isDone = done
|
||||
}
|
||||
}
|
||||
}
|
||||
if done {
|
||||
if sessionStartDate == nil { sessionStartDate = Date() }
|
||||
UserDefaults.kisani.set(iso.string(from: Date()), forKey: kLastActiveDay)
|
||||
}
|
||||
syncBack()
|
||||
}
|
||||
|
||||
func addSet(sectionId: UUID, to exerciseId: UUID) {
|
||||
guard let si = activeSections.firstIndex(where: { $0.id == sectionId }),
|
||||
let ei = activeSections[si].exercises.firstIndex(where: { $0.id == exerciseId })
|
||||
@@ -480,6 +897,34 @@ class WorkoutViewModel: ObservableObject {
|
||||
syncBack()
|
||||
}
|
||||
|
||||
/// Relocate an exercise (within or across sections) for any program via drag-and-drop.
|
||||
/// Drops the exercise just before `beforeId`; pass nil to append to the target section.
|
||||
func moveExercise(programId: UUID, exerciseId: UUID, toSection targetSectionId: UUID, before beforeId: UUID?) {
|
||||
guard exerciseId != beforeId else { return }
|
||||
guard let pi = programs.firstIndex(where: { $0.id == programId }) else { return }
|
||||
|
||||
// Remove from its current section.
|
||||
var moved: Exercise?
|
||||
for si in programs[pi].sections.indices {
|
||||
if let ei = programs[pi].sections[si].exercises.firstIndex(where: { $0.id == exerciseId }) {
|
||||
moved = programs[pi].sections[si].exercises.remove(at: ei)
|
||||
break
|
||||
}
|
||||
}
|
||||
guard let ex = moved,
|
||||
let ti = programs[pi].sections.firstIndex(where: { $0.id == targetSectionId }) else { return }
|
||||
|
||||
// Insert before the target row, or append if none specified.
|
||||
if let beforeId, let idx = programs[pi].sections[ti].exercises.firstIndex(where: { $0.id == beforeId }) {
|
||||
programs[pi].sections[ti].exercises.insert(ex, at: idx)
|
||||
} else {
|
||||
programs[pi].sections[ti].exercises.append(ex)
|
||||
}
|
||||
|
||||
if programId == activeProgramId { activeSections = programs[pi].sections }
|
||||
save()
|
||||
}
|
||||
|
||||
// MARK: - Universal (any program) operations
|
||||
func addExercise(programId: UUID, sectionId: UUID, name: String, setsCount: Int, repsCount: Int) {
|
||||
guard let pi = programs.firstIndex(where: { $0.id == programId }),
|
||||
@@ -494,6 +939,7 @@ class WorkoutViewModel: ObservableObject {
|
||||
Exercise(name: name, sfSymbol: symbol, colorIndex: colorIdx, sets: sets, isDumbbell: isDumbb)
|
||||
)
|
||||
if programId == activeProgramId { activeSections = programs[pi].sections }
|
||||
save()
|
||||
}
|
||||
|
||||
func deleteExercise(programId: UUID, sectionId: UUID, exerciseId: UUID) {
|
||||
@@ -502,18 +948,21 @@ class WorkoutViewModel: ObservableObject {
|
||||
else { return }
|
||||
programs[pi].sections[si].exercises.removeAll { $0.id == exerciseId }
|
||||
if programId == activeProgramId { activeSections = programs[pi].sections }
|
||||
save()
|
||||
}
|
||||
|
||||
func addSection(to programId: UUID, name: String) {
|
||||
guard let pi = programs.firstIndex(where: { $0.id == programId }) else { return }
|
||||
programs[pi].sections.append(WorkoutSection(name: name, exercises: []))
|
||||
if programId == activeProgramId { activeSections = programs[pi].sections }
|
||||
save()
|
||||
}
|
||||
|
||||
func deleteSection(from programId: UUID, sectionId: UUID) {
|
||||
guard let pi = programs.firstIndex(where: { $0.id == programId }) else { return }
|
||||
programs[pi].sections.removeAll { $0.id == sectionId }
|
||||
if programId == activeProgramId { activeSections = programs[pi].sections }
|
||||
save()
|
||||
}
|
||||
|
||||
func renameSection(in programId: UUID, sectionId: UUID, name: String) {
|
||||
@@ -522,6 +971,7 @@ class WorkoutViewModel: ObservableObject {
|
||||
else { return }
|
||||
programs[pi].sections[si].name = name
|
||||
if programId == activeProgramId { activeSections = programs[pi].sections }
|
||||
save()
|
||||
}
|
||||
|
||||
func toggleExpanded(sectionId: UUID, exerciseId: UUID) {
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
// MARK: - Task Model
|
||||
struct TaskItem: Identifiable, Codable, Equatable {
|
||||
var id = UUID()
|
||||
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 hasTime: Bool = false
|
||||
var isComplete: Bool = false
|
||||
@@ -13,11 +18,30 @@ struct TaskItem: Identifiable, Codable, Equatable {
|
||||
var taskColor: TaskColor = .orange
|
||||
var isRecurring: Bool = false
|
||||
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 endDate: Date? = nil
|
||||
var isAllDay: Bool = false
|
||||
var priority: Priority = .none
|
||||
var reminderDate: Date? = nil
|
||||
var constantReminder: Bool = false
|
||||
// Recurrence: optional so existing saved tasks decode safely (missing key → nil).
|
||||
var recurrenceEnd: Date? = nil // "repeat until" (nil = open-ended)
|
||||
var completedOccurrences: [String]? = nil // "yyyy-MM-dd" of completed occurrences
|
||||
// Matrix: horizontal manual moves override urgency only.
|
||||
// nil = auto from deadline, true = force urgent, false = force not-urgent.
|
||||
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 {
|
||||
@@ -96,6 +120,18 @@ enum TaskCategory: String, Codable {
|
||||
case domain = "Domain"
|
||||
case annual = "Annual"
|
||||
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 {
|
||||
@@ -129,17 +165,41 @@ class TaskViewModel: ObservableObject {
|
||||
init() {
|
||||
let uid = AuthManager.shared.currentUser?.id ?? "shared"
|
||||
self.persistKey = "kisani.tasks.v2.\(uid)"
|
||||
// One-time migration: tasks used to live in UserDefaults.standard, but the
|
||||
// widgets read from the shared App Group. Move legacy data over if present.
|
||||
if UserDefaults.kisani.data(forKey: persistKey) == nil,
|
||||
let legacy = UserDefaults.standard.data(forKey: persistKey) {
|
||||
UserDefaults.kisani.set(legacy, forKey: persistKey)
|
||||
UserDefaults.standard.removeObject(forKey: persistKey)
|
||||
}
|
||||
// Restore from iCloud only if the App Group still has nothing (fresh install / new device).
|
||||
CloudSyncManager.shared.restoreIfMissing(key: persistKey)
|
||||
if let data = UserDefaults.standard.data(forKey: persistKey),
|
||||
if let data = UserDefaults.kisani.data(forKey: persistKey),
|
||||
let saved = try? JSONDecoder().decode([TaskItem].self, from: data), !saved.isEmpty {
|
||||
tasks = saved
|
||||
} else {
|
||||
tasks = []
|
||||
}
|
||||
migrateCategoryPrioritiesIfNeeded()
|
||||
}
|
||||
|
||||
/// One-time backfill: tasks created before Priority existed all have `.none`.
|
||||
/// Apply the KisaniCal category defaults (birthdays → High, etc.) so the Matrix
|
||||
/// places legacy birthdays/renewals in the top row like newly-created ones.
|
||||
private func migrateCategoryPrioritiesIfNeeded() {
|
||||
let flag = "kisani.migration.categoryPriority.v1"
|
||||
guard !UserDefaults.kisani.bool(forKey: flag) else { return }
|
||||
var changed = false
|
||||
for idx in tasks.indices where tasks[idx].priority == .none {
|
||||
let def = defaultPriority(category: tasks[idx].category, title: tasks[idx].title)
|
||||
if def != .none { tasks[idx].priority = def; changed = true }
|
||||
}
|
||||
UserDefaults.kisani.set(true, forKey: flag)
|
||||
if changed { save() }
|
||||
}
|
||||
|
||||
func reload() {
|
||||
guard let data = UserDefaults.standard.data(forKey: persistKey),
|
||||
guard let data = UserDefaults.kisani.data(forKey: persistKey),
|
||||
let saved = try? JSONDecoder().decode([TaskItem].self, from: data)
|
||||
else { return }
|
||||
tasks = saved
|
||||
@@ -147,51 +207,168 @@ class TaskViewModel: ObservableObject {
|
||||
|
||||
private func save() {
|
||||
if let data = try? JSONEncoder().encode(tasks) {
|
||||
UserDefaults.standard.set(data, forKey: persistKey)
|
||||
UserDefaults.kisani.set(data, forKey: persistKey)
|
||||
CloudSyncManager.shared.push(data: data, forKey: persistKey)
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Active list rows (single occurrence per task — TickTick model)
|
||||
//
|
||||
// A recurring task contributes exactly ONE row: its next active (incomplete)
|
||||
// occurrence. Completing it advances to the next occurrence — there are never
|
||||
// multiple future copies of the same task across sections. (The calendar grid
|
||||
// still shows every occurrence per-date; that's a date grid, not a list.)
|
||||
|
||||
/// Earliest occurrence on/after `from` not yet completed — the task's one active
|
||||
/// occurrence. Daily → today (or tomorrow once today's done); Yearly → the next
|
||||
/// uncompleted yearly date; etc.
|
||||
func nextActiveOccurrence(_ t: TaskItem, from: Date = Date()) -> Date? {
|
||||
guard recurs(t), let start = t.dueDate else { return nil }
|
||||
let cal = Calendar.current
|
||||
var cur = max(cal.startOfDay(for: start), cal.startOfDay(for: from))
|
||||
for _ in 0..<4000 {
|
||||
if let end = t.recurrenceEnd, cur > cal.startOfDay(for: end) { return nil }
|
||||
if isOccurrence(t, on: cur) && !occurrenceComplete(t, on: cur) { return cur }
|
||||
cur = cal.date(byAdding: .day, value: 1, to: cur) ?? cur
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// One representative active row per task: non-recurring incomplete tasks, plus a
|
||||
/// single next-active occurrence copy for each recurring task.
|
||||
private func activeRows() -> [TaskItem] {
|
||||
let today = Calendar.current.startOfDay(for: Date())
|
||||
var out: [TaskItem] = []
|
||||
for t in tasks {
|
||||
if recurs(t) {
|
||||
if let occ = nextActiveOccurrence(t, from: today) { out.append(occurrenceCopy(t, on: occ)) }
|
||||
} else if !t.isComplete {
|
||||
out.append(t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/// All incomplete single-occurrence rows (dated and undated) — for Group & Sort
|
||||
/// and multi-select, which regroup the same task set the date sections draw from.
|
||||
var allActiveRows: [TaskItem] { activeRows() }
|
||||
|
||||
/// Active rows whose due date falls in [today+lo, today+hi) days (hi nil = open).
|
||||
private func bucket(from lo: Int, to hi: Int?) -> [TaskItem] {
|
||||
let cal = Calendar.current; let base = cal.startOfDay(for: Date())
|
||||
let start = cal.date(byAdding: .day, value: lo, to: base)!
|
||||
let end = hi.flatMap { cal.date(byAdding: .day, value: $0, to: base) }
|
||||
return activeRows().filter {
|
||||
let d = $0.dueDate ?? .distantFuture
|
||||
return d >= start && (end == nil || d < end!)
|
||||
}.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
|
||||
}
|
||||
|
||||
var overdueTasks: [TaskItem] {
|
||||
let startOfDay = Calendar.current.startOfDay(for: Date())
|
||||
return tasks.filter { !$0.isComplete && ($0.dueDate ?? .distantFuture) < startOfDay }
|
||||
let now = Date()
|
||||
let today = Calendar.current.startOfDay(for: now)
|
||||
// TickTick rule: timed tasks become overdue the moment the clock passes;
|
||||
// date-only tasks become overdue at the start of the next day (midnight).
|
||||
return tasks.filter {
|
||||
guard !recurs($0) && !$0.isComplete, let due = $0.dueDate else { return false }
|
||||
return $0.hasTime ? due < now : due < today
|
||||
}
|
||||
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
|
||||
}
|
||||
var todayTasks: [TaskItem] {
|
||||
let start = Calendar.current.startOfDay(for: Date())
|
||||
let end = Calendar.current.date(byAdding: .day, value: 1, to: start)!
|
||||
return tasks
|
||||
.filter { !$0.isComplete && ($0.dueDate ?? .distantFuture) >= start && ($0.dueDate ?? .distantFuture) < end }
|
||||
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
|
||||
}
|
||||
var upcomingTasks: [TaskItem] {
|
||||
let cal = Calendar.current
|
||||
let fourDays = cal.date(byAdding: .day, value: 4, to: cal.startOfDay(for: Date()))!
|
||||
return tasks
|
||||
.filter { !$0.isComplete && ($0.dueDate ?? .distantFuture) >= fourDays }
|
||||
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
|
||||
}
|
||||
var next3DaysTasks: [(date: Date, tasks: [TaskItem])] {
|
||||
let cal = Calendar.current
|
||||
let today = cal.startOfDay(for: Date())
|
||||
return (1...3).compactMap { offset -> (Date, [TaskItem])? in
|
||||
guard let day = cal.date(byAdding: .day, value: offset, to: today),
|
||||
let nextDay = cal.date(byAdding: .day, value: 1, to: day) else { return nil }
|
||||
let dayTasks = tasks.filter {
|
||||
!$0.isComplete &&
|
||||
($0.dueDate ?? .distantFuture) >= day &&
|
||||
($0.dueDate ?? .distantFuture) < nextDay
|
||||
}.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
|
||||
return dayTasks.isEmpty ? nil : (day, dayTasks)
|
||||
let now = Date()
|
||||
let cal = Calendar.current
|
||||
let start = cal.startOfDay(for: now)
|
||||
let end = cal.date(byAdding: .day, value: 1, to: start)!
|
||||
// Timed ONE-OFF tasks whose time has already passed belong in overdue,
|
||||
// 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 {
|
||||
guard let d = $0.dueDate else { return false }
|
||||
if $0.hasTime && d < now && !$0.isRecurring { return false }
|
||||
return d >= start && d < end
|
||||
}
|
||||
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
|
||||
}
|
||||
var tomorrowTasks: [TaskItem] { bucket(from: 1, to: 2) } // due tomorrow
|
||||
var next7DaysTasks: [TaskItem] { bucket(from: 2, to: 8) } // due in 2–7 days
|
||||
var laterTasks: [TaskItem] { bucket(from: 8, to: nil) } // beyond 7 days
|
||||
|
||||
var completedTodayTasks: [TaskItem] {
|
||||
let startOfDay = Calendar.current.startOfDay(for: Date())
|
||||
return tasks
|
||||
.filter { $0.isComplete && ($0.completedAt ?? .distantPast) >= startOfDay }
|
||||
let cal = Calendar.current
|
||||
let startOfDay = cal.startOfDay(for: Date())
|
||||
let nonRec = tasks.filter { !recurs($0) && $0.isComplete && ($0.completedAt ?? .distantPast) >= startOfDay }
|
||||
let rec = tasks.filter { recurs($0) && isOccurrence($0, on: startOfDay) && occurrenceComplete($0, on: startOfDay) }
|
||||
.map { occurrenceCopy($0, on: startOfDay) }
|
||||
return (nonRec + rec).sorted { ($0.completedAt ?? .distantPast) > ($1.completedAt ?? .distantPast) }
|
||||
}
|
||||
/// All completed tasks, most-recent first (for the Completed archive section).
|
||||
var completedTasks: [TaskItem] {
|
||||
tasks
|
||||
.filter { $0.isComplete }
|
||||
.sorted { ($0.completedAt ?? .distantPast) > ($1.completedAt ?? .distantPast) }
|
||||
}
|
||||
|
||||
// MARK: - Completion stats (counts recurring occurrences, not just one-offs)
|
||||
|
||||
/// Completions on a given day: one-off tasks by `completedAt`, recurring
|
||||
/// tasks by their `completedOccurrences` entry for that day.
|
||||
func completionCount(on day: Date) -> Int {
|
||||
Self.completionCount(in: tasks, on: day, calendar: Calendar.current)
|
||||
}
|
||||
|
||||
/// Pure counting (unit-tested in StreakLogicTests).
|
||||
static func completionCount(in tasks: [TaskItem], on day: Date, calendar cal: Calendar) -> Int {
|
||||
let d0 = cal.startOfDay(for: day)
|
||||
let next = cal.date(byAdding: .day, value: 1, to: d0)!
|
||||
let key = occFmt.string(from: d0)
|
||||
return tasks.reduce(0) { n, t in
|
||||
let isRecurring = t.isRecurring && (t.recurrenceLabel ?? "None") != "None" && t.dueDate != nil
|
||||
if isRecurring {
|
||||
return n + ((t.completedOccurrences?.contains(key) ?? false) ? 1 : 0)
|
||||
}
|
||||
if t.isComplete, let at = t.completedAt, at >= d0, at < next { return n + 1 }
|
||||
return n
|
||||
}
|
||||
}
|
||||
|
||||
/// Completed tasks on a day, recurring occurrences included (for history views).
|
||||
func completions(on day: Date) -> [TaskItem] {
|
||||
let cal = Calendar.current
|
||||
let d0 = cal.startOfDay(for: day)
|
||||
let next = cal.date(byAdding: .day, value: 1, to: d0)!
|
||||
let nonRec = tasks.filter { !recurs($0) && $0.isComplete && (($0.completedAt ?? .distantPast) >= d0) && (($0.completedAt ?? .distantPast) < next) }
|
||||
let rec = tasks.filter { recurs($0) && occurrenceComplete($0, on: d0) }
|
||||
.map { occurrenceCopy($0, on: d0) }
|
||||
return (nonRec + rec).sorted { ($0.completedAt ?? .distantPast) > ($1.completedAt ?? .distantPast) }
|
||||
}
|
||||
|
||||
/// Consecutive days with ≥1 completion, walking back from today.
|
||||
/// A quiet day today doesn't break the streak — it just isn't counted yet.
|
||||
var taskStreakDays: Int {
|
||||
let cal = Calendar.current
|
||||
var check = cal.startOfDay(for: Date())
|
||||
var streak = 0
|
||||
if completionCount(on: check) == 0 {
|
||||
check = cal.date(byAdding: .day, value: -1, to: check)!
|
||||
}
|
||||
while completionCount(on: check) > 0 {
|
||||
streak += 1
|
||||
check = cal.date(byAdding: .day, value: -1, to: check)!
|
||||
}
|
||||
return streak
|
||||
}
|
||||
|
||||
func toggle(_ task: TaskItem) {
|
||||
// A recurring row carries its occurrence date in dueDate — toggle just that day.
|
||||
if recurs(task), let d = task.dueDate {
|
||||
toggleOccurrence(task, on: d)
|
||||
return
|
||||
}
|
||||
guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
|
||||
tasks[idx].isComplete.toggle()
|
||||
tasks[idx].completedAt = tasks[idx].isComplete ? Date() : nil
|
||||
@@ -203,6 +380,189 @@ class TaskViewModel: ObservableObject {
|
||||
.sorted { $0.isPinned && !$1.isPinned }
|
||||
}
|
||||
|
||||
// MARK: - Recurrence engine
|
||||
|
||||
private static let occFmt: DateFormatter = {
|
||||
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f
|
||||
}()
|
||||
func occurrenceKey(_ date: Date) -> String { Self.occFmt.string(from: date) }
|
||||
|
||||
/// Whether a task repeats on a schedule.
|
||||
func recurs(_ t: TaskItem) -> Bool {
|
||||
t.isRecurring && (t.recurrenceLabel ?? "None") != "None" && t.dueDate != nil
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// `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 {
|
||||
guard recurs(t), let start = t.dueDate else { return false }
|
||||
return RecurrenceRule.isOccurrence(label: t.recurrenceLabel, interval: t.recurrenceInterval ?? 1,
|
||||
start: start, date: date, end: t.recurrenceEnd)
|
||||
}
|
||||
|
||||
func occurrenceComplete(_ t: TaskItem, on date: Date) -> Bool {
|
||||
t.completedOccurrences?.contains(occurrenceKey(date)) ?? false
|
||||
}
|
||||
|
||||
/// Toggle one occurrence's completion — never affects other dates.
|
||||
func toggleOccurrence(_ t: TaskItem, on date: Date) {
|
||||
guard let idx = tasks.firstIndex(where: { $0.id == t.id }) else { return }
|
||||
let key = occurrenceKey(date)
|
||||
var set = Set(tasks[idx].completedOccurrences ?? [])
|
||||
let nowDone: Bool
|
||||
if set.contains(key) { set.remove(key); nowDone = false } else { set.insert(key); nowDone = true }
|
||||
tasks[idx].completedOccurrences = Array(set)
|
||||
save()
|
||||
// Completing a recurring occurrence → "✓ Done. Repeats …" confirmation.
|
||||
if nowDone {
|
||||
let next = nextActiveOccurrence(tasks[idx], from: date)
|
||||
NotificationManager.shared.notifyRecurringCompleted(title: tasks[idx].title, nextOccurrence: next)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// occurrence's completion baked into `isComplete` (so existing rows just work).
|
||||
func occurrenceCopy(_ t: TaskItem, on date: Date) -> TaskItem {
|
||||
var c = t
|
||||
let cal = Calendar.current
|
||||
if let due = t.dueDate {
|
||||
let hm = cal.dateComponents([.hour, .minute], from: due)
|
||||
c.dueDate = cal.date(bySettingHour: hm.hour ?? 0, minute: hm.minute ?? 0, second: 0, of: date) ?? date
|
||||
} else { c.dueDate = date }
|
||||
let done = occurrenceComplete(t, on: date)
|
||||
c.isComplete = done
|
||||
c.completedAt = done ? c.dueDate : nil
|
||||
return c
|
||||
}
|
||||
|
||||
/// Next occurrence on/after `date`, within the repeat-until.
|
||||
func nextOccurrence(_ t: TaskItem, onOrAfter date: Date) -> Date? {
|
||||
guard recurs(t), let start = t.dueDate else { return nil }
|
||||
let cal = Calendar.current
|
||||
var cur = max(cal.startOfDay(for: start), cal.startOfDay(for: date))
|
||||
for _ in 0..<4000 {
|
||||
if let end = t.recurrenceEnd, cur > cal.startOfDay(for: end) { return nil }
|
||||
if isOccurrence(t, on: cur) { return cur }
|
||||
cur = cal.date(byAdding: .day, value: 1, to: cur) ?? cur
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Eisenhower Matrix (TickTick-style: Priority × deadline, with urgency override)
|
||||
//
|
||||
// The Matrix is a VIEW of tasks, not a separate system. A task is auto-placed:
|
||||
// • Importance (top row) = Priority is High; Medium/Low/None = bottom row.
|
||||
// • Urgency (left column) = its (next active) due date is today/overdue/within
|
||||
// `urgentWindowDays`; undated or far-out = right column.
|
||||
// So Q1 = High + urgent, Q2 = High + not urgent, Q3 = not-High + urgent, Q4 = the rest.
|
||||
// Vertical drag changes Priority directly. Horizontal drag sets `urgencyOverride`
|
||||
// because we should not fabricate/destroy a real deadline just to move columns.
|
||||
// Editing the due date clears urgency override so the task re-places live.
|
||||
// Recurring tasks roll forward (a completed occurrence is replaced by the next).
|
||||
|
||||
static let urgentWindowDays = 3
|
||||
static let categoryUrgentWindowDays = 14
|
||||
|
||||
private func isImportant(_ t: TaskItem) -> Bool { t.priority == .high }
|
||||
|
||||
/// The due date that drives urgency now: a recurring task's next active
|
||||
/// occurrence, or a one-off task's own due date.
|
||||
private func effectiveDue(_ t: TaskItem, on today: Date) -> Date? {
|
||||
recurs(t) ? nextActiveOccurrence(t, from: today) : t.dueDate
|
||||
}
|
||||
|
||||
private func isUrgent(_ t: TaskItem, on today: Date) -> Bool {
|
||||
t.urgencyOverride ?? urgentByDeadline(t, on: today)
|
||||
}
|
||||
|
||||
/// Date-derived urgency, ignoring any manual override (overdue/today/within window).
|
||||
private func urgentByDeadline(_ t: TaskItem, on today: Date = Date()) -> Bool {
|
||||
guard let due = effectiveDue(t, on: today) else { return false } // undated → never urgent
|
||||
let days = Calendar.current.dateComponents([.day],
|
||||
from: Calendar.current.startOfDay(for: today),
|
||||
to: Calendar.current.startOfDay(for: due)).day ?? 0
|
||||
return days <= urgentWindow(for: t) // due soon / overdue
|
||||
}
|
||||
|
||||
private func urgentWindow(for t: TaskItem) -> Int {
|
||||
switch t.category {
|
||||
case .birthday, .domain, .annual:
|
||||
return Self.categoryUrgentWindowDays
|
||||
case .reminder, .custom:
|
||||
let title = t.title.lowercased()
|
||||
if title.contains("domain") || title.contains("subscription")
|
||||
|| title.contains("annual") || title.contains("renewal")
|
||||
|| title.contains("renew") || title.contains("exam")
|
||||
|| title.contains("deadline") {
|
||||
return Self.categoryUrgentWindowDays
|
||||
}
|
||||
return Self.urgentWindowDays
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto-placement from Priority (importance) × deadline (urgency).
|
||||
private func autoQuadrant(_ t: TaskItem, on today: Date) -> Quadrant {
|
||||
switch (isImportant(t), isUrgent(t, on: today)) {
|
||||
case (true, true): return .urgent
|
||||
case (true, false): return .schedule
|
||||
case (false, true): return .delegate_
|
||||
case (false, false): return .eliminate
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a task appears in the Matrix now.
|
||||
func displayQuadrant(_ t: TaskItem, on today: Date = Date()) -> Quadrant {
|
||||
autoQuadrant(t, on: today)
|
||||
}
|
||||
|
||||
/// Matrix rows for a quadrant, grouped by the *displayed* (computed) quadrant.
|
||||
/// Recurring tasks contribute their single next-active occurrence.
|
||||
func matrixTasks(for quadrant: Quadrant, on today: Date = Date()) -> [TaskItem] {
|
||||
var out: [TaskItem] = []
|
||||
for t in tasks {
|
||||
if recurs(t) {
|
||||
guard let occ = nextActiveOccurrence(t, from: today) else { continue }
|
||||
let copy = occurrenceCopy(t, on: occ)
|
||||
if displayQuadrant(copy, on: today) == quadrant { out.append(copy) }
|
||||
} else if displayQuadrant(t, on: today) == quadrant {
|
||||
out.append(t)
|
||||
}
|
||||
}
|
||||
return out.sorted { $0.isPinned && !$1.isPinned }
|
||||
}
|
||||
|
||||
/// All task instances on a given calendar day: non-recurring tasks due that day
|
||||
/// plus a copy of each recurring task that occurs that day. Used by the calendar.
|
||||
func occurrences(on date: Date) -> [TaskItem] {
|
||||
let cal = Calendar.current
|
||||
var out: [TaskItem] = []
|
||||
for t in tasks {
|
||||
if recurs(t) {
|
||||
if isOccurrence(t, on: date) { out.append(occurrenceCopy(t, on: date)) }
|
||||
} else if let d = t.dueDate, cal.isDate(d, inSameDayAs: date) {
|
||||
out.append(t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func pin(_ task: TaskItem) {
|
||||
guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
|
||||
tasks[idx].isPinned.toggle()
|
||||
@@ -212,47 +572,106 @@ class TaskViewModel: ObservableObject {
|
||||
func setDate(_ task: TaskItem, date: Date?) {
|
||||
guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
|
||||
tasks[idx].dueDate = date
|
||||
tasks[idx].urgencyOverride = nil // deadline changed → re-place from urgency
|
||||
tasks[idx].quadrant = displayQuadrant(tasks[idx])
|
||||
save()
|
||||
}
|
||||
|
||||
func setCategory(_ task: TaskItem, category: TaskCategory) {
|
||||
guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
|
||||
tasks[idx].category = category
|
||||
tasks[idx].quadrant = displayQuadrant(tasks[idx])
|
||||
save()
|
||||
}
|
||||
|
||||
func setPriority(_ task: TaskItem, priority: Priority) {
|
||||
guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
|
||||
tasks[idx].priority = priority
|
||||
tasks[idx].quadrant = displayQuadrant(tasks[idx]) // color follows placement
|
||||
save()
|
||||
}
|
||||
|
||||
/// Manual drag/move in the Matrix. Vertical movement updates Priority directly.
|
||||
/// Horizontal movement sets an urgency override instead of mutating due dates —
|
||||
/// but only when the target column disagrees with the date-derived urgency, so a
|
||||
/// drag into a task's natural column leaves it on Auto (no false "Manual" flag).
|
||||
func moveToQuadrant(taskID: UUID, quadrant: Quadrant) {
|
||||
guard let idx = tasks.firstIndex(where: { $0.id == taskID }) else { return }
|
||||
tasks[idx].quadrant = quadrant
|
||||
let importantRow = (quadrant == .urgent || quadrant == .schedule)
|
||||
let urgentColumn = (quadrant == .urgent || quadrant == .delegate_)
|
||||
if importantRow {
|
||||
tasks[idx].priority = .high
|
||||
} else if tasks[idx].priority == .high {
|
||||
tasks[idx].priority = .medium
|
||||
}
|
||||
tasks[idx].urgencyOverride = urgentByDeadline(tasks[idx]) == urgentColumn ? nil : urgentColumn
|
||||
tasks[idx].quadrant = quadrant // keep stored quadrant in sync (color + widget accent)
|
||||
save()
|
||||
}
|
||||
|
||||
func resetMatrixUrgencyOverride(_ task: TaskItem) {
|
||||
guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
|
||||
tasks[idx].urgencyOverride = nil
|
||||
tasks[idx].quadrant = displayQuadrant(tasks[idx])
|
||||
save()
|
||||
}
|
||||
|
||||
/// KisaniCal priority defaults — high-stakes events start High, workouts Medium.
|
||||
/// Applied only when the user didn't pick a priority (== .none) at creation.
|
||||
private func defaultPriority(category: TaskCategory, title: String) -> Priority {
|
||||
switch category {
|
||||
case .birthday, .domain, .annual: return .high
|
||||
default: break
|
||||
}
|
||||
let t = title.lowercased()
|
||||
if t.contains("birthday") || t.contains("exam") || t.contains("subscription")
|
||||
|| t.contains("renew") || t.contains("domain") || t.contains("expir") { return .high }
|
||||
if t.contains("workout") || t.contains("gym") || t.contains("exercise") { return .medium }
|
||||
return .none
|
||||
}
|
||||
|
||||
func addTask(title: String, dueDate: Date?, hasTime: Bool = false,
|
||||
quadrant: Quadrant = .urgent, category: TaskCategory = .reminder,
|
||||
taskColor: TaskColor = .orange, isRecurring: Bool = false,
|
||||
recurrenceLabel: String? = nil,
|
||||
recurrenceLabel: String? = nil, recurrenceInterval: Int? = nil,
|
||||
endDate: Date? = nil, isAllDay: Bool = false,
|
||||
priority: Priority = .none, reminderDate: Date? = nil) {
|
||||
tasks.append(TaskItem(title: title, dueDate: dueDate, hasTime: hasTime,
|
||||
quadrant: quadrant, category: category,
|
||||
taskColor: taskColor, isRecurring: isRecurring,
|
||||
recurrenceLabel: recurrenceLabel,
|
||||
endDate: endDate, isAllDay: isAllDay,
|
||||
priority: priority, reminderDate: reminderDate))
|
||||
priority: Priority = .none, reminderDate: Date? = nil,
|
||||
constantReminder: Bool = false, recurrenceEnd: Date? = nil) {
|
||||
let resolvedPriority = priority == .none
|
||||
? defaultPriority(category: category, title: title) : priority
|
||||
var item = TaskItem(title: title, dueDate: dueDate, hasTime: hasTime,
|
||||
quadrant: quadrant, category: category,
|
||||
taskColor: taskColor, isRecurring: isRecurring,
|
||||
recurrenceLabel: recurrenceLabel,
|
||||
endDate: endDate, isAllDay: isAllDay,
|
||||
priority: resolvedPriority, reminderDate: reminderDate,
|
||||
constantReminder: constantReminder)
|
||||
item.recurrenceInterval = recurrenceInterval
|
||||
item.recurrenceEnd = recurrenceEnd
|
||||
item.quadrant = displayQuadrant(item) // color/widget identity follows placement
|
||||
tasks.append(item)
|
||||
save()
|
||||
}
|
||||
|
||||
func updateTask(_ task: TaskItem, title: String, dueDate: Date?, hasTime: Bool,
|
||||
isRecurring: Bool, recurrenceLabel: String?,
|
||||
endDate: Date?, isAllDay: Bool, reminderDate: Date?) {
|
||||
endDate: Date?, isAllDay: Bool, reminderDate: Date?,
|
||||
constantReminder: Bool = false, recurrenceEnd: Date? = nil,
|
||||
recurrenceInterval: Int? = nil) {
|
||||
guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
|
||||
tasks[idx].title = title
|
||||
tasks[idx].dueDate = dueDate
|
||||
tasks[idx].hasTime = hasTime
|
||||
tasks[idx].isRecurring = isRecurring
|
||||
tasks[idx].recurrenceLabel = recurrenceLabel
|
||||
tasks[idx].endDate = endDate
|
||||
tasks[idx].isAllDay = isAllDay
|
||||
tasks[idx].reminderDate = reminderDate
|
||||
tasks[idx].title = title
|
||||
tasks[idx].dueDate = dueDate
|
||||
tasks[idx].hasTime = hasTime
|
||||
tasks[idx].isRecurring = isRecurring
|
||||
tasks[idx].recurrenceLabel = recurrenceLabel
|
||||
tasks[idx].recurrenceInterval = recurrenceInterval
|
||||
tasks[idx].endDate = endDate
|
||||
tasks[idx].isAllDay = isAllDay
|
||||
tasks[idx].reminderDate = reminderDate
|
||||
tasks[idx].constantReminder = constantReminder
|
||||
tasks[idx].recurrenceEnd = recurrenceEnd
|
||||
tasks[idx].urgencyOverride = nil // editor saved a new deadline → re-place
|
||||
tasks[idx].quadrant = displayQuadrant(tasks[idx])
|
||||
save()
|
||||
}
|
||||
|
||||
@@ -260,6 +679,24 @@ class TaskViewModel: ObservableObject {
|
||||
guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
|
||||
let base = tasks[idx].dueDate ?? Date()
|
||||
tasks[idx].dueDate = Calendar.current.date(byAdding: .day, value: days, to: base)
|
||||
tasks[idx].urgencyOverride = nil
|
||||
tasks[idx].quadrant = displayQuadrant(tasks[idx])
|
||||
save()
|
||||
}
|
||||
|
||||
func postpone(_ task: TaskItem, minutes: Int) {
|
||||
guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
|
||||
let target = Date().addingTimeInterval(TimeInterval(minutes * 60))
|
||||
if tasks[idx].reminderDate != nil {
|
||||
tasks[idx].reminderDate = target
|
||||
} else if tasks[idx].dueDate != nil {
|
||||
tasks[idx].dueDate = target
|
||||
tasks[idx].hasTime = true
|
||||
tasks[idx].urgencyOverride = nil
|
||||
tasks[idx].quadrant = displayQuadrant(tasks[idx])
|
||||
} else {
|
||||
tasks[idx].reminderDate = target
|
||||
}
|
||||
save()
|
||||
}
|
||||
|
||||
@@ -269,6 +706,8 @@ class TaskViewModel: ObservableObject {
|
||||
guard let idx = tasks.firstIndex(where: { $0.id == id }) else { continue }
|
||||
let base = tasks[idx].dueDate ?? Date()
|
||||
tasks[idx].dueDate = Calendar.current.date(byAdding: .day, value: days, to: base)
|
||||
tasks[idx].urgencyOverride = nil // re-place from the new deadline
|
||||
tasks[idx].quadrant = displayQuadrant(tasks[idx]) // keep color/fallback in sync
|
||||
}
|
||||
save()
|
||||
}
|
||||
@@ -278,4 +717,3 @@ class TaskViewModel: ObservableObject {
|
||||
save()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ struct AppColors {
|
||||
static let blue = Color(r: 77, g: 157, b: 224)
|
||||
static let yellow = Color(r: 232, g: 184, b: 42)
|
||||
static let red = Color(r: 232, g: 66, b: 66)
|
||||
static let coffeeBrown = Color(r: 122, g: 84, b: 52)
|
||||
|
||||
static let accentSoft = Color(UIColor { $0.userInterfaceStyle == .dark
|
||||
? UIColor(r: 52, g: 28, b: 14) // dark: deep warm orange
|
||||
|
||||
161
KisaniCal/Views/ActivityHistoryView.swift
Normal file
@@ -0,0 +1,161 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Activity History
|
||||
// Day-by-day archive of everything logged: tasks completed (recurring
|
||||
// occurrences included), workouts done/missed/compensated. Only days with
|
||||
// activity are shown — quiet days stay quiet.
|
||||
struct ActivityHistoryView: View {
|
||||
@EnvironmentObject var taskVM: TaskViewModel
|
||||
@EnvironmentObject var workoutVM: WorkoutViewModel
|
||||
@Environment(\.colorScheme) var cs
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
/// How far back the archive reaches.
|
||||
private let daysBack = 90
|
||||
|
||||
private static let keyFmt: DateFormatter = {
|
||||
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f
|
||||
}()
|
||||
private static let pretty: DateFormatter = {
|
||||
let f = DateFormatter(); f.dateFormat = "EEE, MMM d"; return f
|
||||
}()
|
||||
private static let timeFmt: DateFormatter = {
|
||||
let f = DateFormatter(); f.dateFormat = "HH:mm"; return f
|
||||
}()
|
||||
|
||||
private struct DayEntry: Identifiable {
|
||||
let id: String // "yyyy-MM-dd"
|
||||
let date: Date
|
||||
let tasks: [TaskItem]
|
||||
let workout: WorkoutDayLog? // full log if one was saved
|
||||
let workoutDone: Bool // in workoutDates (streak record)
|
||||
let missed: Bool // marked "didn't do it"
|
||||
let compensated: Bool // rest-day makeup scheduled
|
||||
}
|
||||
|
||||
private var entries: [DayEntry] {
|
||||
let cal = Calendar.current
|
||||
let today = cal.startOfDay(for: Date())
|
||||
let logByDate = Dictionary(uniqueKeysWithValues: workoutVM.history.map { ($0.date, $0) })
|
||||
let doneSet = Set(workoutVM.workoutDates)
|
||||
let missedSet = Set(workoutVM.missedDates)
|
||||
|
||||
return (0..<daysBack).compactMap { ago -> DayEntry? in
|
||||
guard let day = cal.date(byAdding: .day, value: -ago, to: today) else { return nil }
|
||||
let key = Self.keyFmt.string(from: day)
|
||||
let tasks = taskVM.completions(on: day)
|
||||
let log = logByDate[key]
|
||||
let done = doneSet.contains(key)
|
||||
let missed = missedSet.contains(key)
|
||||
let comp = workoutVM.compensations[key] != nil
|
||||
guard !tasks.isEmpty || log != nil || done || missed || comp else { return nil }
|
||||
return DayEntry(id: key, date: day, tasks: tasks, workout: log,
|
||||
workoutDone: done, missed: missed, compensated: comp)
|
||||
}
|
||||
}
|
||||
|
||||
private func label(_ date: Date) -> String {
|
||||
if Calendar.current.isDateInToday(date) { return "Today" }
|
||||
if Calendar.current.isDateInYesterday(date) { return "Yesterday" }
|
||||
return Self.pretty.string(from: date)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text("Activity").font(AppFonts.sans(17, weight: .bold)).foregroundColor(AppColors.text(cs))
|
||||
Spacer()
|
||||
Button("Done") { dismiss() }
|
||||
.font(AppFonts.sans(13, weight: .semibold))
|
||||
.foregroundColor(AppColors.accent).buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 20).padding(.top, 20).padding(.bottom, 14)
|
||||
Divider().background(AppColors.border(cs))
|
||||
|
||||
if entries.isEmpty {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "clock.arrow.circlepath").font(.system(size: 28)).foregroundColor(AppColors.text3(cs))
|
||||
Text("Nothing here yet").font(AppFonts.sans(14, weight: .semibold)).foregroundColor(AppColors.text2(cs))
|
||||
Text("Completed tasks and workouts show up here, day by day.")
|
||||
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs)).multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity).padding()
|
||||
} else {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: 12) {
|
||||
ForEach(entries) { day in
|
||||
dayCard(day)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16).padding(.top, 14).padding(.bottom, 30)
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(AppColors.background(cs).ignoresSafeArea())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func dayCard(_ day: DayEntry) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
Text(label(day.date))
|
||||
.font(AppFonts.sans(13, weight: .bold)).foregroundColor(AppColors.text(cs))
|
||||
Spacer()
|
||||
if !day.tasks.isEmpty {
|
||||
Text("\(day.tasks.count) task\(day.tasks.count == 1 ? "" : "s")")
|
||||
.font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.green)
|
||||
}
|
||||
}
|
||||
|
||||
// Workout line
|
||||
if let log = day.workout {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "dumbbell.fill").font(.system(size: 10)).foregroundColor(AppColors.green).frame(width: 18)
|
||||
Text(log.programName).font(AppFonts.sans(12, weight: .semibold)).foregroundColor(AppColors.text2(cs))
|
||||
Spacer()
|
||||
Text("\(log.doneSets)/\(log.totalSets) sets\(log.volume > 0 ? " · \(Int(log.volume)) kg" : "")")
|
||||
.font(AppFonts.mono(10)).foregroundColor(AppColors.text3(cs))
|
||||
}
|
||||
} else if day.workoutDone {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "dumbbell.fill").font(.system(size: 10)).foregroundColor(AppColors.green).frame(width: 18)
|
||||
Text("Workout done").font(AppFonts.sans(12)).foregroundColor(AppColors.text2(cs))
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
if day.missed {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "minus.circle").font(.system(size: 10)).foregroundColor(AppColors.accent).frame(width: 18)
|
||||
Text("Workout missed\(day.compensated ? " · made up on a rest day" : "")")
|
||||
.font(AppFonts.sans(12)).foregroundColor(AppColors.text3(cs))
|
||||
Spacer()
|
||||
}
|
||||
} else if day.compensated && !day.workoutDone {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "arrow.uturn.forward.circle").font(.system(size: 10)).foregroundColor(AppColors.blue).frame(width: 18)
|
||||
Text("Rest-day makeup scheduled").font(AppFonts.sans(12)).foregroundColor(AppColors.text3(cs))
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
// Task lines
|
||||
ForEach(day.tasks) { t in
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "checkmark.square").font(.system(size: 10)).foregroundColor(AppColors.green).frame(width: 18)
|
||||
Text(t.title).font(AppFonts.sans(12)).foregroundColor(AppColors.text2(cs)).lineLimit(1)
|
||||
if t.isRecurring {
|
||||
Image(systemName: "repeat").font(.system(size: 8)).foregroundColor(AppColors.text3(cs))
|
||||
}
|
||||
Spacer()
|
||||
if t.hasTime, let at = t.completedAt {
|
||||
Text(Self.timeFmt.string(from: at)).font(AppFonts.mono(10)).foregroundColor(AppColors.text3(cs))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.background(AppColors.surface(cs))
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
|
||||
.overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.border(cs), lineWidth: 1))
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@ struct ExercisePickerSheet: View {
|
||||
@State private var setsText = "3"
|
||||
@State private var repsText = "10"
|
||||
@State private var showCustom = false
|
||||
@State private var addedCount = 0
|
||||
@State private var flashedId: UUID? = nil
|
||||
@FocusState private var customFocused: Bool
|
||||
|
||||
private func addExercise(name: String) {
|
||||
@@ -25,27 +27,48 @@ struct ExercisePickerSheet: View {
|
||||
} else {
|
||||
vm.addExercise(to: sectionId, name: name, setsCount: sets, repsCount: reps)
|
||||
}
|
||||
addedCount += 1
|
||||
}
|
||||
|
||||
// Briefly mark a template row as "added" so the user gets feedback while keeping the sheet open.
|
||||
private func flash(_ id: UUID) {
|
||||
withAnimation(KisaniSpring.micro) { flashedId = id }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) {
|
||||
if flashedId == id { withAnimation(KisaniSpring.micro) { flashedId = nil } }
|
||||
}
|
||||
}
|
||||
|
||||
private var filtered: [ExerciseTemplate] {
|
||||
let byMuscle = exerciseLibrary.filter { $0.muscle == selectedMuscle }
|
||||
guard !searchText.isEmpty else { return byMuscle }
|
||||
return byMuscle.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
|
||||
// When searching, look across the whole library (ignore the muscle tab).
|
||||
guard searchText.isEmpty else {
|
||||
return exerciseLibrary.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
|
||||
}
|
||||
return exerciseLibrary.filter { $0.muscle == selectedMuscle }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// ── Header ──
|
||||
HStack {
|
||||
HStack(spacing: 8) {
|
||||
Text("Add Exercise")
|
||||
.font(AppFonts.sans(17, weight: .bold))
|
||||
.foregroundColor(AppColors.text(cs))
|
||||
if addedCount > 0 {
|
||||
Text("\(addedCount) added")
|
||||
.font(AppFonts.mono(9.5, weight: .bold))
|
||||
.foregroundColor(AppColors.green)
|
||||
.padding(.horizontal, 7).padding(.vertical, 3)
|
||||
.background(AppColors.greenSoft)
|
||||
.clipShape(Capsule())
|
||||
.transition(.scale.combined(with: .opacity))
|
||||
}
|
||||
Spacer()
|
||||
Button("Cancel") { dismiss() }
|
||||
.font(AppFonts.sans(13))
|
||||
.foregroundColor(AppColors.text3(cs))
|
||||
Button(addedCount > 0 ? "Done" : "Cancel") { dismiss() }
|
||||
.font(AppFonts.sans(13, weight: addedCount > 0 ? .bold : .regular))
|
||||
.foregroundColor(addedCount > 0 ? AppColors.accent : AppColors.text3(cs))
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.animation(KisaniSpring.snappy, value: addedCount)
|
||||
.padding(.horizontal, 20).padding(.top, 20).padding(.bottom, 12)
|
||||
|
||||
Divider().background(AppColors.border(cs))
|
||||
@@ -114,7 +137,8 @@ struct ExercisePickerSheet: View {
|
||||
let n = customName.trimmingCharacters(in: .whitespaces)
|
||||
guard !n.isEmpty else { return }
|
||||
addExercise(name: n)
|
||||
dismiss()
|
||||
customName = ""
|
||||
customFocused = true
|
||||
} label: {
|
||||
Text("Add Custom Exercise")
|
||||
.font(AppFonts.sans(13, weight: .bold)).foregroundColor(.white)
|
||||
@@ -134,9 +158,9 @@ struct ExercisePickerSheet: View {
|
||||
|
||||
// Library exercises
|
||||
ForEach(filtered) { template in
|
||||
ExerciseTemplateRow(template: template) {
|
||||
ExerciseTemplateRow(template: template, added: flashedId == template.id) {
|
||||
addExercise(name: template.name)
|
||||
dismiss()
|
||||
flash(template.id)
|
||||
}
|
||||
Divider().background(AppColors.border(cs)).padding(.leading, 60)
|
||||
}
|
||||
@@ -188,6 +212,7 @@ struct ExercisePickerSheet: View {
|
||||
private struct ExerciseTemplateRow: View {
|
||||
@Environment(\.colorScheme) private var cs
|
||||
let template: ExerciseTemplate
|
||||
var added: Bool = false
|
||||
let onAdd: () -> Void
|
||||
|
||||
var body: some View {
|
||||
@@ -206,9 +231,10 @@ private struct ExerciseTemplateRow: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "plus.circle.fill")
|
||||
Image(systemName: added ? "checkmark.circle.fill" : "plus.circle.fill")
|
||||
.font(.system(size: 18))
|
||||
.foregroundColor(AppColors.accent.opacity(0.7))
|
||||
.foregroundColor(added ? AppColors.green : AppColors.accent.opacity(0.7))
|
||||
.scaleEffect(added ? 1.15 : 1)
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
.contentShape(Rectangle())
|
||||
|
||||
427
KisaniCal/Views/AnalyticsView.swift
Normal file
@@ -0,0 +1,427 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ struct AuthView: View {
|
||||
.foregroundColor(AppColors.accent)
|
||||
}
|
||||
VStack(spacing: 6) {
|
||||
Text("Kisani Cal")
|
||||
Text("Wenza")
|
||||
.font(AppFonts.sans(32, weight: .bold))
|
||||
.foregroundColor(AppColors.text(cs))
|
||||
Text("Your calendar, your way.")
|
||||
|
||||
@@ -5,10 +5,11 @@ struct MatrixView: View {
|
||||
@EnvironmentObject var taskVM: TaskViewModel
|
||||
@State private var showAddTask = false
|
||||
@State private var dropTarget: Quadrant? = nil
|
||||
@State private var hideCompleted = false
|
||||
@State private var hideCompleted = true
|
||||
@State private var showUserGuide = false
|
||||
@State private var drillQuadrant: Quadrant? = nil
|
||||
@State private var showDrill = false
|
||||
@State private var editingTask: TaskItem? = nil
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
@@ -56,7 +57,7 @@ struct MatrixView: View {
|
||||
quadrant: .urgent,
|
||||
roman: "I", label: "Urgent & Important",
|
||||
badgeBg: AppColors.accentSoft, badgeColor: AppColors.accent,
|
||||
tasks: taskVM.tasks(for: .urgent).filter { !hideCompleted || !$0.isComplete },
|
||||
tasks: taskVM.matrixTasks(for: .urgent).filter { !hideCompleted || !$0.isComplete },
|
||||
isDropTarget: dropTarget == .urgent,
|
||||
onToggle: { taskVM.toggle($0) },
|
||||
onDrop: { id in withAnimation(KisaniSpring.snappy) { taskVM.moveToQuadrant(taskID: id, quadrant: .urgent); dropTarget = nil } },
|
||||
@@ -64,16 +65,21 @@ struct MatrixView: View {
|
||||
onDropExit: { dropTarget = nil },
|
||||
onPin: { taskVM.pin($0) },
|
||||
onSetDate: { taskVM.setDate($0, date: $1) },
|
||||
onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
|
||||
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
|
||||
onSetCategory: { taskVM.setCategory($0, category: $1) },
|
||||
onSetPriority: { taskVM.setPriority($0, priority: $1) },
|
||||
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) },
|
||||
onEdit: { editingTask = $0 },
|
||||
onDelete: { taskVM.delete($0) },
|
||||
onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) },
|
||||
onTapHeader: { drillQuadrant = .urgent; showDrill = true }
|
||||
)
|
||||
QuadrantCard(
|
||||
quadrant: .schedule,
|
||||
roman: "II", label: "Not Urgent & Important",
|
||||
badgeBg: AppColors.yellowSoft, badgeColor: AppColors.yellow,
|
||||
tasks: taskVM.tasks(for: .schedule).filter { !hideCompleted || !$0.isComplete },
|
||||
tasks: taskVM.matrixTasks(for: .schedule).filter { !hideCompleted || !$0.isComplete },
|
||||
isDropTarget: dropTarget == .schedule,
|
||||
onToggle: { taskVM.toggle($0) },
|
||||
onDrop: { id in withAnimation(KisaniSpring.snappy) { taskVM.moveToQuadrant(taskID: id, quadrant: .schedule); dropTarget = nil } },
|
||||
@@ -81,9 +87,14 @@ struct MatrixView: View {
|
||||
onDropExit: { dropTarget = nil },
|
||||
onPin: { taskVM.pin($0) },
|
||||
onSetDate: { taskVM.setDate($0, date: $1) },
|
||||
onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
|
||||
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
|
||||
onSetCategory: { taskVM.setCategory($0, category: $1) },
|
||||
onSetPriority: { taskVM.setPriority($0, priority: $1) },
|
||||
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) },
|
||||
onEdit: { editingTask = $0 },
|
||||
onDelete: { taskVM.delete($0) },
|
||||
onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) },
|
||||
onTapHeader: { drillQuadrant = .schedule; showDrill = true }
|
||||
)
|
||||
}
|
||||
@@ -94,7 +105,7 @@ struct MatrixView: View {
|
||||
quadrant: .delegate_,
|
||||
roman: "III", label: "Urgent & Unimportant",
|
||||
badgeBg: AppColors.blueSoft, badgeColor: AppColors.blue,
|
||||
tasks: taskVM.tasks(for: .delegate_).filter { !hideCompleted || !$0.isComplete },
|
||||
tasks: taskVM.matrixTasks(for: .delegate_).filter { !hideCompleted || !$0.isComplete },
|
||||
isDropTarget: dropTarget == .delegate_,
|
||||
onToggle: { taskVM.toggle($0) },
|
||||
onDrop: { id in withAnimation(KisaniSpring.snappy) { taskVM.moveToQuadrant(taskID: id, quadrant: .delegate_); dropTarget = nil } },
|
||||
@@ -102,16 +113,21 @@ struct MatrixView: View {
|
||||
onDropExit: { dropTarget = nil },
|
||||
onPin: { taskVM.pin($0) },
|
||||
onSetDate: { taskVM.setDate($0, date: $1) },
|
||||
onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
|
||||
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
|
||||
onSetCategory: { taskVM.setCategory($0, category: $1) },
|
||||
onSetPriority: { taskVM.setPriority($0, priority: $1) },
|
||||
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) },
|
||||
onEdit: { editingTask = $0 },
|
||||
onDelete: { taskVM.delete($0) },
|
||||
onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) },
|
||||
onTapHeader: { drillQuadrant = .delegate_; showDrill = true }
|
||||
)
|
||||
QuadrantCard(
|
||||
quadrant: .eliminate,
|
||||
roman: "IV", label: "Not Urgent & Unimportant",
|
||||
badgeBg: AppColors.greenSoft, badgeColor: AppColors.green,
|
||||
tasks: taskVM.tasks(for: .eliminate).filter { !hideCompleted || !$0.isComplete },
|
||||
tasks: taskVM.matrixTasks(for: .eliminate).filter { !hideCompleted || !$0.isComplete },
|
||||
isDropTarget: dropTarget == .eliminate,
|
||||
onToggle: { taskVM.toggle($0) },
|
||||
onDrop: { id in withAnimation(KisaniSpring.snappy) { taskVM.moveToQuadrant(taskID: id, quadrant: .eliminate); dropTarget = nil } },
|
||||
@@ -119,9 +135,14 @@ struct MatrixView: View {
|
||||
onDropExit: { dropTarget = nil },
|
||||
onPin: { taskVM.pin($0) },
|
||||
onSetDate: { taskVM.setDate($0, date: $1) },
|
||||
onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
|
||||
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
|
||||
onSetCategory: { taskVM.setCategory($0, category: $1) },
|
||||
onSetPriority: { taskVM.setPriority($0, priority: $1) },
|
||||
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) },
|
||||
onEdit: { editingTask = $0 },
|
||||
onDelete: { taskVM.delete($0) },
|
||||
onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) },
|
||||
onTapHeader: { drillQuadrant = .eliminate; showDrill = true }
|
||||
)
|
||||
}
|
||||
@@ -147,6 +168,12 @@ struct MatrixView: View {
|
||||
.presentationDetents([.medium])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
.sheet(item: $editingTask) { task in
|
||||
TaskEditSheet(task: task)
|
||||
.environmentObject(taskVM)
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
.navigationDestination(isPresented: $showDrill) {
|
||||
if let q = drillQuadrant {
|
||||
QuadrantDetailView(quadrant: q)
|
||||
@@ -181,9 +208,14 @@ struct QuadrantCard: View {
|
||||
let onDropExit: () -> Void
|
||||
var onPin: ((TaskItem) -> Void)? = nil
|
||||
var onSetDate: ((TaskItem, Date?) -> Void)? = nil
|
||||
var onPostponeMinutes: ((TaskItem, Int) -> Void)? = nil
|
||||
var onMove: ((TaskItem, Quadrant) -> Void)? = nil
|
||||
var onSetCategory: ((TaskItem, TaskCategory) -> Void)? = nil
|
||||
var onSetPriority: ((TaskItem, Priority) -> Void)? = nil
|
||||
var onResetMatrixUrgency: ((TaskItem) -> Void)? = nil
|
||||
var onEdit: ((TaskItem) -> Void)? = nil
|
||||
var onDelete: ((TaskItem) -> Void)? = nil
|
||||
var onCompleteAndStopSeries: ((TaskItem) -> Void)? = nil
|
||||
var onTapHeader: (() -> Void)? = nil
|
||||
|
||||
private let shortFmt: DateFormatter = {
|
||||
@@ -234,12 +266,12 @@ struct QuadrantCard: View {
|
||||
Color.clear.frame(height: 0).trackScrollForTabBar()
|
||||
ForEach(tasks) { task in
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
// Circle checkbox
|
||||
// Rounded-square checkbox
|
||||
ZStack {
|
||||
Circle()
|
||||
RoundedRectangle(cornerRadius: 5, style: .continuous)
|
||||
.fill(task.isComplete ? badgeColor.opacity(0.15) : badgeBg)
|
||||
.frame(width: 16, height: 16)
|
||||
Circle()
|
||||
RoundedRectangle(cornerRadius: 5, style: .continuous)
|
||||
.stroke(task.isComplete ? badgeColor.opacity(0.4) : badgeColor, lineWidth: 1.5)
|
||||
.frame(width: 16, height: 16)
|
||||
if task.isComplete {
|
||||
@@ -260,6 +292,15 @@ struct QuadrantCard: View {
|
||||
.font(.system(size: 7, weight: .bold))
|
||||
.foregroundColor(badgeColor.opacity(0.7))
|
||||
}
|
||||
if task.urgencyOverride != nil {
|
||||
Text("Manual")
|
||||
.font(AppFonts.mono(7, weight: .bold))
|
||||
.foregroundColor(badgeColor)
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 2)
|
||||
.background(badgeBg)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
Text(task.title)
|
||||
.font(AppFonts.sans(12, weight: .medium))
|
||||
.foregroundColor(task.isComplete ? AppColors.text3(cs) : AppColors.text(cs))
|
||||
@@ -278,68 +319,21 @@ struct QuadrantCard: View {
|
||||
.padding(.leading, 2)
|
||||
.draggable(task.id.uuidString)
|
||||
.contextMenu {
|
||||
Button {
|
||||
withAnimation(KisaniSpring.snappy) { onPin?(task) }
|
||||
} label: {
|
||||
Label(task.isPinned ? "Unpin" : "Pin",
|
||||
systemImage: task.isPinned ? "pin.slash" : "pin")
|
||||
}
|
||||
|
||||
Menu {
|
||||
Button {
|
||||
onSetDate?(task, Calendar.current.startOfDay(for: Date()))
|
||||
} label: { Label("Today", systemImage: "sun.max") }
|
||||
Button {
|
||||
let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: Date())!
|
||||
onSetDate?(task, Calendar.current.startOfDay(for: tomorrow))
|
||||
} label: { Label("Tomorrow", systemImage: "sunrise") }
|
||||
Button {
|
||||
let nextWeek = Calendar.current.date(byAdding: .weekOfYear, value: 1, to: Date())!
|
||||
onSetDate?(task, Calendar.current.startOfDay(for: nextWeek))
|
||||
} label: { Label("Next Week", systemImage: "calendar") }
|
||||
Divider()
|
||||
Button {
|
||||
onSetDate?(task, nil)
|
||||
} label: { Label("Remove Date", systemImage: "xmark.circle") }
|
||||
} label: {
|
||||
Label("Date", systemImage: "calendar.badge.clock")
|
||||
}
|
||||
|
||||
Menu {
|
||||
ForEach(Quadrant.allCases.filter { $0 != quadrant }) { q in
|
||||
Button {
|
||||
withAnimation(KisaniSpring.snappy) { onMove?(task, q) }
|
||||
} label: {
|
||||
Text(q.rawValue)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label("Move", systemImage: "arrow.right.square")
|
||||
}
|
||||
|
||||
Menu {
|
||||
ForEach([TaskCategory.reminder, .birthday, .domain, .annual, .custom], id: \.rawValue) { cat in
|
||||
Button {
|
||||
onSetCategory?(task, cat)
|
||||
} label: {
|
||||
Label(cat.rawValue, systemImage: task.category == cat ? "checkmark" : "tag")
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label("Tags", systemImage: "tag")
|
||||
}
|
||||
|
||||
Button { } label: {
|
||||
Label("Add to Live Activity", systemImage: "pin.circle")
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Button(role: .destructive) {
|
||||
withAnimation(KisaniSpring.snappy) { onDelete?(task) }
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
TaskMenuItems(
|
||||
task: task,
|
||||
currentQuadrant: quadrant,
|
||||
onPin: { t in withAnimation(KisaniSpring.snappy) { onPin?(t) } },
|
||||
onSetDate: { onSetDate?($0, $1) },
|
||||
onPostponeMinutes: { onPostponeMinutes?($0, $1) },
|
||||
onMove: { t, q in withAnimation(KisaniSpring.snappy) { onMove?(t, q) } },
|
||||
onSetPriority: { onSetPriority?($0, $1) },
|
||||
onSetCategory: { onSetCategory?($0, $1) },
|
||||
onResetMatrixUrgency: { t in withAnimation(KisaniSpring.snappy) { onResetMatrixUrgency?(t) } },
|
||||
onLiveActivity:{ LiveActivityManager.toggle(for: $0) },
|
||||
onEdit: onEdit.map { cb in { cb($0) } },
|
||||
onDelete: { t in withAnimation(KisaniSpring.snappy) { onDelete?(t) } },
|
||||
onCompleteAndStopSeries: { t in withAnimation(KisaniSpring.snappy) { onCompleteAndStopSeries?(t) } }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -454,21 +448,23 @@ struct QuadrantDetailView: View {
|
||||
@State private var showAllCompleted = false
|
||||
private let completedLimit = 5
|
||||
|
||||
// Grouped by the *computed* quadrant (importance × deadline urgency); recurring
|
||||
// tasks contribute their single next-active occurrence.
|
||||
private var overdueTasks: [TaskItem] {
|
||||
let startOfDay = Calendar.current.startOfDay(for: Date())
|
||||
return taskVM.tasks
|
||||
.filter { $0.quadrant == quadrant && !$0.isComplete && ($0.dueDate ?? .distantFuture) < startOfDay }
|
||||
return taskVM.matrixTasks(for: quadrant)
|
||||
.filter { !$0.isComplete && ($0.dueDate ?? .distantFuture) < startOfDay }
|
||||
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
|
||||
}
|
||||
private var laterTasks: [TaskItem] {
|
||||
let startOfDay = Calendar.current.startOfDay(for: Date())
|
||||
return taskVM.tasks
|
||||
.filter { $0.quadrant == quadrant && !$0.isComplete && ($0.dueDate ?? .distantFuture) >= startOfDay }
|
||||
return taskVM.matrixTasks(for: quadrant)
|
||||
.filter { !$0.isComplete && ($0.dueDate ?? .distantFuture) >= startOfDay }
|
||||
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
|
||||
}
|
||||
private var completedTasks: [TaskItem] {
|
||||
taskVM.tasks
|
||||
.filter { $0.quadrant == quadrant && $0.isComplete }
|
||||
taskVM.matrixTasks(for: quadrant)
|
||||
.filter { $0.isComplete }
|
||||
.sorted { ($0.completedAt ?? .distantPast) > ($1.completedAt ?? .distantPast) }
|
||||
}
|
||||
private var allEmpty: Bool { overdueTasks.isEmpty && laterTasks.isEmpty && completedTasks.isEmpty }
|
||||
@@ -484,6 +480,23 @@ struct QuadrantDetailView: View {
|
||||
QDTaskRow(task: task, quadrant: quadrant,
|
||||
onTap: { editingTask = task },
|
||||
onToggle: { withAnimation(KisaniSpring.bounce) { taskVM.toggle(task) } })
|
||||
.contextMenu {
|
||||
TaskMenuItems(
|
||||
task: task,
|
||||
currentQuadrant: quadrant,
|
||||
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) },
|
||||
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) },
|
||||
onLiveActivity:{ LiveActivityManager.toggle(for: $0) },
|
||||
onEdit: { editingTask = $0 },
|
||||
onDelete: { taskVM.delete($0) },
|
||||
onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) }
|
||||
)
|
||||
}
|
||||
if task.id != overdueTasks.last?.id { Divider().padding(.leading, 46) }
|
||||
}
|
||||
}
|
||||
@@ -495,6 +508,23 @@ struct QuadrantDetailView: View {
|
||||
QDTaskRow(task: task, quadrant: quadrant,
|
||||
onTap: { editingTask = task },
|
||||
onToggle: { withAnimation(KisaniSpring.bounce) { taskVM.toggle(task) } })
|
||||
.contextMenu {
|
||||
TaskMenuItems(
|
||||
task: task,
|
||||
currentQuadrant: quadrant,
|
||||
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) },
|
||||
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) },
|
||||
onLiveActivity:{ LiveActivityManager.toggle(for: $0) },
|
||||
onEdit: { editingTask = $0 },
|
||||
onDelete: { taskVM.delete($0) },
|
||||
onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) }
|
||||
)
|
||||
}
|
||||
if task.id != laterTasks.last?.id { Divider().padding(.leading, 46) }
|
||||
}
|
||||
}
|
||||
@@ -507,6 +537,23 @@ struct QuadrantDetailView: View {
|
||||
QDTaskRow(task: task, quadrant: quadrant,
|
||||
onTap: { editingTask = task },
|
||||
onToggle: { withAnimation(KisaniSpring.bounce) { taskVM.toggle(task) } })
|
||||
.contextMenu {
|
||||
TaskMenuItems(
|
||||
task: task,
|
||||
currentQuadrant: quadrant,
|
||||
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) },
|
||||
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) },
|
||||
onLiveActivity:{ LiveActivityManager.toggle(for: $0) },
|
||||
onEdit: { editingTask = $0 },
|
||||
onDelete: { taskVM.delete($0) },
|
||||
onCompleteAndStopSeries: { taskVM.completeAndStopSeries($0) }
|
||||
)
|
||||
}
|
||||
if task.id != displayed.last?.id { Divider().padding(.leading, 46) }
|
||||
}
|
||||
if completedTasks.count > completedLimit && !showAllCompleted {
|
||||
@@ -609,13 +656,13 @@ private struct QDTaskRow: View {
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Button(action: onToggle) {
|
||||
RoundedRectangle(cornerRadius: 5)
|
||||
RoundedRectangle(cornerRadius: 5, style: .continuous)
|
||||
.stroke(task.isComplete ? quadrant.color.opacity(0.3) : quadrant.color, lineWidth: 1.5)
|
||||
.frame(width: 22, height: 22)
|
||||
.frame(width: 16, height: 16)
|
||||
.overlay {
|
||||
if task.isComplete {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 9, weight: .bold))
|
||||
.font(.system(size: 8, weight: .bold))
|
||||
.foregroundColor(quadrant.color.opacity(0.5))
|
||||
}
|
||||
}
|
||||
@@ -624,12 +671,23 @@ private struct QDTaskRow: View {
|
||||
|
||||
Button(action: onTap) {
|
||||
HStack(alignment: .top) {
|
||||
Text(task.title)
|
||||
.font(AppFonts.sans(14, weight: task.isComplete ? .regular : .medium))
|
||||
.foregroundColor(task.isComplete ? AppColors.text3(cs) : AppColors.text(cs))
|
||||
.strikethrough(task.isComplete, color: AppColors.text3(cs))
|
||||
.lineLimit(2)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if task.urgencyOverride != nil {
|
||||
Text("Manual")
|
||||
.font(AppFonts.mono(8, weight: .bold))
|
||||
.foregroundColor(quadrant.color)
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 2)
|
||||
.background(quadrant.softColor)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
Text(task.title)
|
||||
.font(AppFonts.sans(14, weight: task.isComplete ? .regular : .medium))
|
||||
.foregroundColor(task.isComplete ? AppColors.text3(cs) : AppColors.text(cs))
|
||||
.strikethrough(task.isComplete, color: AppColors.text3(cs))
|
||||
.lineLimit(2)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
VStack(alignment: .trailing, spacing: 3) {
|
||||
if let d = task.dueDate {
|
||||
@@ -679,21 +737,27 @@ struct TaskEditSheet: View {
|
||||
@State private var hasTime: Bool
|
||||
@State private var isRecurring: Bool
|
||||
@State private var recurrenceLabel: String?
|
||||
@State private var recurrenceEnd: Date?
|
||||
@State private var recurrenceInterval: Int?
|
||||
@State private var endDate: Date?
|
||||
@State private var isAllDay: Bool
|
||||
@State private var reminderDate: Date?
|
||||
@State private var constantReminder: Bool
|
||||
@State private var showDatePicker = false
|
||||
|
||||
init(task: TaskItem) {
|
||||
self.task = task
|
||||
_title = State(initialValue: task.title)
|
||||
_dueDate = State(initialValue: task.dueDate)
|
||||
_hasTime = State(initialValue: task.hasTime)
|
||||
_isRecurring = State(initialValue: task.isRecurring)
|
||||
_recurrenceLabel = State(initialValue: task.recurrenceLabel)
|
||||
_endDate = State(initialValue: task.endDate)
|
||||
_isAllDay = State(initialValue: task.isAllDay)
|
||||
_reminderDate = State(initialValue: task.reminderDate)
|
||||
_title = State(initialValue: task.title)
|
||||
_dueDate = State(initialValue: task.dueDate)
|
||||
_hasTime = State(initialValue: task.hasTime)
|
||||
_isRecurring = State(initialValue: task.isRecurring)
|
||||
_recurrenceLabel = State(initialValue: task.recurrenceLabel)
|
||||
_recurrenceEnd = State(initialValue: task.recurrenceEnd)
|
||||
_recurrenceInterval = State(initialValue: task.recurrenceInterval)
|
||||
_endDate = State(initialValue: task.endDate)
|
||||
_isAllDay = State(initialValue: task.isAllDay)
|
||||
_reminderDate = State(initialValue: task.reminderDate)
|
||||
_constantReminder = State(initialValue: task.constantReminder)
|
||||
}
|
||||
|
||||
private var dateSummary: String {
|
||||
@@ -751,7 +815,7 @@ struct TaskEditSheet: View {
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(task.quadrant.color)
|
||||
if let lbl = recurrenceLabel {
|
||||
Text(lbl)
|
||||
Text(RecurrenceRule.displayLabel(label: lbl, interval: recurrenceInterval ?? 1))
|
||||
.font(AppFonts.sans(12))
|
||||
.foregroundColor(AppColors.text3(cs))
|
||||
}
|
||||
@@ -839,7 +903,10 @@ struct TaskEditSheet: View {
|
||||
recurrenceLabel: $recurrenceLabel,
|
||||
endDate: $endDate,
|
||||
isAllDay: $isAllDay,
|
||||
reminderDate: $reminderDate
|
||||
reminderDate: $reminderDate,
|
||||
constantReminder: $constantReminder,
|
||||
recurrenceEnd: $recurrenceEnd,
|
||||
recurrenceInterval: $recurrenceInterval
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -849,7 +916,9 @@ struct TaskEditSheet: View {
|
||||
guard !t.isEmpty else { return }
|
||||
taskVM.updateTask(task, title: t, dueDate: dueDate, hasTime: hasTime,
|
||||
isRecurring: isRecurring, recurrenceLabel: recurrenceLabel,
|
||||
endDate: endDate, isAllDay: isAllDay, reminderDate: reminderDate)
|
||||
endDate: endDate, isAllDay: isAllDay, reminderDate: reminderDate,
|
||||
constantReminder: constantReminder, recurrenceEnd: recurrenceEnd,
|
||||
recurrenceInterval: recurrenceInterval)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,11 @@ struct OnboardingView: View {
|
||||
@AppStorage("heightCm") private var heightCm: Double = 0
|
||||
@AppStorage("weightUnit") private var weightUnit: 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
|
||||
@State private var localAppearance: Int = 2 // dark by default
|
||||
@@ -26,11 +31,10 @@ struct OnboardingView: View {
|
||||
@State private var weightStr = ""
|
||||
@State private var heightStr = ""
|
||||
@State private var selectedDays: Set<Int> = []
|
||||
@State private var calAuthStatus: EKAuthorizationStatus = EKEventStore.authorizationStatus(for: .event)
|
||||
@ObservedObject private var calStore = CalendarStore.shared
|
||||
@ObservedObject private var hk = HealthKitManager.shared
|
||||
@FocusState private var focusedField: Field?
|
||||
|
||||
private let ekStore = EKEventStore()
|
||||
|
||||
private let timeSlots: [(Int, String, String)] = [
|
||||
(0, "Morning", "sunrise.fill"),
|
||||
(1, "Midday", "sun.max.fill"),
|
||||
@@ -40,10 +44,7 @@ struct OnboardingView: View {
|
||||
fileprivate enum Field { case weight, height }
|
||||
private let weekdays: [(Int, String)] = [(2,"M"),(3,"T"),(4,"W"),(5,"T"),(6,"F"),(7,"S"),(1,"S")]
|
||||
|
||||
private var calIsGranted: Bool {
|
||||
if #available(iOS 17.0, *) { return calAuthStatus == .fullAccess }
|
||||
return calAuthStatus == .authorized
|
||||
}
|
||||
private var calIsGranted: Bool { calStore.isAuthorized }
|
||||
|
||||
private var liveScheme: ColorScheme? {
|
||||
switch localAppearance {
|
||||
@@ -72,7 +73,7 @@ struct OnboardingView: View {
|
||||
.foregroundColor(AppColors.accent)
|
||||
}
|
||||
VStack(spacing: 6) {
|
||||
Text("Welcome to KisaniCal")
|
||||
Text("Welcome to Wenza")
|
||||
.font(AppFonts.sans(26, weight: .bold))
|
||||
.foregroundColor(AppColors.text(cs))
|
||||
Text("Tasks, calendar, and workouts\n— all in one place.")
|
||||
@@ -117,7 +118,7 @@ struct OnboardingView: View {
|
||||
.font(.system(size: 22))
|
||||
.foregroundColor(AppColors.green)
|
||||
.transition(.scale.combined(with: .opacity))
|
||||
} else if calAuthStatus == .denied {
|
||||
} else if calStore.isDenied {
|
||||
Text("Denied")
|
||||
.font(AppFonts.mono(10, weight: .bold))
|
||||
.foregroundColor(AppColors.accent)
|
||||
@@ -136,7 +137,7 @@ struct OnboardingView: View {
|
||||
.buttonStyle(PressButtonStyle(scale: 0.92))
|
||||
}
|
||||
}
|
||||
.animation(KisaniSpring.micro, value: calAuthStatus.rawValue)
|
||||
.animation(KisaniSpring.micro, value: calStore.authStatus.rawValue)
|
||||
}
|
||||
.padding(.horizontal, 14).padding(.vertical, 14)
|
||||
.cardStyle()
|
||||
@@ -284,6 +285,50 @@ struct OnboardingView: View {
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 28)
|
||||
|
||||
// ══════════════════════════════
|
||||
// MARK: Health
|
||||
// ══════════════════════════════
|
||||
if hk.isAvailable {
|
||||
OnboardingSectionHeader(title: "Health")
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: hk.authorized ? "heart.fill" : "heart")
|
||||
.font(.system(size: 15))
|
||||
.foregroundColor(hk.authorized ? AppColors.green : AppColors.accent)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(hk.authorized ? AppColors.greenSoft : AppColors.accentSoft)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.animation(KisaniSpring.micro, value: hk.authorized)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Apple Health")
|
||||
.font(AppFonts.sans(13, weight: .semibold))
|
||||
.foregroundColor(AppColors.text(cs))
|
||||
Text(hk.authorized
|
||||
? "Steps, calories & workouts on your dashboard"
|
||||
: "Show your activity stats above today's tasks")
|
||||
.font(AppFonts.sans(11))
|
||||
.foregroundColor(AppColors.text3(cs))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Toggle("", isOn: Binding(
|
||||
get: { hk.authorized },
|
||||
set: { on in
|
||||
if on { Task { _ = await hk.requestAuthorization() } }
|
||||
else { hk.disconnect() }
|
||||
}
|
||||
))
|
||||
.labelsHidden()
|
||||
.tint(AppColors.accent)
|
||||
}
|
||||
.padding(.horizontal, 14).padding(.vertical, 14)
|
||||
.cardStyle()
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 28)
|
||||
}
|
||||
|
||||
// ══════════════════════════════
|
||||
// MARK: Workout
|
||||
// ══════════════════════════════
|
||||
@@ -467,6 +512,41 @@ struct OnboardingView: View {
|
||||
.padding(.horizontal, 20)
|
||||
.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.")
|
||||
.font(AppFonts.sans(11))
|
||||
.foregroundColor(AppColors.text3(cs))
|
||||
@@ -506,29 +586,17 @@ struct OnboardingView: View {
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
calAuthStatus = EKEventStore.authorizationStatus(for: .event)
|
||||
calStore.refreshStatus()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func requestCalendar() {
|
||||
if #available(iOS 17.0, *) {
|
||||
ekStore.requestFullAccessToEvents { granted, _ in
|
||||
Task { @MainActor in
|
||||
withAnimation(KisaniSpring.snappy) {
|
||||
self.calAuthStatus = granted ? .fullAccess : .denied
|
||||
}
|
||||
}
|
||||
}
|
||||
if calStore.isDenied, let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
} else {
|
||||
ekStore.requestAccess(to: .event) { granted, _ in
|
||||
Task { @MainActor in
|
||||
withAnimation(KisaniSpring.snappy) {
|
||||
self.calAuthStatus = granted ? .authorized : .denied
|
||||
}
|
||||
}
|
||||
}
|
||||
calStore.requestAccess()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -549,6 +617,37 @@ 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
|
||||
|
||||
private struct OnboardingSectionHeader: View {
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
import SwiftUI
|
||||
|
||||
extension Bundle {
|
||||
/// Public marketing version, e.g. "2.0".
|
||||
var marketingVersion: String { infoDictionary?["CFBundleShortVersionString"] as? String ?? "—" }
|
||||
/// Build number, e.g. "9".
|
||||
var buildNumber: String { infoDictionary?["CFBundleVersion"] as? String ?? "" }
|
||||
/// Combined display, e.g. "2.0 (9)" — single source of truth for any UI showing the version.
|
||||
var versionDisplay: String { buildNumber.isEmpty ? marketingVersion : "\(marketingVersion) (\(buildNumber))" }
|
||||
}
|
||||
|
||||
struct SettingsView: View {
|
||||
@Environment(\.colorScheme) var cs
|
||||
@EnvironmentObject var workoutVM: WorkoutViewModel
|
||||
@EnvironmentObject var taskVM: TaskViewModel
|
||||
@ObservedObject private var auth = AuthManager.shared
|
||||
@ObservedObject private var calStore = CalendarStore.shared
|
||||
@ObservedObject private var tm = TutorialManager.shared
|
||||
|
||||
// Persisted settings
|
||||
@AppStorage("appearanceMode") private var appearanceMode = 0
|
||||
@AppStorage("soundsOn") private var soundsOn = true
|
||||
@AppStorage("healthOn") private var healthOn = false
|
||||
@AppStorage("showMatrixTab") private var showMatrixTab = true
|
||||
@AppStorage("showWorkoutTab") private var showWorkoutTab = true
|
||||
@AppStorage("dateFormat") private var dateFormat = 0
|
||||
@@ -22,6 +32,11 @@ struct SettingsView: View {
|
||||
@AppStorage("googleCalSync") private var googleCalSync = false
|
||||
@AppStorage("iCloudCalSync") private var iCloudCalSync = true
|
||||
@AppStorage("streakGoal") private var streakGoal = 5
|
||||
// App-Group store so NotificationManager (which reads UserDefaults.kisani) sees it.
|
||||
@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
|
||||
@State private var showProfile = false
|
||||
@@ -31,8 +46,10 @@ struct SettingsView: View {
|
||||
@State private var showWidgets = false
|
||||
@State private var showGeneral = false
|
||||
@State private var showImport = false
|
||||
@State private var showCalendar = false
|
||||
@State private var showBackup = false
|
||||
@State private var showWorkoutSettings = false
|
||||
@State private var showHabitReminders = false
|
||||
@State private var showRestTimer = false
|
||||
@State private var showHelp = false
|
||||
@State private var showFollow = false
|
||||
@@ -42,6 +59,10 @@ struct SettingsView: View {
|
||||
private var restLabel: String { workoutVM.restTimerEnabled ? "\(workoutVM.restTimerSeconds)s" : "Off" }
|
||||
private var weightLabel: String { weightUnit == 0 ? "kg" : "lbs" }
|
||||
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 {
|
||||
ZStack {
|
||||
@@ -101,6 +122,11 @@ struct SettingsView: View {
|
||||
SttNavRow(icon: "menucard", bg: AppColors.accentSoft, fg: AppColors.accent, label: "Tab Bar", value: "\(showMatrixTab && showWorkoutTab ? "5" : showMatrixTab || showWorkoutTab ? "4" : "3") tabs") { showTabBar = true }
|
||||
SttNavRow(icon: "paintpalette", bg: AppColors.blueSoft, fg: AppColors.blue, label: "Appearance", value: appearanceLabel) { showAppearance = true }
|
||||
SttToggleRow(icon: "bell", bg: AppColors.accentSoft, fg: AppColors.accent, label: "Sounds & Notifications", isOn: $soundsOn)
|
||||
SttToggleRow(icon: "calendar.badge.clock", bg: AppColors.blueSoft, fg: AppColors.blue, label: "Time Milestones",
|
||||
isOn: Binding(
|
||||
get: { periodMilestones },
|
||||
set: { periodMilestones = $0
|
||||
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM) }))
|
||||
SttNavRow(icon: "clock", bg: AppColors.greenSoft, fg: AppColors.green, label: "Date & Time", value: dateLabel) { showDateTime = true }
|
||||
SttNavRow(icon: "puzzlepiece", bg: AppColors.yellowSoft, fg: AppColors.yellow, label: "Widgets") { showWidgets = true }
|
||||
SttNavRow(icon: "gearshape", bg: AppColors.surface3(cs), label: "General", isLast: true) { showGeneral = true }
|
||||
@@ -108,6 +134,7 @@ struct SettingsView: View {
|
||||
|
||||
// ── DATA ──
|
||||
SttSection(label: "Data") {
|
||||
SttNavRow(icon: "calendar", bg: AppColors.accentSoft, fg: AppColors.accent, label: "Calendar", value: calStore.isAuthorized ? "● Connected" : (calStore.isDenied ? "Denied" : "Connect"), valueColor: calStore.isAuthorized ? AppColors.green : nil) { showCalendar = true }
|
||||
SttNavRow(icon: "arrow.up.right", bg: AppColors.greenSoft, fg: AppColors.green, label: "Connected Sources", value: googleCalSync || iCloudCalSync ? "Connected" : nil) { showImport = true }
|
||||
SttNavRow(icon: "cloud", bg: AppColors.blueSoft, fg: AppColors.blue, label: "Library Sync", value: iCloudSync ? "● Synced" : "Off", valueColor: iCloudSync ? AppColors.green : nil, isLast: true) { showBackup = true }
|
||||
}
|
||||
@@ -116,7 +143,14 @@ struct SettingsView: View {
|
||||
SttSection(label: "Fitness") {
|
||||
SttNavRow(icon: "dumbbell", bg: AppColors.accentSoft, fg: AppColors.accent, label: "Workout Settings", value: weightLabel) { showWorkoutSettings = true }
|
||||
SttNavRow(icon: "figure.run", bg: AppColors.accentSoft, fg: AppColors.accent, label: "Rest Timer", value: restLabel) { showRestTimer = true }
|
||||
HealthToggleRow(isOn: $healthOn, isLast: true)
|
||||
HealthToggleRow(isLast: true)
|
||||
}
|
||||
|
||||
// ── 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 ──
|
||||
@@ -149,13 +183,28 @@ struct SettingsView: View {
|
||||
// ── SUPPORT ──
|
||||
SttSection(label: "Support", secondary: true) {
|
||||
SttNavRow(icon: "star", bg: AppColors.yellowSoft, fg: AppColors.yellow, label: "Help & Feedback") { showHelp = true }
|
||||
SttNavRow(icon: "message", bg: AppColors.blueSoft, fg: AppColors.blue, label: "Follow Us", value: "𝕏 👾 📸") { showFollow = true }
|
||||
SttNavRow(icon: "info.circle", bg: AppColors.surface3(cs), label: "About", value: "v1.0.0", isLast: true) { showAbout = true }
|
||||
SttNavRow(icon: "message", bg: AppColors.blueSoft, fg: AppColors.blue, label: "Follow Us") { showFollow = true }
|
||||
SttNavRow(icon: "info.circle", bg: AppColors.surface3(cs), label: "About", value: "v\(Bundle.main.marketingVersion)", isLast: true) { showAbout = true }
|
||||
}
|
||||
|
||||
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: $showTabBar) { TabBarSheet(showMatrix: $showMatrixTab, showWorkout: $showWorkoutTab).presentationDetents([.height(320)]).presentationDragIndicator(.visible) }
|
||||
@@ -164,19 +213,21 @@ struct SettingsView: View {
|
||||
.sheet(isPresented: $showWidgets) { WidgetsSheet().presentationDetents([.height(320)]).presentationDragIndicator(.visible) }
|
||||
.sheet(isPresented: $showGeneral) { GeneralSheet(showCompleted: $showCompleted, mondayFirst: $firstDayMonday).presentationDetents([.height(260)]).presentationDragIndicator(.visible) }
|
||||
.sheet(isPresented: $showImport) { ImportSheet(googleCal: $googleCalSync, iCloudCal: $iCloudCalSync).presentationDetents([.height(280)]).presentationDragIndicator(.visible) }
|
||||
.sheet(isPresented: $showCalendar) { CalendarConnectSheet(calStore: calStore).presentationDetents([.large]).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: $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: $showHelp) { HelpSheet().presentationDetents([.height(300)]).presentationDragIndicator(.visible) }
|
||||
.sheet(isPresented: $showFollow) { FollowSheet().presentationDetents([.height(280)]).presentationDragIndicator(.visible) }
|
||||
.sheet(isPresented: $showAbout) { AboutSheet().presentationDetents([.height(340)]).presentationDragIndicator(.visible) }
|
||||
.onAppear { calStore.refreshStatus() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Health Toggle Row (requests auth when turned on)
|
||||
private struct HealthToggleRow: View {
|
||||
@Environment(\.colorScheme) private var cs
|
||||
@Binding var isOn: Bool
|
||||
var isLast: Bool = false
|
||||
@ObservedObject private var hk = HealthKitManager.shared
|
||||
|
||||
@@ -193,25 +244,26 @@ private struct HealthToggleRow: View {
|
||||
Text("Health Integration")
|
||||
.font(AppFonts.sans(13))
|
||||
.foregroundColor(AppColors.text(cs))
|
||||
if isOn && hk.authorized {
|
||||
if hk.authorized {
|
||||
Text("Connected")
|
||||
.font(AppFonts.mono(9))
|
||||
.foregroundColor(AppColors.green)
|
||||
} else if isOn && !hk.isAvailable {
|
||||
} else if !hk.isAvailable {
|
||||
Text("Not available on this device")
|
||||
.font(AppFonts.mono(9))
|
||||
.foregroundColor(AppColors.text3(cs))
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Toggle("", isOn: $isOn)
|
||||
.labelsHidden()
|
||||
.tint(AppColors.green)
|
||||
.onChange(of: isOn) { enabled in
|
||||
if enabled {
|
||||
Task { await HealthKitManager.shared.requestAuthorization() }
|
||||
}
|
||||
Toggle("", isOn: Binding(
|
||||
get: { hk.authorized },
|
||||
set: { on in
|
||||
if on { Task { _ = await hk.requestAuthorization() } }
|
||||
else { hk.disconnect() }
|
||||
}
|
||||
))
|
||||
.labelsHidden()
|
||||
.tint(AppColors.green)
|
||||
}
|
||||
.padding(.horizontal, 14).padding(.vertical, 11)
|
||||
if !isLast { Divider().background(AppColors.border(cs)).padding(.leading, 53) }
|
||||
@@ -452,7 +504,7 @@ private struct WidgetsSheet: View {
|
||||
}
|
||||
.padding(12).cardStyle()
|
||||
}
|
||||
Text("Long-press your home screen → tap + → search Kisani Cal to add widgets.")
|
||||
Text("Long-press your home screen → tap + → search Wenza to add widgets.")
|
||||
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
|
||||
.multilineTextAlignment(.center).padding(.top, 4)
|
||||
}
|
||||
@@ -523,7 +575,7 @@ private struct ImportSheet: View {
|
||||
.padding(.horizontal, 14).padding(.vertical, 12)
|
||||
}
|
||||
.cardStyle().padding(.horizontal, 20)
|
||||
Text("Enabling integrations will sync events to your Kisani calendar.")
|
||||
Text("Enabling integrations will sync events to your Wenza calendar.")
|
||||
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
|
||||
.multilineTextAlignment(.center).padding(.horizontal, 20).padding(.top, 12)
|
||||
Spacer()
|
||||
@@ -761,7 +813,7 @@ private struct WorkoutSettingsSheet: View {
|
||||
}.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
Text("Reach your goal every week to maintain your streak.")
|
||||
Text("Your weekly workout target. Every workout you log adds to your total — a missed day never takes it away.")
|
||||
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
|
||||
}
|
||||
|
||||
@@ -860,6 +912,261 @@ 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 {
|
||||
@Environment(\.colorScheme) private var cs
|
||||
let label: String; @Binding var value: Int; let range: ClosedRange<Int>; var isLast: Bool = false
|
||||
@@ -953,10 +1260,10 @@ private struct HelpSheet: View {
|
||||
SheetHeader(title: "Help & Feedback") { dismiss() }
|
||||
VStack(spacing: 10) {
|
||||
HelpRow(icon: "envelope", label: "Send Feedback", sub: "Report a bug or suggest a feature") {
|
||||
if let url = URL(string: "mailto:feedback@kisanicaI.app?subject=Kisani%20Cal%20Feedback") { openURL(url) }
|
||||
if let url = URL(string: "mailto:feedback@kisanicaI.app?subject=Wenza%20Feedback") { openURL(url) }
|
||||
}
|
||||
HelpRow(icon: "questionmark.circle", label: "FAQ", sub: "Frequently asked questions") {}
|
||||
HelpRow(icon: "book", label: "User Guide", sub: "Learn how to use Kisani Cal") {}
|
||||
HelpRow(icon: "book", label: "User Guide", sub: "Learn how to use Wenza") {}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
Spacer()
|
||||
@@ -1031,9 +1338,9 @@ private struct ProfileStatsSheet: View {
|
||||
@EnvironmentObject var taskVM: TaskViewModel
|
||||
@EnvironmentObject var workoutVM: WorkoutViewModel
|
||||
@AppStorage("streakGoal") private var streakGoal = 5
|
||||
@AppStorage("healthOn") private var healthOn = false
|
||||
@ObservedObject private var hk = HealthKitManager.shared
|
||||
@ObservedObject private var auth = AuthManager.shared
|
||||
@State private var showActivityHistory = false
|
||||
|
||||
// ── Task metrics ──
|
||||
private var totalTasks: Int { taskVM.tasks.count }
|
||||
@@ -1044,24 +1351,19 @@ private struct ProfileStatsSheet: View {
|
||||
return Int(Double(doneTasks) / Double(totalTasks) * 100)
|
||||
}
|
||||
private var completedThisWeek: Int {
|
||||
let start = Calendar.current.date(byAdding: .day, value: -6, to: Calendar.current.startOfDay(for: Date()))!
|
||||
return taskVM.tasks.filter { $0.isComplete && ($0.completedAt ?? .distantPast) >= start }.count
|
||||
// Sum of per-day counts — includes recurring occurrences.
|
||||
last7.reduce(0) { $0 + $1.1 }
|
||||
}
|
||||
private var urgentOpen: Int {
|
||||
taskVM.tasks.filter { !$0.isComplete && $0.quadrant == .urgent }.count
|
||||
taskVM.tasks.filter { !$0.isComplete && taskVM.displayQuadrant($0) == .urgent }.count
|
||||
}
|
||||
|
||||
// ── 7-day task activity ──
|
||||
// ── 7-day task activity (recurring occurrences included) ──
|
||||
private var last7: [(Date, Int)] {
|
||||
let cal = Calendar.current
|
||||
return (0..<7).reversed().map { ago -> (Date, Int) in
|
||||
let day = cal.date(byAdding: .day, value: -ago, to: cal.startOfDay(for: Date()))!
|
||||
let next = cal.date(byAdding: .day, value: 1, to: day)!
|
||||
let n = taskVM.tasks.filter { t in
|
||||
guard t.isComplete, let at = t.completedAt else { return false }
|
||||
return at >= day && at < next
|
||||
}.count
|
||||
return (day, n)
|
||||
return (day, taskVM.completionCount(on: day))
|
||||
}
|
||||
}
|
||||
private var maxDay: Int { max(1, last7.map { $0.1 }.max() ?? 1) }
|
||||
@@ -1102,9 +1404,19 @@ private struct ProfileStatsSheet: View {
|
||||
|
||||
// ── Tasks card ──
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("TASKS").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
|
||||
HStack {
|
||||
Text("TASKS").font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.text3(cs))
|
||||
Spacer()
|
||||
Button { showActivityHistory = true } label: {
|
||||
Text("History")
|
||||
.font(AppFonts.mono(9, weight: .bold))
|
||||
.foregroundColor(AppColors.text2(cs))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
PStatCell(value: "\(taskVM.taskStreakDays)", label: "day streak", color: taskVM.taskStreakDays > 0 ? AppColors.green : AppColors.text3(cs))
|
||||
PStatCell(value: "\(completedThisWeek)", label: "this week", color: AppColors.green)
|
||||
PStatCell(value: "\(overdueCount)", label: "overdue", color: overdueCount > 0 ? AppColors.accent : AppColors.text3(cs))
|
||||
PStatCell(value: "\(completionRate)%", label: "done rate", color: AppColors.blue)
|
||||
@@ -1137,10 +1449,10 @@ private struct ProfileStatsSheet: View {
|
||||
|
||||
// Quadrant breakdown
|
||||
HStack(spacing: 8) {
|
||||
QuadStat(label: "Urgent", count: taskVM.tasks.filter { $0.quadrant == .urgent && !$0.isComplete }.count, color: AppColors.accent)
|
||||
QuadStat(label: "Schedule", count: taskVM.tasks.filter { $0.quadrant == .schedule && !$0.isComplete }.count, color: AppColors.yellow)
|
||||
QuadStat(label: "Delegate", count: taskVM.tasks.filter { $0.quadrant == .delegate_ && !$0.isComplete }.count, color: AppColors.blue)
|
||||
QuadStat(label: "Eliminate",count: taskVM.tasks.filter { $0.quadrant == .eliminate && !$0.isComplete }.count, color: AppColors.green)
|
||||
QuadStat(label: "Urgent", count: taskVM.tasks.filter { !$0.isComplete && taskVM.displayQuadrant($0) == .urgent }.count, color: AppColors.accent)
|
||||
QuadStat(label: "Schedule", count: taskVM.tasks.filter { !$0.isComplete && taskVM.displayQuadrant($0) == .schedule }.count, color: AppColors.yellow)
|
||||
QuadStat(label: "Delegate", count: taskVM.tasks.filter { !$0.isComplete && taskVM.displayQuadrant($0) == .delegate_ }.count, color: AppColors.blue)
|
||||
QuadStat(label: "Eliminate",count: taskVM.tasks.filter { !$0.isComplete && taskVM.displayQuadrant($0) == .eliminate }.count, color: AppColors.green)
|
||||
}
|
||||
}
|
||||
.padding(14).background(AppColors.surface(cs))
|
||||
@@ -1225,7 +1537,7 @@ private struct ProfileStatsSheet: View {
|
||||
.overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.border(cs), lineWidth: 1))
|
||||
|
||||
// ── Health card ──
|
||||
if healthOn && hk.authorized {
|
||||
if hk.authorized {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 6) {
|
||||
Text("HEALTH")
|
||||
@@ -1277,6 +1589,11 @@ private struct ProfileStatsSheet: View {
|
||||
}
|
||||
}
|
||||
.background(AppColors.background(cs).ignoresSafeArea())
|
||||
.sheet(isPresented: $showActivityHistory) {
|
||||
ActivityHistoryView()
|
||||
.environmentObject(taskVM)
|
||||
.environmentObject(workoutVM)
|
||||
}
|
||||
}
|
||||
|
||||
private func dayLetter(_ date: Date) -> String {
|
||||
@@ -1340,8 +1657,8 @@ private struct AboutSheet: View {
|
||||
Image(systemName: "calendar.badge.clock")
|
||||
.font(.system(size: 48, weight: .light)).foregroundColor(AppColors.accent)
|
||||
VStack(spacing: 4) {
|
||||
Text("Kisani Cal").font(AppFonts.sans(20, weight: .bold)).foregroundColor(AppColors.text(cs))
|
||||
Text("Version 1.0.0").font(AppFonts.mono(11)).foregroundColor(AppColors.text3(cs))
|
||||
Text("Wenza").font(AppFonts.sans(20, weight: .bold)).foregroundColor(AppColors.text(cs))
|
||||
Text("Version \(Bundle.main.versionDisplay)").font(AppFonts.mono(11)).foregroundColor(AppColors.text3(cs))
|
||||
}
|
||||
Text("A calm, focused productivity app for tasks, calendar, and fitness.")
|
||||
.font(AppFonts.sans(13)).foregroundColor(AppColors.text2(cs))
|
||||
|
||||
@@ -20,14 +20,11 @@ struct SplashView: View {
|
||||
.scaleEffect(iconScale)
|
||||
.opacity(iconOpacity)
|
||||
|
||||
// kisaniCAL. — single line, orange dot
|
||||
// wenza. — single line, orange dot
|
||||
HStack(spacing: 0) {
|
||||
Text("kisani")
|
||||
Text("wenza")
|
||||
.font(AppFonts.mono(22, weight: .regular))
|
||||
.foregroundColor(AppColors.text(cs))
|
||||
Text("CAL")
|
||||
.font(AppFonts.mono(22, weight: .bold))
|
||||
.foregroundColor(AppColors.text(cs))
|
||||
Text(".")
|
||||
.font(AppFonts.mono(22, weight: .bold))
|
||||
.foregroundColor(AppColors.accent)
|
||||
|
||||
182
KisaniCal/Views/TaskContextMenu.swift
Normal file
@@ -0,0 +1,182 @@
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
/// Tracks one event for the Event Countdown (Bars) widget via App Group storage.
|
||||
/// The widget reads these keys directly — no manual widget configuration needed.
|
||||
enum CountdownTracker {
|
||||
static let idKey = "kisani.widget.trackedEvent"
|
||||
static let sinceKey = "kisani.widget.trackedSince"
|
||||
|
||||
static func isTracked(_ task: TaskItem) -> Bool {
|
||||
UserDefaults.kisani.string(forKey: idKey) == task.id.uuidString
|
||||
}
|
||||
|
||||
static func toggle(_ task: TaskItem) {
|
||||
if isTracked(task) {
|
||||
UserDefaults.kisani.removeObject(forKey: idKey)
|
||||
UserDefaults.kisani.removeObject(forKey: sinceKey)
|
||||
} else {
|
||||
UserDefaults.kisani.set(task.id.uuidString, forKey: idKey)
|
||||
UserDefaults.kisani.set(Date(), forKey: sinceKey) // Mode A anchor
|
||||
}
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
}
|
||||
|
||||
/// The unified long-press menu for a task, shared across Today, Matrix, and Calendar.
|
||||
/// Pass only the closures a given screen needs; the rest default to no-ops.
|
||||
struct TaskMenuItems: View {
|
||||
let task: TaskItem
|
||||
/// The quadrant the row currently lives in (so "Move" hides the current one).
|
||||
var currentQuadrant: Quadrant? = nil
|
||||
|
||||
var onPin: (TaskItem) -> Void = { _ in }
|
||||
var onSetDate: (TaskItem, Date?) -> Void = { _, _ in }
|
||||
var onPostponeMinutes: (TaskItem, Int) -> Void = { _, _ in }
|
||||
var onMove: (TaskItem, Quadrant) -> Void = { _, _ in }
|
||||
var onSetPriority: (TaskItem, Priority) -> Void = { _, _ in }
|
||||
var onSetCategory: (TaskItem, TaskCategory) -> Void = { _, _ in }
|
||||
var onResetMatrixUrgency: ((TaskItem) -> Void)? = nil
|
||||
var onLiveActivity:(TaskItem) -> Void = { LiveActivityManager.toggle(for: $0) }
|
||||
var onEdit: ((TaskItem) -> Void)? = nil
|
||||
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 }
|
||||
|
||||
var body: some View {
|
||||
Button { onPin(task) } label: {
|
||||
Label(task.isPinned ? "Unpin" : "Pin", systemImage: task.isPinned ? "pin.slash" : "pin")
|
||||
}
|
||||
|
||||
Menu {
|
||||
Button { onSetDate(task, preservingTime(on: Date())) } label: {
|
||||
Label("Today", systemImage: "calendar")
|
||||
}
|
||||
Button { onSetDate(task, preservingTime(on: dayOffset(1))) } label: {
|
||||
Label("Tomorrow", systemImage: "sun.max")
|
||||
}
|
||||
Button { onSetDate(task, preservingTime(on: nextMonday())) } label: {
|
||||
Label("Next Monday", systemImage: "calendar.badge.clock")
|
||||
}
|
||||
Divider()
|
||||
if let onEdit {
|
||||
Button { onEdit(task) } label: {
|
||||
Label("Pick Date", systemImage: "calendar.badge.plus")
|
||||
}
|
||||
}
|
||||
Button { onSetDate(task, nil) } label: {
|
||||
Label("Clear", systemImage: "xmark.square")
|
||||
}
|
||||
} label: { Label("Date", systemImage: "calendar.badge.clock") }
|
||||
|
||||
Menu {
|
||||
Button { onPostponeMinutes(task, 15) } label: {
|
||||
Label("Snooze 15 Minutes", systemImage: "clock")
|
||||
}
|
||||
Button { onPostponeMinutes(task, 30) } label: {
|
||||
Label("Snooze 30 Minutes", systemImage: "clock")
|
||||
}
|
||||
Button { onPostponeMinutes(task, 60) } label: {
|
||||
Label("Snooze 1 Hour", systemImage: "clock")
|
||||
}
|
||||
Button { onPostponeMinutes(task, 120) } label: {
|
||||
Label("Snooze 2 Hours", systemImage: "clock")
|
||||
}
|
||||
Divider()
|
||||
Button { onSetDate(task, preservingTime(on: dayOffset(1))) } label: {
|
||||
Label("Tomorrow", systemImage: "sun.max")
|
||||
}
|
||||
} label: { Label("Postpone", systemImage: "clock.arrow.circlepath") }
|
||||
|
||||
Menu {
|
||||
// Only the quadrants the task is NOT currently in — you can't move it to where it sits.
|
||||
ForEach(Quadrant.allCases.filter { $0 != (currentQuadrant ?? task.quadrant) }) { q in
|
||||
Button { onMove(task, q) } label: {
|
||||
Label(q.matrixLabel, systemImage: "arrow.right")
|
||||
}
|
||||
}
|
||||
} label: { Label("Move", systemImage: "arrow.right.square") }
|
||||
|
||||
if task.urgencyOverride != nil, let onResetMatrixUrgency {
|
||||
Button { onResetMatrixUrgency(task) } label: {
|
||||
Label("Reset Matrix Urgency", systemImage: "arrow.counterclockwise")
|
||||
}
|
||||
}
|
||||
|
||||
Menu {
|
||||
ForEach(Priority.allCases) { p in
|
||||
Button { onSetPriority(task, p) } label: {
|
||||
Label(p.label, systemImage: task.priority == p ? "checkmark" : "flag")
|
||||
}
|
||||
}
|
||||
} label: { Label("Priority", systemImage: "flag") }
|
||||
|
||||
Menu {
|
||||
ForEach([TaskCategory.reminder, .birthday, .domain, .annual, .custom], id: \.rawValue) { cat in
|
||||
Button { onSetCategory(task, cat) } label: {
|
||||
Label(cat.rawValue, systemImage: task.category == cat ? "checkmark" : "tag")
|
||||
}
|
||||
}
|
||||
} label: { Label("Tags", systemImage: "tag") }
|
||||
|
||||
Button { onLiveActivity(task) } label: {
|
||||
Label(liveActivityTitle, systemImage: liveActivityIcon)
|
||||
}
|
||||
|
||||
Button { CountdownTracker.toggle(task) } label: {
|
||||
Label(CountdownTracker.isTracked(task) ? "Stop Tracking Countdown" : "Track Countdown",
|
||||
systemImage: "timer")
|
||||
}
|
||||
|
||||
if let onEdit {
|
||||
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()
|
||||
|
||||
Button(role: .destructive) { onDelete(task) } label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
|
||||
private var liveActivityTitle: String {
|
||||
if #available(iOS 16.1, *), LiveActivityManager.isActive(taskID: task.id.uuidString) {
|
||||
return "Remove from Live Activity"
|
||||
}
|
||||
return "Add to Live Activity"
|
||||
}
|
||||
|
||||
private var liveActivityIcon: String {
|
||||
if #available(iOS 16.1, *), LiveActivityManager.isActive(taskID: task.id.uuidString) {
|
||||
return "pin.slash"
|
||||
}
|
||||
return "pin.circle"
|
||||
}
|
||||
|
||||
private func dayOffset(_ value: Int) -> Date {
|
||||
cal.date(byAdding: .day, value: value, to: Date()) ?? Date()
|
||||
}
|
||||
|
||||
private func nextMonday() -> Date {
|
||||
var comps = DateComponents()
|
||||
comps.weekday = 2
|
||||
return cal.nextDate(after: Date(), matching: comps, matchingPolicy: .nextTimePreservingSmallerComponents)
|
||||
?? dayOffset(7)
|
||||
}
|
||||
|
||||
private func preservingTime(on day: Date) -> Date {
|
||||
let base = cal.startOfDay(for: day)
|
||||
guard task.hasTime, let due = task.dueDate else { return base }
|
||||
let parts = cal.dateComponents([.hour, .minute], from: due)
|
||||
return cal.date(bySettingHour: parts.hour ?? 0, minute: parts.minute ?? 0, second: 0, of: base) ?? base
|
||||
}
|
||||
}
|
||||
323
KisaniCal/Views/TodayMenuFeatures.swift
Normal file
@@ -0,0 +1,323 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Today background themes
|
||||
|
||||
enum TodayBackground {
|
||||
static let presets: [(id: String, label: String)] = [
|
||||
("default", "Default"),
|
||||
("warm", "Warm"),
|
||||
("cool", "Cool"),
|
||||
("mono", "Mono"),
|
||||
("paper", "Paper"),
|
||||
]
|
||||
|
||||
/// The full-screen background for a given style. "default" is a flat surface;
|
||||
/// the rest are subtle top-down tints that fade into the base background.
|
||||
static func gradient(_ id: String, _ cs: ColorScheme) -> LinearGradient {
|
||||
let base = AppColors.background(cs)
|
||||
let top: Color
|
||||
switch id {
|
||||
case "warm": top = AppColors.accentSoft
|
||||
case "cool": top = AppColors.blueSoft
|
||||
case "mono": top = Color(.systemGray5)
|
||||
case "paper": top = Color(red: 0.96, green: 0.94, blue: 0.89)
|
||||
default: top = base
|
||||
}
|
||||
return LinearGradient(colors: [top, base], startPoint: .top, endPoint: .bottom)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Group & Sort model
|
||||
|
||||
enum TaskGrouping: String, CaseIterable, Identifiable {
|
||||
case date, priority, category, quadrant
|
||||
var id: String { rawValue }
|
||||
var label: String {
|
||||
switch self {
|
||||
case .date: return "Date"
|
||||
case .priority: return "Priority"
|
||||
case .category: return "Category"
|
||||
case .quadrant: return "Quadrant"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum TaskSort: String, CaseIterable, Identifiable {
|
||||
case smart, due, priority, title
|
||||
var id: String { rawValue }
|
||||
var label: String {
|
||||
switch self {
|
||||
case .smart: return "Smart (pinned, then date)"
|
||||
case .due: return "Due date"
|
||||
case .priority: return "Priority"
|
||||
case .title: return "Title"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum TaskOrganizer {
|
||||
private static func priorityRank(_ p: Priority) -> Int {
|
||||
switch p { case .high: return 0; case .medium: return 1; case .low: return 2; case .none: return 3 }
|
||||
}
|
||||
|
||||
static func sorted(_ tasks: [TaskItem], by sort: String) -> [TaskItem] {
|
||||
switch sort {
|
||||
case "due":
|
||||
return tasks.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
|
||||
case "priority":
|
||||
return tasks.sorted { priorityRank($0.priority) < priorityRank($1.priority) }
|
||||
case "title":
|
||||
return tasks.sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending }
|
||||
default: // smart
|
||||
return tasks.sorted {
|
||||
if $0.isPinned != $1.isPinned { return $0.isPinned }
|
||||
return ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Non-date groupings → titled sections, each internally sorted. ("date" is handled
|
||||
/// by the existing day sections, so it isn't produced here.)
|
||||
@MainActor
|
||||
static func sections(_ tasks: [TaskItem], grouping: String, sort: String,
|
||||
vm: TaskViewModel) -> [(title: String, tasks: [TaskItem])] {
|
||||
let s = sorted(tasks, by: sort)
|
||||
switch grouping {
|
||||
case "priority":
|
||||
return [Priority.high, .medium, .low, .none].compactMap { p in
|
||||
let g = s.filter { $0.priority == p }
|
||||
return g.isEmpty ? nil : (p.label, g)
|
||||
}
|
||||
case "category":
|
||||
return [TaskCategory.birthday, .domain, .annual, .reminder, .custom].compactMap { c in
|
||||
let g = s.filter { $0.category == c }
|
||||
return g.isEmpty ? nil : (c.rawValue, g)
|
||||
}
|
||||
case "quadrant":
|
||||
return [Quadrant.urgent, .schedule, .delegate_, .eliminate].compactMap { q in
|
||||
let g = s.filter { vm.displayQuadrant($0) == q }
|
||||
return g.isEmpty ? nil : (q.matrixLabel, g)
|
||||
}
|
||||
default:
|
||||
return [("Tasks", s)]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Background picker sheet
|
||||
|
||||
struct BackgroundPickerSheet: View {
|
||||
@Binding var selected: String
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.colorScheme) private var cs
|
||||
|
||||
private let columns = [GridItem(.adaptive(minimum: 96), spacing: 14)]
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: columns, spacing: 14) {
|
||||
ForEach(TodayBackground.presets, id: \.id) { preset in
|
||||
Button {
|
||||
withAnimation(KisaniSpring.snappy) { selected = preset.id }
|
||||
} label: {
|
||||
VStack(spacing: 8) {
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.fill(TodayBackground.gradient(preset.id, cs))
|
||||
.frame(height: 80)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14)
|
||||
.stroke(selected == preset.id ? AppColors.accent : AppColors.border(cs),
|
||||
lineWidth: selected == preset.id ? 2.5 : 1)
|
||||
)
|
||||
.overlay(alignment: .topTrailing) {
|
||||
if selected == preset.id {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(AppColors.accent)
|
||||
.padding(6)
|
||||
}
|
||||
}
|
||||
Text(preset.label)
|
||||
.font(AppFonts.sans(12, weight: .medium))
|
||||
.foregroundColor(AppColors.text2(cs))
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(18)
|
||||
}
|
||||
.background(AppColors.background(cs).ignoresSafeArea())
|
||||
.navigationTitle("Background")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Group & Sort sheet
|
||||
|
||||
struct GroupSortSheet: View {
|
||||
@Binding var grouping: String
|
||||
@Binding var sort: String
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section("Group by") {
|
||||
ForEach(TaskGrouping.allCases) { g in
|
||||
Button {
|
||||
withAnimation(KisaniSpring.micro) { grouping = g.rawValue }
|
||||
} label: {
|
||||
HStack {
|
||||
Text(g.label).foregroundColor(.primary)
|
||||
Spacer()
|
||||
if grouping == g.rawValue {
|
||||
Image(systemName: "checkmark").foregroundColor(AppColors.accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Section("Sort by") {
|
||||
ForEach(TaskSort.allCases) { s in
|
||||
Button {
|
||||
withAnimation(KisaniSpring.micro) { sort = s.rawValue }
|
||||
} label: {
|
||||
HStack {
|
||||
Text(s.label).foregroundColor(.primary)
|
||||
Spacer()
|
||||
if sort == s.rawValue {
|
||||
Image(systemName: "checkmark").foregroundColor(AppColors.accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Group & Sort")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Multi-select sheet
|
||||
|
||||
struct SelectableTaskListSheet: View {
|
||||
@EnvironmentObject var taskVM: TaskViewModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.colorScheme) private var cs
|
||||
@State private var selection: Set<UUID> = []
|
||||
|
||||
private let fmt: DateFormatter = {
|
||||
let f = DateFormatter(); f.dateFormat = "d MMM"; return f
|
||||
}()
|
||||
|
||||
private var rows: [TaskItem] { taskVM.allActiveRows }
|
||||
private var selectedTasks: [TaskItem] { rows.filter { selection.contains($0.id) } }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
ForEach(rows) { task in
|
||||
Button { toggle(task.id) } label: {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: selection.contains(task.id) ? "checkmark.circle.fill" : "circle")
|
||||
.font(.system(size: 18))
|
||||
.foregroundColor(selection.contains(task.id) ? AppColors.accent : AppColors.text3(cs))
|
||||
Text(task.title)
|
||||
.font(AppFonts.sans(14))
|
||||
.foregroundColor(AppColors.text(cs))
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
if let d = task.dueDate {
|
||||
Text(fmt.string(from: d))
|
||||
.font(AppFonts.mono(10))
|
||||
.foregroundColor(AppColors.text3(cs))
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.navigationTitle(selection.isEmpty ? "Select" : "\(selection.count) Selected")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(selection.count == rows.count && !rows.isEmpty ? "Deselect All" : "Select All") { toggleAll() }
|
||||
.disabled(rows.isEmpty)
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
if !selection.isEmpty { bulkBar }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var bulkBar: some View {
|
||||
HStack(spacing: 18) {
|
||||
bulkButton("Complete", "checkmark.circle") {
|
||||
selectedTasks.forEach { taskVM.toggle($0) }
|
||||
selection.removeAll()
|
||||
}
|
||||
Menu {
|
||||
ForEach(Priority.allCases) { p in
|
||||
Button(p.label) { selectedTasks.forEach { taskVM.setPriority($0, priority: p) } }
|
||||
}
|
||||
} label: { bulkLabel("Priority", "flag") }
|
||||
|
||||
Menu {
|
||||
ForEach(Quadrant.allCases) { q in
|
||||
Button(q.matrixLabel) { selectedTasks.forEach { taskVM.moveToQuadrant(taskID: $0.id, quadrant: q) } }
|
||||
}
|
||||
} label: { bulkLabel("Move", "arrow.right.square") }
|
||||
|
||||
bulkButton("Delete", "trash", role: .destructive) {
|
||||
selectedTasks.forEach { taskVM.delete($0) }
|
||||
selection.removeAll()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 12)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(.ultraThinMaterial)
|
||||
.overlay(Rectangle().frame(height: 1).foregroundColor(AppColors.border(cs)), alignment: .top)
|
||||
}
|
||||
|
||||
private func bulkButton(_ title: String, _ icon: String,
|
||||
role: ButtonRole? = nil, action: @escaping () -> Void) -> some View {
|
||||
Button(role: role, action: action) { bulkLabel(title, icon, destructive: role == .destructive) }
|
||||
}
|
||||
|
||||
private func bulkLabel(_ title: String, _ icon: String, destructive: Bool = false) -> some View {
|
||||
VStack(spacing: 4) {
|
||||
Image(systemName: icon).font(.system(size: 18))
|
||||
Text(title).font(AppFonts.sans(10, weight: .medium))
|
||||
}
|
||||
.foregroundColor(destructive ? .red : AppColors.accent)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
private func toggle(_ id: UUID) {
|
||||
if selection.contains(id) { selection.remove(id) } else { selection.insert(id) }
|
||||
}
|
||||
|
||||
private func toggleAll() {
|
||||
if selection.count == rows.count { selection.removeAll() }
|
||||
else { selection = Set(rows.map { $0.id }) }
|
||||
}
|
||||
}
|
||||
@@ -186,6 +186,7 @@ struct InViewTutorialCard: View {
|
||||
@Binding var step: Int
|
||||
let onAdvance: () -> Void
|
||||
let onSkip: () -> Void
|
||||
@Environment(\.colorScheme) private var cs
|
||||
|
||||
private var current: PageTip { tips[min(step, tips.count - 1)] }
|
||||
private var isLast: Bool { step == tips.count - 1 }
|
||||
@@ -203,17 +204,17 @@ struct InViewTutorialCard: View {
|
||||
}
|
||||
Text(current.title)
|
||||
.font(AppFonts.sans(15, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.foregroundColor(AppColors.text(cs))
|
||||
Spacer()
|
||||
Button("Skip") { onSkip() }
|
||||
.font(AppFonts.sans(11))
|
||||
.foregroundColor(.white.opacity(0.45))
|
||||
.foregroundColor(AppColors.text3(cs))
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
Text(current.body)
|
||||
.font(AppFonts.sans(13))
|
||||
.foregroundColor(.white.opacity(0.80))
|
||||
.foregroundColor(AppColors.text2(cs))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.lineSpacing(2)
|
||||
|
||||
@@ -221,7 +222,7 @@ struct InViewTutorialCard: View {
|
||||
HStack(spacing: 5) {
|
||||
ForEach(0..<tips.count, id: \.self) { i in
|
||||
Capsule()
|
||||
.fill(i == step ? AppColors.accent : Color.white.opacity(0.20))
|
||||
.fill(i == step ? AppColors.accent : AppColors.text3(cs).opacity(0.22))
|
||||
.frame(width: i == step ? 18 : 6, height: 6)
|
||||
.animation(KisaniSpring.micro, value: step)
|
||||
}
|
||||
@@ -243,8 +244,9 @@ struct InViewTutorialCard: View {
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 20))
|
||||
.overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.white.opacity(0.10), lineWidth: 1))
|
||||
.background(AppColors.surface(cs), in: RoundedRectangle(cornerRadius: 20))
|
||||
.overlay(RoundedRectangle(cornerRadius: 20).stroke(AppColors.border(cs), lineWidth: 1))
|
||||
.shadow(color: Color.black.opacity(cs == .dark ? 0.28 : 0.08), radius: 18, x: 0, y: 10)
|
||||
.padding(.horizontal, 20)
|
||||
.id(step)
|
||||
.transition(.asymmetric(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import SwiftUI
|
||||
import AudioToolbox
|
||||
import Charts
|
||||
|
||||
// MARK: - Rest Timer Banner
|
||||
private struct RestTimerBanner: View {
|
||||
@@ -61,7 +62,12 @@ struct WorkoutView: View {
|
||||
@EnvironmentObject var vm: WorkoutViewModel
|
||||
@State private var showManage = false
|
||||
@State private var showAddSection = false
|
||||
@State private var showHistory = false
|
||||
@State private var showStats = false
|
||||
@State private var showAnalytics = false
|
||||
@State private var isReordering = false
|
||||
@State private var trainAnyway = false // override the rest-day state for today
|
||||
@State private var showClearAllConfirm = false
|
||||
@ObservedObject private var tm = TutorialManager.shared
|
||||
private let restTick = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
||||
|
||||
@@ -70,6 +76,10 @@ struct WorkoutView: View {
|
||||
return f.string(from: Date())
|
||||
}
|
||||
|
||||
/// Show the rest-day state when today has no scheduled program and the user
|
||||
/// hasn't chosen to train anyway.
|
||||
private var isRestState: Bool { vm.isRestDayToday && !trainAnyway }
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
if isReordering {
|
||||
@@ -85,8 +95,8 @@ struct WorkoutView: View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(spacing: 6) {
|
||||
ProgramIcon(name: vm.workoutEmoji, size: 14, color: AppColors.accent)
|
||||
Text(vm.workoutTitle)
|
||||
ProgramIcon(name: isRestState ? "moon.zzz.fill" : vm.workoutEmoji, size: 14, color: AppColors.accent)
|
||||
Text(isRestState ? "Rest Day" : vm.workoutTitle)
|
||||
.font(AppFonts.sans(15, weight: .semibold))
|
||||
.foregroundColor(AppColors.text(cs))
|
||||
}
|
||||
@@ -95,22 +105,58 @@ struct WorkoutView: View {
|
||||
.foregroundColor(AppColors.text3(cs))
|
||||
}
|
||||
Spacer()
|
||||
IButton(icon: "ellipsis") { showManage = true }
|
||||
IButton(icon: "chart.line.uptrend.xyaxis") { showAnalytics = true }
|
||||
IButton(icon: "chart.bar.xaxis") { showStats = true }
|
||||
IButton(icon: "clock.arrow.circlepath") { showHistory = true }
|
||||
Menu {
|
||||
Button(role: .destructive) { showClearAllConfirm = true } label: {
|
||||
Label("Clear All Sets", systemImage: "circle")
|
||||
}
|
||||
Divider()
|
||||
Button { showManage = true } label: { Label("Manage Workouts", systemImage: "slider.horizontal.3") }
|
||||
} label: {
|
||||
Image(systemName: "ellipsis")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundColor(AppColors.text2(cs))
|
||||
.frame(width: 34, height: 34)
|
||||
.background(AppColors.surface2(cs))
|
||||
.clipShape(Circle())
|
||||
.overlay(Circle().stroke(AppColors.border(cs), lineWidth: 1))
|
||||
}
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 8, leading: 18, bottom: 4, trailing: 18))
|
||||
.moveDisabled(true)
|
||||
|
||||
// ── Dot Grid Progress ──
|
||||
DotProgressCard(progress: vm.progress, done: vm.doneSets, total: vm.totalSets)
|
||||
if isRestState {
|
||||
// ── Rest Day ──
|
||||
RestDayCard(onTrain: { withAnimation(KisaniSpring.snappy) { trainAnyway = true } })
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 16, leading: 14, bottom: 6, trailing: 14))
|
||||
.moveDisabled(true)
|
||||
} else {
|
||||
|
||||
// ── Dot Grid Progress + Completion ──
|
||||
DotProgressCard(
|
||||
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)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 14, bottom: 6, trailing: 14))
|
||||
.moveDisabled(true)
|
||||
|
||||
// ── Streak ──
|
||||
StreakBannerView(streak: vm.streakDays)
|
||||
StreakBannerView(vm: vm)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 14, bottom: 10, trailing: 14))
|
||||
@@ -142,6 +188,17 @@ struct WorkoutView: View {
|
||||
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||
}
|
||||
.contextMenu {
|
||||
Button {
|
||||
withAnimation { vm.setExerciseDone(sectionId: section.id, exerciseId: ex.id, done: true) }
|
||||
} label: {
|
||||
Label("Mark as Done", systemImage: "checkmark.circle")
|
||||
}
|
||||
Button {
|
||||
withAnimation { vm.setExerciseDone(sectionId: section.id, exerciseId: ex.id, done: false) }
|
||||
} label: {
|
||||
Label("Mark as Not Done", systemImage: "circle")
|
||||
}
|
||||
Divider()
|
||||
Button(role: .destructive) {
|
||||
withAnimation { vm.deleteExercise(sectionId: section.id, exerciseId: ex.id) }
|
||||
} label: {
|
||||
@@ -200,6 +257,7 @@ struct WorkoutView: View {
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 4, leading: 14, bottom: 100, trailing: 14))
|
||||
.moveDisabled(true)
|
||||
} // end non-rest content
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
@@ -271,6 +329,33 @@ struct WorkoutView: View {
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
.sheet(isPresented: $showHistory) {
|
||||
WorkoutHistoryView().environmentObject(vm)
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
.sheet(isPresented: $showAnalytics) {
|
||||
AnalyticsView()
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
.sheet(isPresented: $showStats) {
|
||||
WorkoutStatsView().environmentObject(vm)
|
||||
.presentationDetents([.large])
|
||||
.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) {
|
||||
AddSectionSheet().environmentObject(vm)
|
||||
.presentationDetents([.height(240)])
|
||||
@@ -506,60 +591,561 @@ struct AddSectionSheet: View {
|
||||
struct DotProgressCard: View {
|
||||
@Environment(\.colorScheme) private var cs
|
||||
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 {
|
||||
VStack(spacing: 10) {
|
||||
VStack(spacing: 12) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("\(done) of \(total) sets done")
|
||||
Text(statusTitle)
|
||||
.font(AppFonts.sans(13, weight: .semibold))
|
||||
.foregroundColor(AppColors.text(cs))
|
||||
Text(total == 0 ? "Add exercises to get started"
|
||||
: done == total && total > 0 ? "All sets done"
|
||||
: "\(total - done) sets remaining")
|
||||
Text(statusSubtitle)
|
||||
.font(AppFonts.mono(9))
|
||||
.foregroundColor(AppColors.text3(cs))
|
||||
}
|
||||
Spacer()
|
||||
Text(total > 0 ? "\(Int(progress * 100))%" : "0%")
|
||||
.font(AppFonts.mono(13, weight: .bold))
|
||||
.foregroundColor(progress >= 1 ? AppColors.green : AppColors.text2(cs))
|
||||
statusBadge
|
||||
}
|
||||
|
||||
// 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) {
|
||||
Text("PROGRESS")
|
||||
.font(AppFonts.mono(8, weight: .bold))
|
||||
.foregroundColor(AppColors.text3(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)
|
||||
.cardStyle(borderColor: isDoneToday ? AppColors.green.opacity(0.3) : nil)
|
||||
.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Workout Stats
|
||||
struct WorkoutStatsView: View {
|
||||
@EnvironmentObject var vm: WorkoutViewModel
|
||||
@Environment(\.colorScheme) var cs
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@State private var period: StatPeriod = .week
|
||||
@State private var metric: Metric = .volume
|
||||
|
||||
enum Metric: String, CaseIterable, Identifiable {
|
||||
case volume = "Volume", sets = "Sets", workouts = "Workouts"
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
private var current: WorkoutStat { vm.stat(period, offset: 0) }
|
||||
private var previous: WorkoutStat { vm.stat(period, offset: -1) }
|
||||
private var buckets: [StatBucket] {
|
||||
switch period {
|
||||
case .day: return vm.statBuckets(.day, count: 14)
|
||||
case .week: return vm.statBuckets(.week, count: 8)
|
||||
case .month: return vm.statBuckets(.month, count: 6)
|
||||
}
|
||||
}
|
||||
|
||||
private func value(_ s: WorkoutStat) -> Double {
|
||||
switch metric { case .volume: return s.volume; case .sets: return Double(s.sets); case .workouts: return Double(s.workouts) }
|
||||
}
|
||||
private static let num: NumberFormatter = {
|
||||
let f = NumberFormatter(); f.numberStyle = .decimal; f.maximumFractionDigits = 0; return f
|
||||
}()
|
||||
private func volStr(_ v: Double) -> String { (Self.num.string(from: NSNumber(value: v)) ?? "0") + " kg" }
|
||||
|
||||
private func delta(_ cur: Double, _ prev: Double) -> (String, Bool)? {
|
||||
guard prev > 0 else { return cur > 0 ? ("New", true) : nil }
|
||||
let pct = (cur - prev) / prev * 100
|
||||
if abs(pct) < 1 { return ("±0%", true) }
|
||||
return (String(format: "%+.0f%%", pct), pct >= 0)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text("Workout Stats").font(AppFonts.sans(17, weight: .bold)).foregroundColor(AppColors.text(cs))
|
||||
Spacer()
|
||||
Button("Done") { dismiss() }.font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.accent).buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 20).padding(.top, 20).padding(.bottom, 12)
|
||||
|
||||
Picker("", selection: $period) {
|
||||
ForEach(StatPeriod.allCases) { Text($0.rawValue).tag($0) }
|
||||
}
|
||||
.pickerStyle(.segmented).padding(.horizontal, 20).padding(.bottom, 12)
|
||||
|
||||
Divider().background(AppColors.border(cs))
|
||||
|
||||
if vm.history.isEmpty {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "chart.bar.xaxis").font(.system(size: 28)).foregroundColor(AppColors.text3(cs))
|
||||
Text("No data yet").font(AppFonts.sans(14, weight: .semibold)).foregroundColor(AppColors.text2(cs))
|
||||
Text("Complete workouts and your stats appear here.")
|
||||
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs)).multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity).padding()
|
||||
} else {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: 14) {
|
||||
// ── Summary cards (this period vs last) ──
|
||||
HStack(spacing: 10) {
|
||||
statCard("Workouts", "\(current.workouts)", delta(Double(current.workouts), Double(previous.workouts)))
|
||||
statCard("Sets", "\(current.sets)", delta(Double(current.sets), Double(previous.sets)))
|
||||
}
|
||||
statCard("Volume", volStr(current.volume), delta(current.volume, previous.volume), wide: true)
|
||||
|
||||
// ── Metric chooser ──
|
||||
Picker("", selection: $metric) {
|
||||
ForEach(Metric.allCases) { Text($0.rawValue).tag($0) }
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
// ── Trend chart ──
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("\(metric.rawValue.uppercased()) · LAST \(buckets.count) \(period.rawValue.uppercased())S")
|
||||
.font(AppFonts.mono(8.5, weight: .bold)).foregroundColor(AppColors.text3(cs))
|
||||
Chart(buckets) { b in
|
||||
BarMark(
|
||||
x: .value("Period", b.label),
|
||||
y: .value(metric.rawValue, value(b.stat))
|
||||
)
|
||||
.foregroundStyle(AppColors.accent.gradient)
|
||||
.cornerRadius(4)
|
||||
}
|
||||
.frame(height: 180)
|
||||
.chartYAxis { AxisMarks(position: .leading) }
|
||||
}
|
||||
.padding(14).cardStyle()
|
||||
}
|
||||
.padding(.horizontal, 16).padding(.top, 14).padding(.bottom, 30)
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(AppColors.background(cs).ignoresSafeArea())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func statCard(_ title: String, _ value: String, _ delta: (String, Bool)?, wide: Bool = false) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(title.uppercased()).font(AppFonts.mono(8.5, weight: .bold)).foregroundColor(AppColors.text3(cs))
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(value).font(AppFonts.mono(wide ? 24 : 20, weight: .bold)).foregroundColor(AppColors.text(cs))
|
||||
if let d = delta {
|
||||
HStack(spacing: 2) {
|
||||
Image(systemName: d.1 ? "arrow.up.right" : "arrow.down.right").font(.system(size: 9, weight: .bold))
|
||||
Text(d.0).font(AppFonts.mono(10, weight: .bold))
|
||||
}
|
||||
.foregroundColor(d.1 ? AppColors.green : AppColors.red)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
Text("vs last \(period.rawValue.lowercased())").font(AppFonts.sans(10)).foregroundColor(AppColors.text3(cs))
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(14).cardStyle()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Workout History
|
||||
struct WorkoutHistoryView: View {
|
||||
@EnvironmentObject var vm: WorkoutViewModel
|
||||
@Environment(\.colorScheme) var cs
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
private let parser: DateFormatter = {
|
||||
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f
|
||||
}()
|
||||
private let pretty: DateFormatter = {
|
||||
let f = DateFormatter(); f.dateFormat = "EEE, MMM d"; return f
|
||||
}()
|
||||
|
||||
private func label(_ key: String) -> String {
|
||||
guard let d = parser.date(from: key) else { return key }
|
||||
if Calendar.current.isDateInToday(d) { return "Today" }
|
||||
if Calendar.current.isDateInYesterday(d) { return "Yesterday" }
|
||||
return pretty.string(from: d)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text("Workout History").font(AppFonts.sans(17, weight: .bold)).foregroundColor(AppColors.text(cs))
|
||||
Spacer()
|
||||
Button("Done") { dismiss() }.font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.accent).buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 20).padding(.top, 20).padding(.bottom, 14)
|
||||
Divider().background(AppColors.border(cs))
|
||||
|
||||
if vm.historySorted.isEmpty {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "clock.arrow.circlepath").font(.system(size: 28)).foregroundColor(AppColors.text3(cs))
|
||||
Text("No history yet").font(AppFonts.sans(14, weight: .semibold)).foregroundColor(AppColors.text2(cs))
|
||||
Text("Completed workouts are saved here each day.")
|
||||
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs)).multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity).padding()
|
||||
} else {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: 12) {
|
||||
ForEach(vm.historySorted) { day in
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(spacing: 8) {
|
||||
ProgramIcon(name: day.programEmoji, size: 14, color: AppColors.accent)
|
||||
Text(day.programName).font(AppFonts.sans(14, weight: .semibold)).foregroundColor(AppColors.text(cs))
|
||||
Spacer()
|
||||
Text(label(day.date)).font(AppFonts.mono(10)).foregroundColor(AppColors.text3(cs))
|
||||
}
|
||||
Text("\(day.doneSets)/\(day.totalSets) sets")
|
||||
.font(AppFonts.mono(9, weight: .bold)).foregroundColor(AppColors.green)
|
||||
ForEach(day.exercises) { ex in
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: ex.sfSymbol).font(.system(size: 11)).foregroundColor(AppColors.text3(cs)).frame(width: 18)
|
||||
Text(ex.name).font(AppFonts.sans(12)).foregroundColor(AppColors.text2(cs))
|
||||
Spacer()
|
||||
Text("\(ex.doneSets)/\(ex.sets.count)").font(AppFonts.mono(10)).foregroundColor(AppColors.text3(cs))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(14).cardStyle()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16).padding(.top, 14).padding(.bottom, 30)
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(AppColors.background(cs).ignoresSafeArea())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Compensation Sheet ("missed → make it up on a rest day")
|
||||
struct CompensationSheet: View {
|
||||
let missed: WorkoutViewModel.MissedDay
|
||||
@EnvironmentObject var vm: WorkoutViewModel
|
||||
@Environment(\.colorScheme) var cs
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
private let dayFmt: DateFormatter = {
|
||||
let f = DateFormatter(); f.dateFormat = "EEEE, MMM d"; return f
|
||||
}()
|
||||
|
||||
private var restDays: [Date] { vm.upcomingRestDays() }
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: "arrow.uturn.forward.circle")
|
||||
.font(.system(size: 30, weight: .light))
|
||||
.foregroundColor(AppColors.accent)
|
||||
Text("Missed: \(missed.programName)")
|
||||
.font(AppFonts.sans(17, weight: .bold)).foregroundColor(AppColors.text(cs))
|
||||
Text("Do you want to compensate for this workout on a rest day?")
|
||||
.font(AppFonts.sans(12)).foregroundColor(AppColors.text3(cs))
|
||||
.multilineTextAlignment(.center).padding(.horizontal, 24)
|
||||
}
|
||||
.padding(.top, 24).padding(.bottom, 16)
|
||||
|
||||
Divider().background(AppColors.border(cs))
|
||||
|
||||
if restDays.isEmpty {
|
||||
Text("No free rest days in the next two weeks.")
|
||||
.font(AppFonts.sans(12)).foregroundColor(AppColors.text3(cs))
|
||||
.frame(maxWidth: .infinity).padding(.vertical, 30)
|
||||
} else {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(restDays, id: \.self) { day in
|
||||
Button {
|
||||
if let pid = missed.programId {
|
||||
vm.scheduleCompensation(programId: pid, on: day)
|
||||
}
|
||||
dismiss()
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "moon.zzz")
|
||||
.font(.system(size: 13)).foregroundColor(AppColors.accent)
|
||||
.frame(width: 28)
|
||||
Text(dayFmt.string(from: day))
|
||||
.font(AppFonts.sans(14)).foregroundColor(AppColors.text(cs))
|
||||
Spacer()
|
||||
Text("COMPENSATE")
|
||||
.font(AppFonts.mono(8.5, weight: .bold))
|
||||
.foregroundColor(AppColors.accent)
|
||||
.padding(.horizontal, 8).padding(.vertical, 4)
|
||||
.background(AppColors.accentSoft).clipShape(Capsule())
|
||||
}
|
||||
.padding(.horizontal, 18).padding(.vertical, 13)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
if day != restDays.last { Divider().background(AppColors.border(cs)).padding(.leading, 46) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
Button { dismiss() } label: {
|
||||
Text("No, keep as missed")
|
||||
.font(AppFonts.sans(14, weight: .semibold))
|
||||
.foregroundColor(AppColors.text2(cs))
|
||||
.frame(maxWidth: .infinity).padding(13)
|
||||
.background(AppColors.surface2(cs))
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.horizontal, 20).padding(.bottom, 20).padding(.top, 8)
|
||||
}
|
||||
.background(AppColors.background(cs).ignoresSafeArea())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Rest Day Card
|
||||
struct RestDayCard: View {
|
||||
@Environment(\.colorScheme) private var cs
|
||||
let onTrain: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "moon.zzz.fill")
|
||||
.font(.system(size: 30, weight: .light))
|
||||
.foregroundColor(AppColors.accent)
|
||||
VStack(spacing: 4) {
|
||||
Text("Rest Day")
|
||||
.font(AppFonts.sans(17, weight: .bold))
|
||||
.foregroundColor(AppColors.text(cs))
|
||||
Text("No workout scheduled today. Recover and come back stronger.")
|
||||
.font(AppFonts.sans(12))
|
||||
.foregroundColor(AppColors.text3(cs))
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
Button(action: onTrain) {
|
||||
Text("Work out anyway")
|
||||
.font(AppFonts.sans(13, weight: .semibold))
|
||||
.foregroundColor(AppColors.accent)
|
||||
.padding(.horizontal, 16).padding(.vertical, 9)
|
||||
.background(AppColors.accentSoft)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.buttonStyle(PressButtonStyle(scale: 0.96))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 28).padding(.horizontal, 16)
|
||||
.cardStyle()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Streak Banner
|
||||
// Swipeable: this week → this year → this-week-vs-last-week.
|
||||
struct StreakBannerView: View {
|
||||
@Environment(\.colorScheme) private var cs
|
||||
let streak: Int
|
||||
@ObservedObject var vm: WorkoutViewModel
|
||||
@State private var page = 0
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("\(streak)")
|
||||
.font(AppFonts.mono(28, weight: .bold))
|
||||
.foregroundColor(AppColors.text(cs))
|
||||
Text("day streak")
|
||||
.font(AppFonts.sans(11))
|
||||
.foregroundColor(AppColors.text3(cs))
|
||||
VStack(spacing: 8) {
|
||||
TabView(selection: $page) {
|
||||
weekPage.tag(0)
|
||||
yearPage.tag(1)
|
||||
comparePage.tag(2)
|
||||
}
|
||||
Spacer()
|
||||
// 7-day tick bar — current week progress
|
||||
HStack(spacing: 3) {
|
||||
ForEach(0..<7, id: \.self) { i in
|
||||
let filled = streak > 0 && i < (streak % 7 == 0 ? 7 : streak % 7)
|
||||
RoundedRectangle(cornerRadius: 1.5)
|
||||
.fill(filled ? AppColors.accent : AppColors.borderHi(cs))
|
||||
.frame(width: 18, height: 3)
|
||||
.tabViewStyle(.page(indexDisplayMode: .never))
|
||||
.frame(height: 50)
|
||||
|
||||
// Page dots
|
||||
HStack(spacing: 5) {
|
||||
ForEach(0..<3, id: \.self) { i in
|
||||
Circle()
|
||||
.fill(i == page ? AppColors.accent : AppColors.borderHi(cs))
|
||||
.frame(width: 5, height: 5)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -567,6 +1153,76 @@ struct StreakBannerView: View {
|
||||
.padding(.vertical, 13)
|
||||
.cardStyle()
|
||||
}
|
||||
|
||||
// Page 1 — this week (per-day ticks)
|
||||
private var weekPage: some View {
|
||||
HStack(spacing: 0) {
|
||||
statBlock(value: "\(vm.workoutsThisWeek)", label: "this week")
|
||||
Spacer()
|
||||
HStack(spacing: 3) {
|
||||
ForEach(Array(vm.currentWeekDays.enumerated()), id: \.offset) { _, done in
|
||||
RoundedRectangle(cornerRadius: 1.5)
|
||||
.fill(done ? AppColors.accent : AppColors.borderHi(cs))
|
||||
.frame(width: 18, height: 3)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Page 2 — this year (month ticks) + all-time total
|
||||
private var yearPage: some View {
|
||||
HStack(spacing: 0) {
|
||||
statBlock(value: "\(vm.workoutsThisYear)", label: "this year")
|
||||
Spacer()
|
||||
VStack(alignment: .trailing, spacing: 5) {
|
||||
HStack(spacing: 2) {
|
||||
ForEach(Array(vm.currentYearMonths.enumerated()), id: \.offset) { _, active in
|
||||
RoundedRectangle(cornerRadius: 1)
|
||||
.fill(active ? AppColors.accent : AppColors.borderHi(cs))
|
||||
.frame(width: 9, height: 3)
|
||||
}
|
||||
}
|
||||
Text("\(vm.streakDays) all-time")
|
||||
.font(AppFonts.mono(9))
|
||||
.foregroundColor(AppColors.text3(cs))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Page 3 — this week vs last week
|
||||
private var comparePage: some View {
|
||||
let delta = vm.workoutsThisWeek - vm.workoutsLastWeek
|
||||
let deltaColor = delta > 0 ? AppColors.green : (delta < 0 ? AppColors.accent : AppColors.text3(cs))
|
||||
return HStack(spacing: 0) {
|
||||
statBlock(value: "\(vm.workoutsThisWeek)", label: "this week")
|
||||
Spacer()
|
||||
VStack(alignment: .trailing, spacing: 3) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: delta > 0 ? "arrow.up.right" : (delta < 0 ? "arrow.down.right" : "equal"))
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
Text(delta == 0
|
||||
? "same as last week"
|
||||
: "\(abs(delta)) \(delta > 0 ? "more" : "fewer") than last week")
|
||||
.font(AppFonts.mono(10, weight: .semibold))
|
||||
}
|
||||
.foregroundColor(deltaColor)
|
||||
Text("last week: \(vm.workoutsLastWeek)")
|
||||
.font(AppFonts.mono(9))
|
||||
.foregroundColor(AppColors.text3(cs))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func statBlock(value: String, label: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(value)
|
||||
.font(AppFonts.mono(28, weight: .bold))
|
||||
.foregroundColor(AppColors.text(cs))
|
||||
Text(label)
|
||||
.font(AppFonts.sans(11))
|
||||
.foregroundColor(AppColors.text3(cs))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Exercise Card
|
||||
@@ -1039,11 +1695,19 @@ struct ProgramDetailSheet: View {
|
||||
ForEach(prog.sections) { section in
|
||||
Section {
|
||||
if section.exercises.isEmpty {
|
||||
Text("No exercises — tap + to add")
|
||||
Text("No exercises — tap + or drag one here")
|
||||
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
|
||||
.frame(maxWidth: .infinity).padding(.vertical, 10)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.dropDestination(for: String.self) { items, _ in
|
||||
guard let s = items.first, let id = UUID(uuidString: s) else { return false }
|
||||
withAnimation(KisaniSpring.snappy) {
|
||||
vm.moveExercise(programId: programId, exerciseId: id,
|
||||
toSection: section.id, before: nil)
|
||||
}
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
ForEach(section.exercises) { exercise in
|
||||
HStack(spacing: 10) {
|
||||
@@ -1065,6 +1729,15 @@ struct ProgramDetailSheet: View {
|
||||
.padding(.vertical, 4)
|
||||
.listRowBackground(AppColors.surface(cs))
|
||||
.listRowSeparator(.hidden)
|
||||
.draggable(exercise.id.uuidString)
|
||||
.dropDestination(for: String.self) { items, _ in
|
||||
guard let s = items.first, let id = UUID(uuidString: s) else { return false }
|
||||
withAnimation(KisaniSpring.snappy) {
|
||||
vm.moveExercise(programId: programId, exerciseId: id,
|
||||
toSection: section.id, before: exercise.id)
|
||||
}
|
||||
return true
|
||||
}
|
||||
.swipeActions(edge: .trailing) {
|
||||
Button(role: .destructive) {
|
||||
vm.deleteExercise(programId: programId, sectionId: section.id, exerciseId: exercise.id)
|
||||
@@ -1093,6 +1766,14 @@ struct ProgramDetailSheet: View {
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.padding(.vertical, 2)
|
||||
.dropDestination(for: String.self) { items, _ in
|
||||
guard let s = items.first, let id = UUID(uuidString: s) else { return false }
|
||||
withAnimation(KisaniSpring.snappy) {
|
||||
vm.moveExercise(programId: programId, exerciseId: id,
|
||||
toSection: section.id, before: nil)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
} header: {
|
||||
HStack {
|
||||
|
||||
61
KisaniCalTests/AnalyticsAdapterTests.swift
Normal file
@@ -0,0 +1,61 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
53
KisaniCalTests/AnalyticsCoordinatorTests.swift
Normal file
@@ -0,0 +1,53 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
282
KisaniCalTests/AnalyticsEngineTests.swift
Normal file
@@ -0,0 +1,282 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
94
KisaniCalTests/AnalyticsStoreTests.swift
Normal file
@@ -0,0 +1,94 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
158
KisaniCalTests/CalendarGridTests.swift
Normal file
@@ -0,0 +1,158 @@
|
||||
import XCTest
|
||||
@testable import KisaniCal
|
||||
|
||||
/// Calendar correctness tests. These treat the month grid as a data-integrity concern:
|
||||
/// every cell must be a real date, columns must respect firstWeekday, and the weekday
|
||||
/// header must match the grid for any firstWeekday and timezone.
|
||||
final class CalendarGridTests: XCTestCase {
|
||||
|
||||
private func makeCalendar(firstWeekday: Int, tz: String) -> Calendar {
|
||||
var c = Calendar(identifier: .gregorian)
|
||||
c.timeZone = TimeZone(identifier: tz)!
|
||||
c.firstWeekday = firstWeekday
|
||||
return c
|
||||
}
|
||||
|
||||
private func iso(_ date: Date, _ cal: Calendar) -> String {
|
||||
let c = cal.dateComponents([.year, .month, .day], from: date)
|
||||
return String(format: "%04d-%02d-%02d", c.year!, c.month!, c.day!)
|
||||
}
|
||||
|
||||
private func date(_ y: Int, _ m: Int, _ d: Int, _ cal: Calendar) -> Date {
|
||||
cal.date(from: DateComponents(year: y, month: m, day: d))!
|
||||
}
|
||||
|
||||
private let timezones = ["America/New_York", "Europe/London", "Pacific/Kiritimati", "UTC"]
|
||||
|
||||
// MARK: - Ground-truth weekday facts
|
||||
|
||||
func testJune2026WeekdayFacts() {
|
||||
for tz in timezones {
|
||||
let cal = makeCalendar(firstWeekday: 1, tz: tz)
|
||||
let f = DateFormatter(); f.calendar = cal; f.timeZone = cal.timeZone; f.dateFormat = "EEEE"
|
||||
XCTAssertEqual(f.string(from: date(2026, 6, 1, cal)), "Monday", tz)
|
||||
XCTAssertEqual(f.string(from: date(2026, 6, 7, cal)), "Sunday", tz)
|
||||
XCTAssertEqual(f.string(from: date(2026, 6, 24, cal)), "Wednesday", tz)
|
||||
XCTAssertEqual(f.string(from: date(2026, 6, 27, cal)), "Saturday", tz)
|
||||
XCTAssertEqual(f.string(from: date(2026, 6, 30, cal)), "Tuesday", tz)
|
||||
XCTAssertEqual(f.string(from: date(2026, 7, 1, cal)), "Wednesday", tz)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sunday-first June 2026 grid matches the exact spec
|
||||
|
||||
func testSundayFirstJune2026GridMatchesSpec() {
|
||||
let expected = [
|
||||
"2026-05-31","2026-06-01","2026-06-02","2026-06-03","2026-06-04","2026-06-05","2026-06-06",
|
||||
"2026-06-07","2026-06-08","2026-06-09","2026-06-10","2026-06-11","2026-06-12","2026-06-13",
|
||||
"2026-06-14","2026-06-15","2026-06-16","2026-06-17","2026-06-18","2026-06-19","2026-06-20",
|
||||
"2026-06-21","2026-06-22","2026-06-23","2026-06-24","2026-06-25","2026-06-26","2026-06-27",
|
||||
"2026-06-28","2026-06-29","2026-06-30","2026-07-01","2026-07-02","2026-07-03","2026-07-04",
|
||||
]
|
||||
for tz in timezones {
|
||||
let cal = makeCalendar(firstWeekday: 1, tz: tz)
|
||||
let grid = CalendarGrid.monthGrid(for: date(2026, 6, 15, cal), calendar: cal)
|
||||
XCTAssertEqual(grid.map { iso($0, cal) }, expected, "Sunday-first grid wrong in \(tz)")
|
||||
XCTAssertEqual(grid.count % 7, 0, "grid must be whole weeks")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Header always matches grid (the actual bug)
|
||||
|
||||
func testHeaderMatchesGridFirstColumnForAnyFirstWeekday() {
|
||||
for tz in timezones {
|
||||
for fw in 1...7 {
|
||||
let cal = makeCalendar(firstWeekday: fw, tz: tz)
|
||||
let grid = CalendarGrid.monthGrid(for: date(2026, 6, 15, cal), calendar: cal)
|
||||
let header = CalendarGrid.weekdaySymbols(calendar: cal)
|
||||
let letters = ["S","M","T","W","T","F","S"] // weekday 1...7
|
||||
for col in 0..<7 {
|
||||
let weekday = cal.component(.weekday, from: grid[col]) // 1=Sun..7=Sat
|
||||
XCTAssertEqual(header[col], letters[weekday - 1],
|
||||
"col \(col) header \(header[col]) != grid weekday \(weekday) (fw=\(fw), tz=\(tz))")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testHeaderRotations() {
|
||||
let sun = makeCalendar(firstWeekday: 1, tz: "UTC")
|
||||
let mon = makeCalendar(firstWeekday: 2, tz: "UTC")
|
||||
XCTAssertEqual(CalendarGrid.weekdaySymbols(calendar: sun), ["S","M","T","W","T","F","S"])
|
||||
XCTAssertEqual(CalendarGrid.weekdaySymbols(calendar: mon), ["M","T","W","T","F","S","S"])
|
||||
}
|
||||
|
||||
// MARK: - Cell date identity
|
||||
|
||||
func testCellIdentityIsRealDate() {
|
||||
let cal = makeCalendar(firstWeekday: 1, tz: "America/New_York")
|
||||
let grid = CalendarGrid.monthGrid(for: date(2026, 6, 1, cal), calendar: cal)
|
||||
// June 24 must be a Wednesday and the 25th element (index 24) in the spec grid.
|
||||
let june24 = grid.first { cal.isDate($0, inSameDayAs: date(2026, 6, 24, cal)) }
|
||||
XCTAssertNotNil(june24)
|
||||
XCTAssertEqual(cal.component(.weekday, from: june24!), 4) // Wednesday
|
||||
// No duplicate dates, strictly increasing by one day.
|
||||
for i in 1..<grid.count {
|
||||
XCTAssertEqual(cal.dateComponents([.day], from: grid[i-1], to: grid[i]).day, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Month navigation
|
||||
|
||||
func testMonthNavigationBoundaries() {
|
||||
let cal = makeCalendar(firstWeekday: 1, tz: "UTC")
|
||||
// Dec 2026 -> trails into Jan 2027
|
||||
let dec = CalendarGrid.monthGrid(for: date(2026, 12, 10, cal), calendar: cal).map { iso($0, cal) }
|
||||
XCTAssertTrue(dec.contains("2027-01-01"))
|
||||
XCTAssertEqual(dec.first, "2026-11-29")
|
||||
// Feb 2026 (non-leap): contains Feb 28, not Feb 29
|
||||
let feb26 = CalendarGrid.monthGrid(for: date(2026, 2, 10, cal), calendar: cal).map { iso($0, cal) }
|
||||
XCTAssertTrue(feb26.contains("2026-02-28"))
|
||||
XCTAssertFalse(feb26.contains("2026-02-29"))
|
||||
// Feb 2028 (leap): contains Feb 29
|
||||
let feb28 = CalendarGrid.monthGrid(for: date(2028, 2, 10, cal), calendar: cal).map { iso($0, cal) }
|
||||
XCTAssertTrue(feb28.contains("2028-02-29"))
|
||||
// May & July 2026 endpoints
|
||||
let may = CalendarGrid.monthGrid(for: date(2026, 5, 10, cal), calendar: cal).map { iso($0, cal) }
|
||||
XCTAssertTrue(may.contains("2026-05-01") && may.contains("2026-05-31"))
|
||||
let jul = CalendarGrid.monthGrid(for: date(2026, 7, 10, cal), calendar: cal).map { iso($0, cal) }
|
||||
XCTAssertTrue(jul.contains("2026-07-01") && jul.contains("2026-07-31"))
|
||||
}
|
||||
|
||||
// MARK: - Event-day bucketing (same logic CalendarStore.events(for:) uses)
|
||||
|
||||
/// Mirrors CalendarStore.events(for:) overlap test: s < endOfDay && e > startOfDay.
|
||||
private func overlaps(start: Date, end: Date, day: Date, cal: Calendar) -> Bool {
|
||||
let dayStart = cal.startOfDay(for: day)
|
||||
let dayEnd = cal.date(byAdding: .day, value: 1, to: dayStart)!
|
||||
return start < dayEnd && end > dayStart
|
||||
}
|
||||
|
||||
func testAllDayEventMapsToSingleLocalDay() {
|
||||
let cal = makeCalendar(firstWeekday: 1, tz: "America/New_York")
|
||||
// All-day June 24: [Jun24 00:00, Jun25 00:00)
|
||||
let s = cal.startOfDay(for: date(2026, 6, 24, cal))
|
||||
let e = cal.date(byAdding: .day, value: 1, to: s)!
|
||||
XCTAssertTrue(overlaps(start: s, end: e, day: date(2026, 6, 24, cal), cal: cal))
|
||||
XCTAssertFalse(overlaps(start: s, end: e, day: date(2026, 6, 23, cal), cal: cal))
|
||||
XCTAssertFalse(overlaps(start: s, end: e, day: date(2026, 6, 25, cal), cal: cal))
|
||||
}
|
||||
|
||||
func testLateNightTimedEventStaysOnLocalDay() {
|
||||
let cal = makeCalendar(firstWeekday: 1, tz: "America/New_York")
|
||||
// 11:30pm–11:45pm local on June 24 — must not bleed into the 25th.
|
||||
let s = cal.date(from: DateComponents(year: 2026, month: 6, day: 24, hour: 23, minute: 30))!
|
||||
let e = cal.date(from: DateComponents(year: 2026, month: 6, day: 24, hour: 23, minute: 45))!
|
||||
XCTAssertTrue(overlaps(start: s, end: e, day: date(2026, 6, 24, cal), cal: cal))
|
||||
XCTAssertFalse(overlaps(start: s, end: e, day: date(2026, 6, 25, cal), cal: cal))
|
||||
}
|
||||
|
||||
func testEventCrossingMidnightHitsBothDays() {
|
||||
let cal = makeCalendar(firstWeekday: 1, tz: "America/New_York")
|
||||
let s = cal.date(from: DateComponents(year: 2026, month: 6, day: 24, hour: 23, minute: 30))!
|
||||
let e = cal.date(from: DateComponents(year: 2026, month: 6, day: 25, hour: 0, minute: 30))!
|
||||
XCTAssertTrue(overlaps(start: s, end: e, day: date(2026, 6, 24, cal), cal: cal))
|
||||
XCTAssertTrue(overlaps(start: s, end: e, day: date(2026, 6, 25, cal), cal: cal))
|
||||
XCTAssertFalse(overlaps(start: s, end: e, day: date(2026, 6, 23, cal), cal: cal))
|
||||
}
|
||||
}
|
||||
51
KisaniCalTests/NLRecurrenceParseTests.swift
Normal file
@@ -0,0 +1,51 @@
|
||||
import XCTest
|
||||
@testable import KisaniCal
|
||||
|
||||
/// Natural-language recurrence detection in the quick-add parser.
|
||||
final class NLRecurrenceParseTests: XCTestCase {
|
||||
|
||||
private func label(_ s: String) -> String? {
|
||||
NLTaskParser.parse(s).recurrenceLabel
|
||||
}
|
||||
|
||||
func testDailyVariants() {
|
||||
XCTAssertEqual(label("remind me to pray everyday"), "Daily") // the reported case
|
||||
XCTAssertEqual(label("remind me to pray every day"), "Daily")
|
||||
XCTAssertEqual(label("stretch daily"), "Daily")
|
||||
XCTAssertEqual(label("read each day"), "Daily")
|
||||
}
|
||||
|
||||
func testWeekdayVsWeekly() {
|
||||
XCTAssertEqual(label("standup every weekday"), "Every Weekday")
|
||||
XCTAssertEqual(label("standup weekdays"), "Every Weekday")
|
||||
XCTAssertEqual(label("groceries every week"), "Weekly")
|
||||
XCTAssertEqual(label("groceries weekly"), "Weekly")
|
||||
}
|
||||
|
||||
func testMonthlyYearly() {
|
||||
XCTAssertEqual(label("pay rent every month"), "Monthly")
|
||||
XCTAssertEqual(label("pay rent monthly"), "Monthly")
|
||||
XCTAssertEqual(label("renew every year"), "Yearly")
|
||||
XCTAssertEqual(label("renew annually"), "Yearly")
|
||||
XCTAssertEqual(label("file taxes yearly"), "Yearly")
|
||||
}
|
||||
|
||||
func testBirthdayIsYearly() {
|
||||
let p = NLTaskParser.parse("Jay's birthday")
|
||||
XCTAssertTrue(p.isBirthday)
|
||||
XCTAssertTrue(p.isRecurring)
|
||||
XCTAssertEqual(p.recurrenceLabel, "Yearly")
|
||||
}
|
||||
|
||||
func testNonRecurringStaysNil() {
|
||||
XCTAssertNil(label("buy milk"))
|
||||
XCTAssertNil(label("call mom tomorrow"))
|
||||
}
|
||||
|
||||
/// Sub-day cadence isn't supported by the (day-granular) occurrence engine, so it
|
||||
/// must NOT be mislabeled as Daily/etc. — it stays a one-off.
|
||||
func testHourlyIsNotMisclassified() {
|
||||
XCTAssertNil(label("drink water every hour"))
|
||||
XCTAssertNil(label("check oven hourly"))
|
||||
}
|
||||
}
|
||||
100
KisaniCalTests/RecurrenceTests.swift
Normal file
@@ -0,0 +1,100 @@
|
||||
import XCTest
|
||||
@testable import KisaniCal
|
||||
|
||||
/// Verifies recurring-task occurrence expansion maps to the correct real calendar
|
||||
/// days (the calendar's event-date mapping for tasks). Uses Calendar.current — the
|
||||
/// same calendar the production code uses — so assertions hold in any timezone.
|
||||
@MainActor
|
||||
final class RecurrenceTests: XCTestCase {
|
||||
|
||||
private let cal = Calendar.current
|
||||
|
||||
private func day(_ y: Int, _ m: Int, _ d: Int, h: Int = 0, min: Int = 0) -> Date {
|
||||
cal.date(from: DateComponents(year: y, month: m, day: d, hour: h, minute: min))!
|
||||
}
|
||||
|
||||
private func vm(with tasks: [TaskItem]) -> TaskViewModel {
|
||||
let v = TaskViewModel()
|
||||
v.tasks = tasks
|
||||
return v
|
||||
}
|
||||
|
||||
private func task(_ label: String, start: Date, hasTime: Bool = false, end: Date? = nil) -> TaskItem {
|
||||
var t = TaskItem(title: "T-\(label)", dueDate: start)
|
||||
t.isRecurring = true
|
||||
t.recurrenceLabel = label
|
||||
t.hasTime = hasTime
|
||||
t.recurrenceEnd = end
|
||||
return t
|
||||
}
|
||||
|
||||
private func hasOccurrence(_ v: TaskViewModel, _ id: UUID, on date: Date) -> Bool {
|
||||
v.occurrences(on: date).contains { $0.id == id }
|
||||
}
|
||||
|
||||
func testDailyExpandsEveryDayFromStart() {
|
||||
let t = task("Daily", start: day(2026, 6, 1))
|
||||
let v = vm(with: [t])
|
||||
XCTAssertFalse(hasOccurrence(v, t.id, on: day(2026, 5, 31)), "no occurrence before start")
|
||||
XCTAssertTrue(hasOccurrence(v, t.id, on: day(2026, 6, 1)))
|
||||
XCTAssertTrue(hasOccurrence(v, t.id, on: day(2026, 6, 24)))
|
||||
}
|
||||
|
||||
func testWeeklyHitsEverySevenDaysOnly() {
|
||||
let t = task("Weekly", start: day(2026, 6, 1)) // Monday
|
||||
let v = vm(with: [t])
|
||||
XCTAssertTrue(hasOccurrence(v, t.id, on: day(2026, 6, 8)))
|
||||
XCTAssertTrue(hasOccurrence(v, t.id, on: day(2026, 6, 22)))
|
||||
XCTAssertFalse(hasOccurrence(v, t.id, on: day(2026, 6, 9)), "off-cadence day must not match")
|
||||
XCTAssertFalse(hasOccurrence(v, t.id, on: day(2026, 6, 24)))
|
||||
}
|
||||
|
||||
func testEveryWeekdaySkipsWeekend() {
|
||||
let t = task("Every Weekday", start: day(2026, 6, 1))
|
||||
let v = vm(with: [t])
|
||||
XCTAssertTrue(hasOccurrence(v, t.id, on: day(2026, 6, 24)), "Wed is a weekday")
|
||||
XCTAssertFalse(hasOccurrence(v, t.id, on: day(2026, 6, 27)), "Sat excluded")
|
||||
XCTAssertFalse(hasOccurrence(v, t.id, on: day(2026, 6, 28)), "Sun excluded")
|
||||
}
|
||||
|
||||
func testMonthlyMatchesDayOfMonth() {
|
||||
let t = task("Monthly", start: day(2026, 1, 15))
|
||||
let v = vm(with: [t])
|
||||
XCTAssertTrue(hasOccurrence(v, t.id, on: day(2026, 6, 15)))
|
||||
XCTAssertFalse(hasOccurrence(v, t.id, on: day(2026, 6, 16)))
|
||||
}
|
||||
|
||||
func testYearlyMatchesMonthAndDay() {
|
||||
let t = task("Yearly", start: day(2025, 6, 24))
|
||||
let v = vm(with: [t])
|
||||
XCTAssertTrue(hasOccurrence(v, t.id, on: day(2026, 6, 24)))
|
||||
XCTAssertFalse(hasOccurrence(v, t.id, on: day(2026, 6, 23)))
|
||||
}
|
||||
|
||||
func testRecurrenceEndStopsExpansion() {
|
||||
let t = task("Daily", start: day(2026, 6, 1), end: day(2026, 6, 10))
|
||||
let v = vm(with: [t])
|
||||
XCTAssertTrue(hasOccurrence(v, t.id, on: day(2026, 6, 10)), "inclusive of end day")
|
||||
XCTAssertFalse(hasOccurrence(v, t.id, on: day(2026, 6, 11)), "past repeat-until")
|
||||
XCTAssertFalse(hasOccurrence(v, t.id, on: day(2026, 6, 24)))
|
||||
}
|
||||
|
||||
func testOccurrenceCopyPreservesTimeOnTargetLocalDay() {
|
||||
let t = task("Daily", start: day(2026, 6, 1, h: 9, min: 30), hasTime: true)
|
||||
let v = vm(with: [t])
|
||||
let occ = v.occurrences(on: day(2026, 6, 24)).first { $0.id == t.id }
|
||||
XCTAssertNotNil(occ)
|
||||
XCTAssertTrue(cal.isDate(occ!.dueDate!, inSameDayAs: day(2026, 6, 24)), "mapped to June 24")
|
||||
XCTAssertEqual(cal.component(.hour, from: occ!.dueDate!), 9)
|
||||
XCTAssertEqual(cal.component(.minute, from: occ!.dueDate!), 30)
|
||||
}
|
||||
|
||||
func testNonRecurringTaskAppearsOnlyOnItsDay() {
|
||||
var t = TaskItem(title: "Dinner with Jay", dueDate: day(2026, 6, 24))
|
||||
t.isRecurring = false
|
||||
let v = vm(with: [t])
|
||||
XCTAssertTrue(hasOccurrence(v, t.id, on: day(2026, 6, 24)))
|
||||
XCTAssertFalse(hasOccurrence(v, t.id, on: day(2026, 6, 23)))
|
||||
XCTAssertFalse(hasOccurrence(v, t.id, on: day(2026, 6, 25)))
|
||||
}
|
||||
}
|
||||
122
KisaniCalTests/StreakLogicTests.swift
Normal file
@@ -0,0 +1,122 @@
|
||||
import XCTest
|
||||
@testable import KisaniCal
|
||||
|
||||
@MainActor
|
||||
final class StreakLogicTests: XCTestCase {
|
||||
|
||||
private let cal = Calendar.current
|
||||
private let fmt: DateFormatter = {
|
||||
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f
|
||||
}()
|
||||
|
||||
private func day(_ offset: Int, from ref: Date) -> String {
|
||||
fmt.string(from: cal.date(byAdding: .day, value: offset, to: ref)!)
|
||||
}
|
||||
|
||||
// MARK: - Workout tally (lifetime; only ever grows, never resets)
|
||||
|
||||
/// The field scenario: 6-day week, Thursday skipped, 5 workouts done →
|
||||
/// the count is 5, not 1. A missed day never reduces it.
|
||||
func testSkippedDayStillCountsAchieved() {
|
||||
let today = cal.startOfDay(for: Date())
|
||||
var dates: [String] = []
|
||||
guard let week = cal.dateInterval(of: .weekOfYear, for: today) else { return XCTFail() }
|
||||
var d = week.start, added = 0
|
||||
while d <= today {
|
||||
let key = fmt.string(from: d)
|
||||
if key != day(-2, from: today) { dates.append(key); added += 1 } // skip one day
|
||||
d = cal.date(byAdding: .day, value: 1, to: d)!
|
||||
}
|
||||
XCTAssertEqual(WorkoutViewModel.totalAchievedWorkoutDays(dates), added,
|
||||
"every logged day counts; the skipped day is simply absent")
|
||||
}
|
||||
|
||||
/// A below-goal week (4/5) and a full week (5/5) both contribute all their
|
||||
/// achieved days — 9 total, nothing lost to the 4/5 week "failing."
|
||||
func testBelowGoalAndFullWeekBothCountFully() {
|
||||
let today = cal.startOfDay(for: Date())
|
||||
guard let prevRef = cal.date(byAdding: .weekOfYear, value: -1, to: today),
|
||||
let prevWeek = cal.dateInterval(of: .weekOfYear, for: prevRef),
|
||||
let thisWeek = cal.dateInterval(of: .weekOfYear, for: today)
|
||||
else { return XCTFail() }
|
||||
|
||||
var dates: [String] = []
|
||||
var d = prevWeek.start
|
||||
for i in 0..<7 { if i < 4 { dates.append(fmt.string(from: d)) }; d = cal.date(byAdding: .day, value: 1, to: d)! } // 4/5
|
||||
d = thisWeek.start
|
||||
for i in 0..<7 { if i < 5 { dates.append(fmt.string(from: d)) }; d = cal.date(byAdding: .day, value: 1, to: d)! } // 5/5
|
||||
|
||||
XCTAssertEqual(WorkoutViewModel.totalAchievedWorkoutDays(dates), 9,
|
||||
"4/5 + 5/5 = 9; a below-goal week is never discarded")
|
||||
}
|
||||
|
||||
/// A completely blank week between two active stretches does not reset the
|
||||
/// tally — earlier achievements are preserved.
|
||||
func testBlankWeekDoesNotReset() {
|
||||
let today = cal.startOfDay(for: Date())
|
||||
guard let twoBack = cal.date(byAdding: .weekOfYear, value: -2, to: today),
|
||||
let oldWeek = cal.dateInterval(of: .weekOfYear, for: twoBack),
|
||||
let thisWeek = cal.dateInterval(of: .weekOfYear, for: today)
|
||||
else { return XCTFail() }
|
||||
|
||||
var dates: [String] = []
|
||||
var d = oldWeek.start
|
||||
for i in 0..<7 { if i < 3 { dates.append(fmt.string(from: d)) }; d = cal.date(byAdding: .day, value: 1, to: d)! }
|
||||
// (previous week: nothing — a blank week)
|
||||
dates.append(fmt.string(from: thisWeek.start))
|
||||
|
||||
XCTAssertEqual(WorkoutViewModel.totalAchievedWorkoutDays(dates), 4,
|
||||
"3 old + 1 now; the empty week in between changes nothing")
|
||||
}
|
||||
|
||||
/// Duplicate day entries (e.g. HealthKit merge) never double-count.
|
||||
func testDuplicatesCountOnce() {
|
||||
let key = fmt.string(from: cal.startOfDay(for: Date()))
|
||||
XCTAssertEqual(WorkoutViewModel.totalAchievedWorkoutDays([key, key, key]), 1)
|
||||
}
|
||||
|
||||
func testEmptyDataIsZero() {
|
||||
XCTAssertEqual(WorkoutViewModel.totalAchievedWorkoutDays([]), 0)
|
||||
}
|
||||
|
||||
// MARK: - Task completion counting (recurring occurrences)
|
||||
|
||||
private func makeRecurring(title: String, completedOn keys: [String]) -> TaskItem {
|
||||
var t = TaskItem(title: title, dueDate: cal.date(byAdding: .day, value: -30, to: Date()))
|
||||
t.isRecurring = true
|
||||
t.recurrenceLabel = "Daily"
|
||||
t.completedOccurrences = keys
|
||||
return t
|
||||
}
|
||||
|
||||
/// Recurring completions must count toward per-day stats even though the
|
||||
/// stored task's isComplete/completedAt stay untouched.
|
||||
func testRecurringOccurrencesCountedPerDay() {
|
||||
let today = cal.startOfDay(for: Date())
|
||||
let todayKey = fmt.string(from: today)
|
||||
let recurring = makeRecurring(title: "Morning routine", completedOn: [todayKey])
|
||||
|
||||
var oneOff = TaskItem(title: "Buy milk")
|
||||
oneOff.isComplete = true
|
||||
oneOff.completedAt = today.addingTimeInterval(3600)
|
||||
|
||||
let n = TaskViewModel.completionCount(in: [recurring, oneOff], on: today, calendar: cal)
|
||||
XCTAssertEqual(n, 2, "one recurring occurrence + one one-off completion")
|
||||
}
|
||||
|
||||
func testRecurringNotCountedOnOtherDays() {
|
||||
let today = cal.startOfDay(for: Date())
|
||||
let yesterdayKey = day(-1, from: today)
|
||||
let recurring = makeRecurring(title: "Morning routine", completedOn: [yesterdayKey])
|
||||
|
||||
XCTAssertEqual(TaskViewModel.completionCount(in: [recurring], on: today, calendar: cal), 0)
|
||||
let yesterday = cal.date(byAdding: .day, value: -1, to: today)!
|
||||
XCTAssertEqual(TaskViewModel.completionCount(in: [recurring], on: yesterday, calendar: cal), 1)
|
||||
}
|
||||
|
||||
func testIncompleteOneOffNotCounted() {
|
||||
let today = cal.startOfDay(for: Date())
|
||||
let t = TaskItem(title: "Open task")
|
||||
XCTAssertEqual(TaskViewModel.completionCount(in: [t], on: today, calendar: cal), 0)
|
||||
}
|
||||
}
|
||||
371
KisaniCalWidgets/EventCountdownWidget.swift
Normal file
@@ -0,0 +1,371 @@
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
import AppIntents
|
||||
|
||||
// MARK: - Configuration Intent
|
||||
//
|
||||
// Each widget instance stores its own event. The user sets a name, the day the
|
||||
// countdown starts ("Counting from" — defaults to the day they add the widget,
|
||||
// which acts as the creation anchor) and the event date. Because these values are
|
||||
// persisted per-instance by WidgetKit, the widget is fully self-contained and does
|
||||
// not depend on App Group data sharing.
|
||||
|
||||
// Which upcoming event to auto-track when the queue is on.
|
||||
enum AutoSelectRank: Int, AppEnum {
|
||||
case first = 1, second, third, fourth, fifth
|
||||
|
||||
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Auto Select"
|
||||
static var caseDisplayRepresentations: [AutoSelectRank: DisplayRepresentation] = [
|
||||
.first: "Nearest to finish",
|
||||
.second: "2nd nearest to finish",
|
||||
.third: "3rd nearest to finish",
|
||||
.fourth: "4th nearest to finish",
|
||||
.fifth: "5th nearest to finish",
|
||||
]
|
||||
}
|
||||
|
||||
struct EventConfigIntent: WidgetConfigurationIntent {
|
||||
static var title: LocalizedStringResource = "Event Countdown"
|
||||
static var description = IntentDescription("Count down to an event with a dot grid.")
|
||||
|
||||
// When on, the widget auto-tracks one of your upcoming events. When off, you
|
||||
// track a specific event you pick, or a fully custom name + date.
|
||||
@Parameter(title: "Upcoming queue", default: true)
|
||||
var upcomingQueue: Bool
|
||||
|
||||
@Parameter(title: "Auto select", default: .first)
|
||||
var autoSelect: AutoSelectRank
|
||||
|
||||
@Parameter(title: "Track event")
|
||||
var event: EventEntity?
|
||||
|
||||
@Parameter(title: "Or event name", default: "My Event")
|
||||
var eventName: String
|
||||
|
||||
// Optional so the rows read "Choose" until the user actually picks a date —
|
||||
// a same-second default for both made the bar span zero and looked broken.
|
||||
@Parameter(title: "Event date")
|
||||
var targetDate: Date?
|
||||
|
||||
@Parameter(title: "Counting from")
|
||||
var startDate: Date?
|
||||
|
||||
// Show only the relevant fields depending on the toggle — like a clean,
|
||||
// single-purpose config screen instead of every field at once.
|
||||
static var parameterSummary: some ParameterSummary {
|
||||
When(\.$upcomingQueue, .equalTo, true) {
|
||||
Summary("Track upcoming event") {
|
||||
\.$upcomingQueue
|
||||
\.$autoSelect
|
||||
}
|
||||
} otherwise: {
|
||||
Summary("Track a custom event") {
|
||||
\.$upcomingQueue
|
||||
\.$event
|
||||
\.$eventName
|
||||
\.$targetDate
|
||||
\.$startDate
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Event picker (reads your tasks from the App Group)
|
||||
|
||||
struct EventEntity: AppEntity {
|
||||
let id: String // task UUID string
|
||||
let title: String
|
||||
let dueDate: Date?
|
||||
var isRecurring: Bool = false
|
||||
var recurrenceLabel: String? = nil
|
||||
var recurrenceInterval: Int? = nil
|
||||
var createdAt: Date? = nil
|
||||
|
||||
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Event"
|
||||
static var defaultQuery = EventQuery()
|
||||
|
||||
var displayRepresentation: DisplayRepresentation {
|
||||
if let d = dueDate {
|
||||
let f = DateFormatter(); f.dateStyle = .medium
|
||||
return DisplayRepresentation(title: "\(title)", subtitle: "\(f.string(from: d))")
|
||||
}
|
||||
return DisplayRepresentation(title: "\(title)")
|
||||
}
|
||||
}
|
||||
|
||||
struct EventQuery: EntityQuery {
|
||||
func entities(for identifiers: [String]) async throws -> [EventEntity] {
|
||||
allEvents().filter { identifiers.contains($0.id) }
|
||||
}
|
||||
|
||||
func suggestedEntities() async throws -> [EventEntity] {
|
||||
allEvents()
|
||||
}
|
||||
|
||||
private func allEvents() -> [EventEntity] {
|
||||
loadWidgetTasks()
|
||||
.filter { !$0.isComplete && $0.dueDate != nil }
|
||||
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
|
||||
.map { EventEntity(id: $0.id.uuidString, title: $0.title, dueDate: $0.dueDate,
|
||||
isRecurring: $0.isRecurring, recurrenceLabel: $0.recurrenceLabel,
|
||||
recurrenceInterval: $0.recurrenceInterval,
|
||||
createdAt: $0.createdAt) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Entry
|
||||
|
||||
// Tapping the countdown label cycles this unit (shared by all event widgets).
|
||||
let countdownUnitKey = "kisani.widget.countdownUnit" // 0 = days, 1 = weeks, 2 = time
|
||||
|
||||
struct CycleCountdownUnitIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Change Countdown Unit"
|
||||
|
||||
func perform() async throws -> some IntentResult {
|
||||
let cur = UserDefaults.kisani.integer(forKey: countdownUnitKey)
|
||||
UserDefaults.kisani.set((cur + 1) % 3, forKey: countdownUnitKey)
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
struct EventEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let eventName: String
|
||||
let start: Date
|
||||
let target: Date
|
||||
var unitMode: Int = 0 // 0 days, 1 weeks, 2 time
|
||||
|
||||
/// Fraction of the countdown window that has elapsed (0…1).
|
||||
var progress: Double {
|
||||
let span = target.timeIntervalSince(start)
|
||||
guard span > 0 else { return target <= date ? 1 : 0 }
|
||||
return min(1, max(0, date.timeIntervalSince(start) / span))
|
||||
}
|
||||
|
||||
/// Whole days from start to target (the number of dots in the grid).
|
||||
var totalDays: Int {
|
||||
let cal = Calendar.current
|
||||
let d = cal.dateComponents([.day],
|
||||
from: cal.startOfDay(for: start),
|
||||
to: cal.startOfDay(for: target)).day ?? 0
|
||||
return max(1, d)
|
||||
}
|
||||
|
||||
/// Days remaining until the event (never negative).
|
||||
var daysLeft: Int {
|
||||
let cal = Calendar.current
|
||||
let d = cal.dateComponents([.day],
|
||||
from: cal.startOfDay(for: date),
|
||||
to: cal.startOfDay(for: target)).day ?? 0
|
||||
return max(0, d)
|
||||
}
|
||||
|
||||
/// Granular countdown for the bars widget: days → hr → min as it nears.
|
||||
var fineTimeLeft: String {
|
||||
if target <= date { return "Today" }
|
||||
let secs = target.timeIntervalSince(date)
|
||||
let days = Int(secs / 86400)
|
||||
let hrs = Int(secs.truncatingRemainder(dividingBy: 86400) / 3600)
|
||||
let mins = Int(secs.truncatingRemainder(dividingBy: 3600) / 60)
|
||||
if days >= 1 { return hrs > 0 ? "\(days)d, \(hrs) hr left" : "\(days) day\(days == 1 ? "" : "s") left" }
|
||||
if hrs >= 1 { return "\(hrs) hr, \(mins) min left" }
|
||||
return "\(mins) min left"
|
||||
}
|
||||
|
||||
/// Countdown from the current date. Tapping the label cycles the unit:
|
||||
/// days → weeks → time remaining.
|
||||
var timeLeftLabel: String {
|
||||
if target <= date { return "Today" }
|
||||
let cal = Calendar.current
|
||||
switch unitMode {
|
||||
case 1: // weeks
|
||||
let days = cal.dateComponents([.day], from: cal.startOfDay(for: date),
|
||||
to: cal.startOfDay(for: target)).day ?? 0
|
||||
if days < 7 { return "< 1 week left" }
|
||||
let weeks = days / 7
|
||||
return "\(weeks) week\(weeks == 1 ? "" : "s") left"
|
||||
case 2: // time remaining (detailed)
|
||||
let c = cal.dateComponents([.day, .hour], from: date, to: target)
|
||||
let days = c.day ?? 0, hours = c.hour ?? 0
|
||||
if days == 0 { return hours <= 0 ? "Due now" : "\(hours) hr left" }
|
||||
return hours > 0 ? "\(days)d, \(hours) hr left" : "\(days) day\(days == 1 ? "" : "s") left"
|
||||
default: // days
|
||||
let days = cal.dateComponents([.day], from: cal.startOfDay(for: date),
|
||||
to: cal.startOfDay(for: target)).day ?? 0
|
||||
return "\(days) day\(days == 1 ? "" : "s") left"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Provider
|
||||
|
||||
struct EventProvider: AppIntentTimelineProvider {
|
||||
func placeholder(in context: Context) -> EventEntry {
|
||||
// ~90-day window, ~60% elapsed → a clear, legible dot grid in the gallery.
|
||||
EventEntry(date: .now,
|
||||
eventName: "IELTS",
|
||||
start: Date().addingTimeInterval(-86400 * 54),
|
||||
target: Date().addingTimeInterval(86400 * 36))
|
||||
}
|
||||
|
||||
func snapshot(for configuration: EventConfigIntent, in context: Context) async -> EventEntry {
|
||||
entry(for: configuration)
|
||||
}
|
||||
|
||||
func timeline(for configuration: EventConfigIntent, in context: Context) async -> Timeline<EventEntry> {
|
||||
let now = Date()
|
||||
let step: TimeInterval = 1800 // 30 minutes
|
||||
let count = 24 // ~12-hour pre-render window
|
||||
// Re-resolve the selection at EACH step's own time, so the moment the nearest
|
||||
// event expires the next entry rolls to the next-nearest automatically.
|
||||
let entries: [EventEntry] = (0..<count).map { i in
|
||||
entry(for: configuration, asOf: now.addingTimeInterval(Double(i) * step))
|
||||
}
|
||||
// Refresh at the window end, or right when the current selection expires
|
||||
// (whichever is sooner) so the next event gets a fresh full pre-render.
|
||||
let windowEnd = now.addingTimeInterval(Double(count) * step)
|
||||
var next = windowEnd
|
||||
if let expiry = currentSelectionExpiry(for: configuration, asOf: now),
|
||||
expiry > now, expiry < windowEnd {
|
||||
next = expiry.addingTimeInterval(1)
|
||||
}
|
||||
return Timeline(entries: entries, policy: .after(next))
|
||||
}
|
||||
|
||||
/// Upcoming events (incomplete tasks with a due date still in the future *as of `now`*),
|
||||
/// nearest first. Passing `now` lets future timeline steps drop just-expired events.
|
||||
private func upcomingEvents(asOf now: Date) -> [EventEntity] {
|
||||
loadWidgetTasks()
|
||||
.filter { !$0.isComplete && ($0.dueDate ?? .distantPast) > now }
|
||||
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
|
||||
.map { EventEntity(id: $0.id.uuidString, title: $0.title, dueDate: $0.dueDate,
|
||||
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
|
||||
/// completed, and either recurring or with a deadline still ahead of `now`.
|
||||
/// Returns nil once it's done/expired so the widget advances to the next event.
|
||||
private func trackedTask(asOf now: Date) -> WidgetTask? {
|
||||
guard let trackedId = UserDefaults.kisani.string(forKey: "kisani.widget.trackedEvent"),
|
||||
let task = loadWidgetTasks().first(where: { $0.id.uuidString == trackedId }),
|
||||
let due = task.dueDate,
|
||||
!task.isComplete,
|
||||
task.isRecurring || due > now
|
||||
else { return nil }
|
||||
return task
|
||||
}
|
||||
|
||||
/// Due date the currently-shown event expires at, so the timeline can refresh
|
||||
/// the moment it's done. Covers both the tracked event and the auto-select queue.
|
||||
private func currentSelectionExpiry(for config: EventConfigIntent, asOf now: Date) -> Date? {
|
||||
if let task = trackedTask(asOf: now) {
|
||||
return task.isRecurring ? nil : task.dueDate
|
||||
}
|
||||
guard config.upcomingQueue else { return nil }
|
||||
let upcoming = upcomingEvents(asOf: now)
|
||||
let idx = max(0, config.autoSelect.rawValue - 1)
|
||||
guard idx < upcoming.count else { return nil }
|
||||
return upcoming[idx].dueDate
|
||||
}
|
||||
|
||||
/// Resolve the progress window (start → target) for any event. Recurring events
|
||||
/// (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
|
||||
/// from an explicit start, the task's real creation date, the tracking date,
|
||||
/// or first-seen, up to the due date — in that priority order.
|
||||
private func resolveSpan(due: Date, isRecurring: Bool, label: String?, interval: Int? = nil,
|
||||
anchorKey: String, explicitStart: Date?,
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
private func entry(for config: EventConfigIntent, asOf now: Date = Date()) -> EventEntry {
|
||||
let mode = UserDefaults.kisani.integer(forKey: countdownUnitKey)
|
||||
|
||||
// An event tracked from the app ("Track Countdown") bypasses the widget
|
||||
// configuration entirely. Once it's completed or its deadline has passed
|
||||
// (and it isn't recurring), stop pinning it and fall through to the
|
||||
// next-nearest event below — so the widget always advances.
|
||||
if let task = trackedTask(asOf: now) {
|
||||
let trackedId = task.id.uuidString
|
||||
let since = UserDefaults.kisani.object(forKey: "kisani.widget.trackedSince") as? Date
|
||||
let s = resolveSpan(due: task.dueDate ?? now, isRecurring: task.isRecurring,
|
||||
label: task.recurrenceLabel, interval: task.recurrenceInterval,
|
||||
anchorKey: trackedId, explicitStart: since,
|
||||
taskCreatedAt: task.createdAt)
|
||||
return EventEntry(date: now, eventName: task.title, start: s.start, target: s.target, unitMode: mode)
|
||||
}
|
||||
|
||||
// ── Configured (no tracked event) ──
|
||||
if config.upcomingQueue {
|
||||
let upcoming = upcomingEvents(asOf: now)
|
||||
let idx = max(0, config.autoSelect.rawValue - 1)
|
||||
guard idx < upcoming.count, let due = upcoming[idx].dueDate else {
|
||||
return EventEntry(date: now, eventName: "No upcoming event",
|
||||
start: now, target: now.addingTimeInterval(86400), unitMode: mode)
|
||||
}
|
||||
let ev = upcoming[idx]
|
||||
let s = resolveSpan(due: due, isRecurring: ev.isRecurring, label: ev.recurrenceLabel,
|
||||
interval: ev.recurrenceInterval,
|
||||
anchorKey: ev.id, explicitStart: nil, taskCreatedAt: ev.createdAt)
|
||||
return EventEntry(date: now, eventName: ev.title, start: s.start, target: s.target, unitMode: mode)
|
||||
}
|
||||
if let ev = config.event, let due = ev.dueDate {
|
||||
let s = resolveSpan(due: due, isRecurring: ev.isRecurring, label: ev.recurrenceLabel,
|
||||
interval: ev.recurrenceInterval,
|
||||
anchorKey: ev.id, explicitStart: config.startDate,
|
||||
taskCreatedAt: ev.createdAt)
|
||||
return EventEntry(date: now, eventName: ev.title, start: s.start, target: s.target, unitMode: mode)
|
||||
}
|
||||
// Fully custom event (no task behind it).
|
||||
let name = config.eventName.isEmpty ? "My Event" : config.eventName
|
||||
let target = config.targetDate
|
||||
?? Calendar.current.startOfDay(for: now).addingTimeInterval(2 * 86400)
|
||||
let anchorKey = "custom.\(name).\(Int(target.timeIntervalSince1970))"
|
||||
let start = (config.startDate.map { $0 < target ? $0 : nil } ?? nil)
|
||||
?? Self.storedAnchor(key: anchorKey, target: target)
|
||||
return EventEntry(date: now, eventName: name, start: start, target: target, unitMode: mode)
|
||||
}
|
||||
|
||||
/// First-seen date for a tracked event, persisted in the App Group so progress
|
||||
/// is stable across refreshes (and resets naturally when the event changes).
|
||||
private static func storedAnchor(key: String, target: Date) -> Date {
|
||||
let ud = UserDefaults.kisani
|
||||
let k = "kisani.widget.anchor.\(key)"
|
||||
if let saved = ud.object(forKey: k) as? Date, saved < target { return saved }
|
||||
let now = Date()
|
||||
ud.set(now, forKey: k)
|
||||
return now
|
||||
}
|
||||
|
||||
/// Mode B window for a recurring event: the occurrence span containing "now"
|
||||
/// (previous occurrence → next occurrence), derived from the rule label.
|
||||
/// The actual math lives in RecurrenceRule (Shared/) — shared with the main
|
||||
/// app's occurrence matching and the other countdown widget's copy of this
|
||||
/// same calculation, so none of the three can independently drift.
|
||||
private static func recurrenceSpan(label: String, interval: Int = 1, due: Date) -> (prev: Date, next: Date)? {
|
||||
RecurrenceRule.span(label: label, interval: interval, due: due, now: Date())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Event Countdown (Bars)
|
||||
|
||||
struct EventBarsWidget: Widget {
|
||||
let kind = "KisaniEventBars"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
AppIntentConfiguration(kind: kind, intent: EventConfigIntent.self, provider: EventProvider()) { entry in
|
||||
EventBarsView(entry: entry)
|
||||
}
|
||||
.configurationDisplayName("Event Countdown (Bars)")
|
||||
.description("Count down to an event with a bar meter.")
|
||||
.supportedFamilies([.systemMedium])
|
||||
}
|
||||
}
|
||||
29
KisaniCalWidgets/Info.plist
Normal file
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Wenza Widgets</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>XPC!</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.widgetkit-extension</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
10
KisaniCalWidgets/KisaniCalWidgets.entitlements
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.kutesir.KisaniCal</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
304
KisaniCalWidgets/KisaniCalWidgets.swift
Normal file
@@ -0,0 +1,304 @@
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
import AppIntents
|
||||
|
||||
// MARK: - Shared Entry
|
||||
|
||||
struct KisaniEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let task: WidgetTask?
|
||||
let allTasks: [WidgetTask]
|
||||
let streak: Int
|
||||
}
|
||||
|
||||
// MARK: - Shared Provider
|
||||
|
||||
struct KisaniProvider: TimelineProvider {
|
||||
func placeholder(in context: Context) -> KisaniEntry {
|
||||
let sample = WidgetTask(id: UUID(), title: "Mums Birthday", dueDate: Date().addingTimeInterval(86400 * 60),
|
||||
isComplete: false, quadrant: "Urgent + Important", category: "birthday")
|
||||
return KisaniEntry(date: .now, task: sample, allTasks: [sample], streak: 7)
|
||||
}
|
||||
|
||||
func getSnapshot(in context: Context, completion: @escaping (KisaniEntry) -> Void) {
|
||||
let tasks = loadWidgetTasks()
|
||||
completion(KisaniEntry(date: .now, task: nextDueTask(from: tasks), allTasks: tasks, streak: loadWorkoutStreak()))
|
||||
}
|
||||
|
||||
func getTimeline(in context: Context, completion: @escaping (Timeline<KisaniEntry>) -> Void) {
|
||||
let tasks = loadWidgetTasks()
|
||||
let entry = KisaniEntry(date: .now, task: nextDueTask(from: tasks), allTasks: tasks, streak: loadWorkoutStreak())
|
||||
// Refresh every 30 minutes or when app triggers WidgetCenter.reloadAllTimelines()
|
||||
let next = Calendar.current.date(byAdding: .minute, value: 30, to: .now)!
|
||||
completion(Timeline(entries: [entry], policy: .after(next)))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Day Progress Widget ("Day done %")
|
||||
|
||||
enum ProgressDotMode: String, AppEnum {
|
||||
case day
|
||||
case week
|
||||
case month
|
||||
case year
|
||||
case customEvent
|
||||
|
||||
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Progress Mode"
|
||||
static var caseDisplayRepresentations: [ProgressDotMode: DisplayRepresentation] = [
|
||||
.day: "This Day",
|
||||
.week: "This Week",
|
||||
.month: "This Month",
|
||||
.year: "This Year",
|
||||
.customEvent: "Custom Event",
|
||||
]
|
||||
}
|
||||
|
||||
struct ProgressDotConfigIntent: WidgetConfigurationIntent {
|
||||
static var title: LocalizedStringResource = "Progress Dots"
|
||||
static var description = IntentDescription("Track day, week, month, or a Wenza event.")
|
||||
|
||||
@Parameter(title: "Mode", default: .month)
|
||||
var mode: ProgressDotMode
|
||||
|
||||
@Parameter(title: "Event")
|
||||
var event: EventEntity?
|
||||
|
||||
static var parameterSummary: some ParameterSummary {
|
||||
When(\ProgressDotConfigIntent.$mode, .equalTo, ProgressDotMode.customEvent) {
|
||||
Summary("Show \(\.$mode) for \(\.$event)")
|
||||
} otherwise: {
|
||||
Summary("Show \(\.$mode)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ProgressDotEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let title: String
|
||||
let progress: Double
|
||||
let totalDots: Int
|
||||
}
|
||||
|
||||
struct ProgressDotProvider: AppIntentTimelineProvider {
|
||||
func placeholder(in context: Context) -> ProgressDotEntry {
|
||||
ProgressDotEntry(date: .now, title: Self.dayTitle(for: .now), progress: 0.06, totalDots: 100)
|
||||
}
|
||||
|
||||
func snapshot(for configuration: ProgressDotConfigIntent, in context: Context) async -> ProgressDotEntry {
|
||||
entry(for: configuration, at: .now)
|
||||
}
|
||||
|
||||
func timeline(for configuration: ProgressDotConfigIntent, in context: Context) async -> Timeline<ProgressDotEntry> {
|
||||
let interval: TimeInterval = 5 * 60
|
||||
let entries = (0..<72).map { step in
|
||||
entry(for: configuration, at: Date().addingTimeInterval(Double(step) * interval))
|
||||
}
|
||||
return Timeline(entries: entries, policy: .after(Date().addingTimeInterval(Double(entries.count) * interval)))
|
||||
}
|
||||
|
||||
private func entry(for configuration: ProgressDotConfigIntent, at date: Date) -> ProgressDotEntry {
|
||||
switch configuration.mode {
|
||||
case .day:
|
||||
let start = Calendar.current.startOfDay(for: date)
|
||||
let end = Calendar.current.date(byAdding: .day, value: 1, to: start) ?? date
|
||||
return ProgressDotEntry(date: date, title: Self.dayTitle(for: date),
|
||||
progress: Self.progress(now: date, start: start, end: end),
|
||||
totalDots: 100)
|
||||
case .week:
|
||||
let start = Self.startOfISOWeek(containing: date)
|
||||
let end = Calendar(identifier: .iso8601).date(byAdding: .weekOfYear, value: 1, to: start) ?? date
|
||||
return ProgressDotEntry(date: date, title: Self.weekTitle(for: start),
|
||||
progress: Self.progress(now: date, start: start, end: end),
|
||||
totalDots: 100)
|
||||
case .month:
|
||||
let interval = Calendar.current.dateInterval(of: .month, for: date)
|
||||
let start = interval?.start ?? date
|
||||
let end = interval?.end ?? date
|
||||
return ProgressDotEntry(date: date, title: Self.monthTitle(for: date),
|
||||
progress: Self.progress(now: date, start: start, end: end),
|
||||
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:
|
||||
guard let event = configuration.event,
|
||||
let task = loadWidgetTasks().first(where: { $0.id.uuidString == event.id }),
|
||||
let due = task.dueDate
|
||||
else {
|
||||
return ProgressDotEntry(date: date, title: "Select Event", progress: 0, totalDots: 100)
|
||||
}
|
||||
|
||||
let start = Self.eventStartDate(for: task, due: due, now: date)
|
||||
let computedProgress = Self.progress(now: date, start: start, end: due)
|
||||
let storedProgress = task.progress.map(Self.clamp) ?? 0
|
||||
return ProgressDotEntry(date: date, title: task.title,
|
||||
progress: max(storedProgress, computedProgress),
|
||||
totalDots: 100)
|
||||
}
|
||||
}
|
||||
|
||||
private static func progress(now: Date, start: Date, end: Date) -> Double {
|
||||
let total = end.timeIntervalSince(start)
|
||||
guard total > 0 else { return end <= now ? 1 : 0 }
|
||||
return clamp(now.timeIntervalSince(start) / total)
|
||||
}
|
||||
|
||||
private static func clamp(_ value: Double) -> Double {
|
||||
min(1, max(0, value))
|
||||
}
|
||||
|
||||
private static func eventStartDate(for task: WidgetTask, due: Date, now: Date) -> Date {
|
||||
if let span = recurrenceSpan(for: task, due: due, now: now) {
|
||||
return span.prev
|
||||
}
|
||||
|
||||
let start = [task.createdAt, task.reminderDate]
|
||||
.compactMap { $0 }
|
||||
.filter { $0 < due }
|
||||
.min()
|
||||
|
||||
let stored = storedAnchor(key: task.id.uuidString, target: due, now: now)
|
||||
return bestStartDate(explicit: start, stored: stored, target: due, now: now)
|
||||
}
|
||||
|
||||
private static func storedAnchor(key: String, target: Date, now: Date) -> Date {
|
||||
let userDefaults = UserDefaults.kisani
|
||||
let key = "kisani.widget.progressDots.anchor.\(key)"
|
||||
if let saved = userDefaults.object(forKey: key) as? Date, saved < target {
|
||||
return saved
|
||||
}
|
||||
userDefaults.set(now, forKey: key)
|
||||
return now
|
||||
}
|
||||
|
||||
private static func bestStartDate(explicit: Date?, stored: Date, target: Date, now: Date) -> Date {
|
||||
let candidate = explicit ?? stored
|
||||
guard candidate < target else { return inferredLegacyStart(target: target, now: now) }
|
||||
|
||||
let remaining = target.timeIntervalSince(now)
|
||||
let elapsed = now.timeIntervalSince(candidate)
|
||||
let total = target.timeIntervalSince(candidate)
|
||||
let progress = total > 0 ? elapsed / total : 0
|
||||
let looksLikeUpgradeAnchor = remaining > 14 * 86400
|
||||
&& elapsed >= 0
|
||||
&& elapsed < 3 * 86400
|
||||
&& progress < (1.0 / 100.0)
|
||||
|
||||
return looksLikeUpgradeAnchor ? inferredLegacyStart(target: target, now: now) : candidate
|
||||
}
|
||||
|
||||
private static func inferredLegacyStart(target: Date, now: Date) -> Date {
|
||||
let remainingDays = max(1, Int(ceil(target.timeIntervalSince(now) / 86400)))
|
||||
let totalDays = min(365, max(30, remainingDays * 2))
|
||||
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)? {
|
||||
let category = task.category.lowercased()
|
||||
if category == "birthday" || category == "annual" {
|
||||
return RecurrenceRule.span(label: "Yearly", due: due, now: now)
|
||||
}
|
||||
guard task.isRecurring, let label = task.recurrenceLabel else { return nil }
|
||||
return RecurrenceRule.span(label: label, interval: task.recurrenceInterval ?? 1, due: due, now: now)
|
||||
}
|
||||
|
||||
private static func startOfISOWeek(containing date: Date) -> Date {
|
||||
let cal = Calendar(identifier: .iso8601)
|
||||
return cal.dateInterval(of: .weekOfYear, for: date)?.start ?? cal.startOfDay(for: date)
|
||||
}
|
||||
|
||||
private static func dayTitle(for date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "EEEE d"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
private static func weekTitle(for start: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMM d"
|
||||
return "Week of \(formatter.string(from: start))"
|
||||
}
|
||||
|
||||
private static func monthTitle(for date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMMM yyyy"
|
||||
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 {
|
||||
let kind = "KisaniDayProgress"
|
||||
var body: some WidgetConfiguration {
|
||||
AppIntentConfiguration(kind: kind, intent: ProgressDotConfigIntent.self, provider: ProgressDotProvider()) { entry in
|
||||
ProgressDotView(entry: entry, accent: WidgetTheme.accent)
|
||||
}
|
||||
.configurationDisplayName("Progress Dots")
|
||||
.description("Track day, week, month, or a selected event as a dot grid.")
|
||||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .accessoryRectangular])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Lock Screen Widgets
|
||||
|
||||
struct LockScreenWidget: Widget {
|
||||
let kind = "KisaniLockScreen"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: kind, provider: KisaniProvider()) { entry in
|
||||
if let task = entry.task {
|
||||
LockScreenRectView(task: task)
|
||||
} else {
|
||||
LockScreenRectView(task: WidgetTask(id: UUID(), title: "No tasks",
|
||||
dueDate: nil, isComplete: false, quadrant: "Urgent + Important", category: "reminder"))
|
||||
}
|
||||
}
|
||||
.configurationDisplayName("Task (Lock Screen)")
|
||||
.description("Glass countdown on your lock screen.")
|
||||
.supportedFamilies([.accessoryRectangular])
|
||||
}
|
||||
}
|
||||
|
||||
struct LockScreenCircularWidget: Widget {
|
||||
let kind = "KisaniLockCircle"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: kind, provider: KisaniProvider()) { entry in
|
||||
if let task = entry.task {
|
||||
LockScreenCircularView(task: task)
|
||||
} else {
|
||||
LockScreenCircularView(task: WidgetTask(id: UUID(), title: "–",
|
||||
dueDate: nil, isComplete: false, quadrant: "Urgent + Important", category: "reminder"))
|
||||
}
|
||||
}
|
||||
.configurationDisplayName("Days Left (Lock Screen)")
|
||||
.description("Circular countdown on your lock screen.")
|
||||
.supportedFamilies([.accessoryCircular])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Widget Bundle
|
||||
|
||||
@main
|
||||
struct KisaniWidgetBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
EventBarsWidget()
|
||||
MyTasksWidget()
|
||||
DayProgressWidget()
|
||||
LockScreenWidget()
|
||||
LockScreenCircularWidget()
|
||||
TaskLiveActivity()
|
||||
}
|
||||
}
|
||||
148
KisaniCalWidgets/MyTasksWidget.swift
Normal file
@@ -0,0 +1,148 @@
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
import AppIntents
|
||||
|
||||
// MARK: - Interactive intents
|
||||
|
||||
/// Tapping a checkbox toggles the task's completion directly in the App Group.
|
||||
struct ToggleTaskIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Toggle Task Complete"
|
||||
|
||||
@Parameter(title: "Task ID")
|
||||
var taskId: String
|
||||
|
||||
init() {}
|
||||
init(taskId: String) { self.taskId = taskId }
|
||||
|
||||
func perform() async throws -> some IntentResult {
|
||||
toggleWidgetTaskComplete(id: taskId)
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
/// The "+" button opens the app and asks it to present the add-task sheet.
|
||||
struct AddTaskFromWidgetIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Add Task"
|
||||
static var openAppWhenRun = true
|
||||
|
||||
func perform() async throws -> some IntentResult {
|
||||
UserDefaults.kisani.set(true, forKey: "kisani.widget.openAddTask")
|
||||
return .result()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Entry + Provider
|
||||
|
||||
struct TasksEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let tasks: [WidgetTask]
|
||||
let openCount: Int
|
||||
}
|
||||
|
||||
struct TasksProvider: TimelineProvider {
|
||||
func placeholder(in context: Context) -> TasksEntry {
|
||||
TasksEntry(date: .now, tasks: [
|
||||
WidgetTask(id: UUID(), title: "Pray for Uganda", dueDate: .now, isComplete: false,
|
||||
quadrant: "Urgent + Important", category: "reminder"),
|
||||
WidgetTask(id: UUID(), title: "Masters of the Universe", dueDate: .now, isComplete: false,
|
||||
quadrant: "Schedule It", category: "reminder")
|
||||
], openCount: 2)
|
||||
}
|
||||
|
||||
func getSnapshot(in context: Context, completion: @escaping (TasksEntry) -> Void) {
|
||||
completion(loadEntry())
|
||||
}
|
||||
|
||||
func getTimeline(in context: Context, completion: @escaping (Timeline<TasksEntry>) -> Void) {
|
||||
let next = Calendar.current.startOfDay(for: Date().addingTimeInterval(86400))
|
||||
completion(Timeline(entries: [loadEntry()], policy: .after(next)))
|
||||
}
|
||||
|
||||
private func loadEntry() -> TasksEntry {
|
||||
let tasks = todayWidgetTasks()
|
||||
return TasksEntry(date: .now, tasks: tasks, openCount: tasks.filter { !$0.isComplete }.count)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Widget
|
||||
|
||||
struct MyTasksWidget: Widget {
|
||||
let kind = "KisaniMyTasks"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: kind, provider: TasksProvider()) { entry in
|
||||
MyTasksView(entry: entry)
|
||||
}
|
||||
.configurationDisplayName("My Tasks")
|
||||
.description("Today's tasks — check them off right from the widget.")
|
||||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
|
||||
}
|
||||
}
|
||||
|
||||
struct MyTasksView: View {
|
||||
let entry: TasksEntry
|
||||
@Environment(\.widgetFamily) var family
|
||||
|
||||
private var maxRows: Int {
|
||||
switch family {
|
||||
case .systemLarge: return 9
|
||||
case .systemMedium: return 4
|
||||
default: return 3
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 6) {
|
||||
Text("Today")
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.foregroundColor(WidgetTheme.accent)
|
||||
Text("\(entry.openCount)")
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.foregroundColor(Color(white: 0.5))
|
||||
Spacer()
|
||||
Button(intent: AddTaskFromWidgetIntent()) {
|
||||
Image(systemName: "plus")
|
||||
.font(.system(size: 16))
|
||||
.foregroundColor(WidgetTheme.accent)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
if entry.tasks.isEmpty {
|
||||
Spacer()
|
||||
Text("All clear for today")
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundColor(Color(white: 0.5))
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
Spacer()
|
||||
} else {
|
||||
ForEach(entry.tasks.prefix(maxRows)) { task in
|
||||
HStack(spacing: 10) {
|
||||
Button(intent: ToggleTaskIntent(taskId: task.id.uuidString)) {
|
||||
Image(systemName: task.isComplete ? "checkmark.circle.fill" : "circle")
|
||||
.font(.system(size: 18))
|
||||
.foregroundColor(task.isComplete ? task.accentColor : Color(white: 0.45))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Text(task.title)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundColor(task.isComplete ? Color(white: 0.45) : .white)
|
||||
.strikethrough(task.isComplete, color: Color(white: 0.45))
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
if entry.tasks.count > maxRows {
|
||||
Text("+\(entry.tasks.count - maxRows) more")
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundColor(Color(white: 0.4))
|
||||
.padding(.leading, 28)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
.containerBackground(WidgetTheme.background, for: .widget)
|
||||
}
|
||||
}
|
||||
26
KisaniCalWidgets/StopCountdownIntent.swift
Normal file
@@ -0,0 +1,26 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
111
KisaniCalWidgets/TaskLiveActivity.swift
Normal file
@@ -0,0 +1,111 @@
|
||||
import ActivityKit
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
@available(iOS 16.1, *)
|
||||
struct TaskLiveActivity: Widget {
|
||||
private let accent = WidgetTheme.accent // brand orange
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
ActivityConfiguration(for: TaskActivityAttributes.self) { context in
|
||||
// ── Lock screen / banner ──
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: context.state.isComplete ? "checkmark.circle.fill" : "hourglass")
|
||||
.font(.system(size: 20))
|
||||
.foregroundColor(context.state.isComplete ? .green : accent)
|
||||
.frame(width: 24)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(context.attributes.title)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
.strikethrough(context.state.isComplete)
|
||||
if let due = context.state.dueDate {
|
||||
Text(due, style: context.state.isComplete ? .date : .relative)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.6))
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
.layoutPriority(1)
|
||||
|
||||
if let due = context.state.dueDate, !context.state.isComplete {
|
||||
Text(due, style: .timer)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.monospacedDigit()
|
||||
.foregroundColor(accent)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.72)
|
||||
.frame(width: 92, alignment: .trailing)
|
||||
.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(.trailing, 10)
|
||||
.padding(.vertical, 12)
|
||||
.activityBackgroundTint(WidgetTheme.background.opacity(0.9))
|
||||
.activitySystemActionForegroundColor(.white)
|
||||
|
||||
} dynamicIsland: { context in
|
||||
DynamicIsland {
|
||||
DynamicIslandExpandedRegion(.leading) {
|
||||
Image(systemName: "checklist")
|
||||
.foregroundColor(accent)
|
||||
}
|
||||
DynamicIslandExpandedRegion(.trailing) {
|
||||
if let due = context.state.dueDate, !context.state.isComplete {
|
||||
Text(due, style: .timer)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.monospacedDigit()
|
||||
.foregroundColor(accent)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
.frame(width: 86, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
DynamicIslandExpandedRegion(.center) {
|
||||
Text(context.attributes.title)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.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: {
|
||||
Image(systemName: "checklist").foregroundColor(accent)
|
||||
} compactTrailing: {
|
||||
if let due = context.state.dueDate, !context.state.isComplete {
|
||||
Text(due, style: .timer)
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.monospacedDigit()
|
||||
.foregroundColor(accent)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
.frame(width: 48, alignment: .trailing)
|
||||
}
|
||||
} minimal: {
|
||||
Image(systemName: "checklist").foregroundColor(accent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
177
KisaniCalWidgets/WidgetData.swift
Normal file
@@ -0,0 +1,177 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
// Minimal task model shared between widget and main app via App Group
|
||||
struct WidgetTask: Codable, Identifiable {
|
||||
let id: UUID
|
||||
let title: String
|
||||
let dueDate: Date?
|
||||
let isComplete: Bool
|
||||
let quadrant: String // raw value of Quadrant enum
|
||||
let category: String
|
||||
var isRecurring: Bool = false
|
||||
var recurrenceLabel: String? = nil
|
||||
var recurrenceInterval: Int? = nil
|
||||
var createdAt: Date? = nil
|
||||
var reminderDate: Date? = nil
|
||||
var progress: Double? = nil
|
||||
|
||||
// Days remaining until due date
|
||||
var daysLeft: Int? {
|
||||
guard let due = dueDate else { return nil }
|
||||
let d = Calendar.current.dateComponents([.day], from: Calendar.current.startOfDay(for: .now), to: due).day ?? 0
|
||||
return max(0, d)
|
||||
}
|
||||
|
||||
// Hours remaining (for sub-day precision)
|
||||
var hoursLeft: Int? {
|
||||
guard let due = dueDate else { return nil }
|
||||
let h = Calendar.current.dateComponents([.hour], from: .now, to: due).hour ?? 0
|
||||
return max(0, h)
|
||||
}
|
||||
|
||||
var timeLeftLabel: String {
|
||||
guard dueDate != nil else { return "" }
|
||||
if isComplete { return "Done" }
|
||||
let days = daysLeft ?? 0
|
||||
if days == 0 {
|
||||
let hrs = hoursLeft ?? 0
|
||||
return hrs <= 0 ? "Due now" : "\(hrs)hr left"
|
||||
}
|
||||
if days < 30 { return "\(days)d left" }
|
||||
let months = days / 30
|
||||
let remDays = days % 30
|
||||
if remDays == 0 { return "\(months)mth left" }
|
||||
return "\(months)mth \(remDays)d..."
|
||||
}
|
||||
|
||||
var accentColor: Color {
|
||||
switch quadrant {
|
||||
case "Urgent + Important": return Color(red: 0.96, green: 0.42, blue: 0.20)
|
||||
case "Schedule It": return Color(red: 0.95, green: 0.78, blue: 0.10)
|
||||
case "Delegate": return Color(red: 0.24, green: 0.58, blue: 0.95)
|
||||
case "Eliminate": return Color(red: 0.20, green: 0.78, blue: 0.50)
|
||||
default: return Color(red: 0.96, green: 0.42, blue: 0.20)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load tasks from the shared App Group UserDefaults
|
||||
func loadWidgetTasks() -> [WidgetTask] {
|
||||
let uid = UserDefaults.kisani.string(forKey: "kisani.activeUserId") ?? "shared"
|
||||
let key = "kisani.tasks.v2.\(uid)"
|
||||
guard let data = UserDefaults.kisani.data(forKey: key),
|
||||
let raw = try? JSONDecoder().decode([RawTask].self, from: data)
|
||||
else { return [] }
|
||||
return raw.map {
|
||||
WidgetTask(id: $0.id, title: $0.title, dueDate: $0.dueDate,
|
||||
isComplete: $0.isComplete, quadrant: $0.quadrant, category: $0.category,
|
||||
isRecurring: $0.isRecurring ?? false, recurrenceLabel: $0.recurrenceLabel,
|
||||
recurrenceInterval: $0.recurrenceInterval,
|
||||
createdAt: $0.createdAt, reminderDate: $0.reminderDate,
|
||||
progress: $0.countdownProgress ?? $0.progress)
|
||||
}
|
||||
}
|
||||
|
||||
// Partial decode of TaskItem — only the fields widgets need
|
||||
private struct RawTask: Codable {
|
||||
let id: UUID
|
||||
let title: String
|
||||
let dueDate: Date?
|
||||
var isComplete: Bool = false
|
||||
var quadrant: String = "Urgent + Important"
|
||||
var category: String = "reminder"
|
||||
var isRecurring: Bool? = nil
|
||||
var recurrenceLabel: String? = nil
|
||||
var recurrenceInterval: Int? = nil
|
||||
var createdAt: Date? = nil
|
||||
var reminderDate: Date? = nil
|
||||
var progress: Double? = nil
|
||||
var countdownProgress: Double? = nil
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, title, dueDate, isComplete, quadrant, category, isRecurring, recurrenceLabel
|
||||
case recurrenceInterval, createdAt, reminderDate, progress, countdownProgress
|
||||
}
|
||||
}
|
||||
|
||||
// Next upcoming task with a due date (for default widget display)
|
||||
func nextDueTask(from tasks: [WidgetTask]) -> WidgetTask? {
|
||||
tasks
|
||||
.filter { !$0.isComplete && $0.dueDate != nil }
|
||||
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
|
||||
.first
|
||||
}
|
||||
|
||||
private func activeTasksKey() -> String {
|
||||
let uid = UserDefaults.kisani.string(forKey: "kisani.activeUserId") ?? "shared"
|
||||
return "kisani.tasks.v2.\(uid)"
|
||||
}
|
||||
|
||||
// Tasks due today (incomplete first, then by due time) for the interactive list widget.
|
||||
func todayWidgetTasks() -> [WidgetTask] {
|
||||
let cal = Calendar.current
|
||||
let start = cal.startOfDay(for: Date())
|
||||
let end = cal.date(byAdding: .day, value: 1, to: start)!
|
||||
return loadWidgetTasks()
|
||||
.filter { t in
|
||||
guard let due = t.dueDate else { return false }
|
||||
return due >= start && due < end
|
||||
}
|
||||
.sorted {
|
||||
if $0.isComplete != $1.isComplete { return !$0.isComplete }
|
||||
return ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture)
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle a task's completion from the widget. Uses JSONSerialization so ALL task
|
||||
// fields are preserved on write (the widget only models a subset of TaskItem).
|
||||
func toggleWidgetTaskComplete(id: String) {
|
||||
let key = activeTasksKey()
|
||||
guard let data = UserDefaults.kisani.data(forKey: key),
|
||||
var arr = (try? JSONSerialization.jsonObject(with: data)) as? [[String: Any]]
|
||||
else { return }
|
||||
for i in arr.indices where (arr[i]["id"] as? String) == id {
|
||||
let done = (arr[i]["isComplete"] as? Bool) ?? false
|
||||
arr[i]["isComplete"] = !done
|
||||
if !done {
|
||||
// TaskItem uses the default (deferredToDate) Date strategy → seconds since reference date.
|
||||
arr[i]["completedAt"] = Date().timeIntervalSinceReferenceDate
|
||||
} else {
|
||||
arr[i].removeValue(forKey: "completedAt")
|
||||
}
|
||||
}
|
||||
if let out = try? JSONSerialization.data(withJSONObject: arr) {
|
||||
UserDefaults.kisani.set(out, forKey: key)
|
||||
CloudSyncManager_widgetPush(out, key)
|
||||
}
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
|
||||
// The widget extension can't see CloudSyncManager; mirror to iCloud KV store directly.
|
||||
private func CloudSyncManager_widgetPush(_ data: Data, _ key: String) {
|
||||
guard data.count < 900_000 else { return }
|
||||
let kv = NSUbiquitousKeyValueStore.default
|
||||
kv.set(data, forKey: key)
|
||||
kv.synchronize()
|
||||
}
|
||||
|
||||
// Workout streak count
|
||||
func loadWorkoutStreak() -> Int {
|
||||
let uid = UserDefaults.kisani.string(forKey: "kisani.activeUserId") ?? "shared"
|
||||
let key = "kisani.workout.completedDates.\(uid)"
|
||||
let dates = UserDefaults.kisani.stringArray(forKey: key) ?? []
|
||||
let fmt = DateFormatter(); fmt.dateFormat = "yyyy-MM-dd"
|
||||
let cal = Calendar.current
|
||||
let dateSet = Set(dates)
|
||||
var streak = 0
|
||||
var check = cal.startOfDay(for: Date())
|
||||
if !dateSet.contains(fmt.string(from: check)) {
|
||||
check = cal.date(byAdding: .day, value: -1, to: check)!
|
||||
}
|
||||
while dateSet.contains(fmt.string(from: check)) {
|
||||
streak += 1
|
||||
check = cal.date(byAdding: .day, value: -1, to: check)!
|
||||
}
|
||||
return streak
|
||||
}
|
||||
254
KisaniCalWidgets/WidgetViews.swift
Normal file
@@ -0,0 +1,254 @@
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
// MARK: - Shared widget theme (slate background + brand orange)
|
||||
|
||||
enum WidgetTheme {
|
||||
// Exactly the brand palette: app accent RGB(232,98,42) on the logo's
|
||||
// dark RGB(26,29,34) — widgets read as siblings of the app icon.
|
||||
static let background = Color(red: 26/255, green: 29/255, blue: 34/255)
|
||||
static let accent = Color(red: 232/255, green: 98/255, blue: 42/255)
|
||||
static let text = Color.white
|
||||
static let textDim = Color.white.opacity(0.55)
|
||||
static let textFaint = Color.white.opacity(0.4)
|
||||
}
|
||||
|
||||
private extension String {
|
||||
func widgetTitleCased() -> String {
|
||||
split(separator: " ").map { rawWord in
|
||||
let word = String(rawWord)
|
||||
guard let first = word.first else { return word }
|
||||
if word == word.uppercased(), word.count <= 5 { return word }
|
||||
return first.uppercased() + word.dropFirst().lowercased()
|
||||
}
|
||||
.joined(separator: " ")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Vertical-bar progress (comb / equalizer style)
|
||||
|
||||
struct BarGrid: View {
|
||||
let progress: Double // 0…1
|
||||
let color: Color
|
||||
var count: Int = 64
|
||||
var gap: CGFloat = 3
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let totalGap = gap * CGFloat(count - 1)
|
||||
let barW = max(1.5, (geo.size.width - totalGap) / CGFloat(count))
|
||||
let filled = Int((progress * Double(count)).rounded())
|
||||
HStack(spacing: gap) {
|
||||
ForEach(0..<count, id: \.self) { i in
|
||||
RoundedRectangle(cornerRadius: barW / 2)
|
||||
.fill(i < filled ? color : color.opacity(0.22))
|
||||
.frame(width: barW)
|
||||
}
|
||||
}
|
||||
.frame(height: geo.size.height, alignment: .center)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Dot-grid progress
|
||||
|
||||
struct ProgressDotView: View {
|
||||
let entry: ProgressDotEntry
|
||||
let accent: Color
|
||||
@Environment(\.widgetFamily) private var family
|
||||
|
||||
private var headerFont: Font {
|
||||
switch family {
|
||||
case .systemLarge: return .system(.title3, design: .monospaced)
|
||||
case .accessoryRectangular: return .system(.caption, design: .monospaced)
|
||||
default: return .system(.body, design: .monospaced)
|
||||
}
|
||||
}
|
||||
|
||||
private var spacing: CGFloat {
|
||||
family == .accessoryRectangular ? 4 : 10
|
||||
}
|
||||
|
||||
private var horizontalPadding: CGFloat {
|
||||
switch family {
|
||||
case .systemLarge: return 8
|
||||
case .accessoryRectangular: return 0
|
||||
default: return 4
|
||||
}
|
||||
}
|
||||
|
||||
private var isAccessory: Bool { family == .accessoryRectangular }
|
||||
|
||||
// 100 tiny dots are illegible in a lock-screen pill — show a coarse grid there.
|
||||
private var dotCount: Int { isAccessory ? 28 : entry.totalDots }
|
||||
|
||||
var body: some View {
|
||||
let percent = Int((entry.progress * 100).rounded(.down))
|
||||
VStack(alignment: .leading, spacing: spacing) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(entry.title)
|
||||
.font(headerFont)
|
||||
.foregroundColor(isAccessory ? .primary : .white)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.72)
|
||||
Spacer()
|
||||
Text("\(percent)%")
|
||||
.font(headerFont)
|
||||
.foregroundColor(isAccessory ? .primary.opacity(0.7) : Color(white: 0.5))
|
||||
.lineLimit(1)
|
||||
}
|
||||
DotGridView(progress: entry.progress, totalDots: dotCount,
|
||||
color: isAccessory ? .primary : accent)
|
||||
}
|
||||
.padding(.horizontal, horizontalPadding)
|
||||
.containerBackground(for: .widget) {
|
||||
if isAccessory { AccessoryWidgetBackground() } else { WidgetTheme.background }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reusable circular dot grid
|
||||
|
||||
struct DotGridView: View {
|
||||
let progress: Double
|
||||
var totalDots: Int = 100
|
||||
let color: Color
|
||||
var gap: CGFloat = 3
|
||||
|
||||
private var filledDots: Int {
|
||||
Int((min(1, max(0, progress)) * Double(totalDots)).rounded(.down))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let layout = layout(for: totalDots, in: geo.size)
|
||||
let columns = Array(repeating: GridItem(.fixed(layout.size), spacing: gap, alignment: .center),
|
||||
count: layout.cols)
|
||||
LazyVGrid(columns: columns, alignment: .center, spacing: gap) {
|
||||
ForEach(0..<totalDots, id: \.self) { index in
|
||||
dot(filled: index < filledDots, size: layout.size)
|
||||
}
|
||||
}
|
||||
.frame(width: layout.width, height: layout.height, alignment: .topLeading)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func dot(filled: Bool, size: CGFloat) -> some View {
|
||||
if filled {
|
||||
Circle().fill(color).frame(width: size, height: size)
|
||||
} else {
|
||||
Circle()
|
||||
.strokeBorder(color.opacity(0.55), lineWidth: max(0.8, size * 0.13))
|
||||
.frame(width: size, height: size)
|
||||
}
|
||||
}
|
||||
|
||||
private func layout(for count: Int, in size: CGSize) -> (cols: Int, rows: Int, size: CGFloat, width: CGFloat, height: CGFloat) {
|
||||
guard count > 0, size.width > 1, size.height > 1 else { return (1, 1, 6, 6, 6) }
|
||||
var best = (cols: 1, rows: count, size: CGFloat(0), width: CGFloat(0), height: CGFloat(0))
|
||||
for cols in 1...count {
|
||||
let rows = Int(ceil(Double(count) / Double(cols)))
|
||||
let w = (size.width - gap * CGFloat(cols - 1)) / CGFloat(cols)
|
||||
let h = (size.height - gap * CGFloat(rows - 1)) / CGFloat(rows)
|
||||
let s = min(w, h)
|
||||
let gridWidth = CGFloat(cols) * s + gap * CGFloat(cols - 1)
|
||||
let gridHeight = CGFloat(rows) * s + gap * CGFloat(rows - 1)
|
||||
if s > best.size && gridWidth <= size.width && gridHeight <= size.height {
|
||||
best = (cols, rows, s, gridWidth, gridHeight)
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Event Countdown — Bars style (medium)
|
||||
|
||||
struct EventBarsView: View {
|
||||
let entry: EventEntry
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 10) {
|
||||
Text(entry.eventName.widgetTitleCased())
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.foregroundColor(WidgetTheme.accent)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.78)
|
||||
.layoutPriority(1)
|
||||
Button(intent: CycleCountdownUnitIntent()) {
|
||||
Text(entry.fineTimeLeft)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.foregroundColor(WidgetTheme.textDim)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.9)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
.layoutPriority(2)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
BarGrid(progress: entry.progress, color: WidgetTheme.accent)
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
.padding(.horizontal, 4).padding(.vertical, 2)
|
||||
.containerBackground(WidgetTheme.background, for: .widget)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Lock Screen Widget (accessoryRectangular)
|
||||
|
||||
struct LockScreenRectView: View {
|
||||
let task: WidgetTask
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(task.title)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.lineLimit(1)
|
||||
Text(task.timeLeftLabel)
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if !task.isComplete, let days = task.daysLeft, days <= 365 {
|
||||
ProgressView(value: Double(365 - days), total: 365)
|
||||
.progressViewStyle(.circular)
|
||||
.frame(width: 22, height: 22)
|
||||
.tint(WidgetTheme.accent)
|
||||
} else if task.isComplete {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
}
|
||||
}
|
||||
.containerBackground(.thinMaterial, for: .widget)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Lock Screen Circular
|
||||
|
||||
struct LockScreenCircularView: View {
|
||||
let task: WidgetTask
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
if !task.isComplete, let days = task.daysLeft, days <= 365 {
|
||||
ProgressView(value: Double(365 - days), total: 365)
|
||||
.progressViewStyle(.circular)
|
||||
.tint(WidgetTheme.accent)
|
||||
}
|
||||
VStack(spacing: 0) {
|
||||
if let days = task.daysLeft {
|
||||
Text("\(days)").font(.system(.caption, design: .monospaced))
|
||||
Text("days").font(.system(.caption2, design: .monospaced))
|
||||
} else {
|
||||
Image(systemName: "checkmark").font(.system(size: 14))
|
||||
}
|
||||
}
|
||||
}
|
||||
.containerBackground(.thinMaterial, for: .widget)
|
||||
}
|
||||
}
|
||||
48
SERVICES.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Rackpeek Services
|
||||
|
||||
Inventory of reverse-proxy hosts published via Rackpeek (<http://10.10.1.70:18080/services/list>).
|
||||
Format: **Service** — `IP:port` — URL. All `Public`, `Custom Certificate` unless noted; `?` = name unconfirmed.
|
||||
|
||||
- **Rackpeek** — `10.10.1.70:18080` — http://10.10.1.70:18080
|
||||
- **Backup** (Proxmox Backup Server) — `10.10.1.11:8007` — http://10.10.1.11:8007
|
||||
- **Proxmox VE** — `10.10.1.44:8006` — http://10.10.1.44:8006
|
||||
- **Uptime Kuma (Canal+)** — `10.10.1.50:3001` — http://10.10.1.50:3001
|
||||
- **Uptime Kuma (Airtel)** — `10.10.1.51:3001` — http://10.10.1.51:3001
|
||||
- **Uptime Kuma** — `10.10.1.20:3001` — http://10.10.1.20:3001
|
||||
- **Nginx Proxy Manager** — `10.10.1.5:81` — http://10.10.1.5:81
|
||||
- **Portainer** — `10.10.1.70:9000` — http://10.10.1.70:9000
|
||||
- **Netdata** — `10.10.1.20:19999` — http://10.10.1.20:19999
|
||||
- **Homarr** — `10.10.1.20:7575` — http://10.10.1.20:7575
|
||||
- **Plex** — `10.10.1.70:32400` — http://10.10.1.70:32400 _(HTTP Only)_
|
||||
- **Radarr** — `10.10.1.70:7878` — http://10.10.1.70:7878
|
||||
- **Sonarr** — `10.10.1.70:8989` — http://10.10.1.70:8989
|
||||
- **Prowlarr** — `10.10.1.70:9696` — http://10.10.1.70:9696
|
||||
- **Bazarr** — `10.10.1.70:6767` — http://10.10.1.70:6767
|
||||
- **Overseerr/Jellyseerr ?** — `10.10.1.70:5055` — http://10.10.1.70:5055
|
||||
- **Synology DSM ?** — `10.10.1.20:5000` — http://10.10.1.20:5000
|
||||
- **?** — `10.10.1.78:8181` — http://10.10.1.78:8181
|
||||
- **?** — `10.10.1.6:8181` — http://10.10.1.6:8181
|
||||
- **?** — `10.10.1.21:8181` — http://10.10.1.21:8181
|
||||
- **?** — `10.10.1.70:8181` — http://10.10.1.70:8181
|
||||
- **?** — `10.10.1.7:8181` — http://10.10.1.7:8181
|
||||
- **?** — `10.10.1.5:8181` — http://10.10.1.5:8181
|
||||
- **?** — `10.10.1.21:80` — http://10.10.1.21:80
|
||||
- **?** — `10.10.1.20:80` — http://10.10.1.20:80
|
||||
- **?** — `10.10.1.13:80` — http://10.10.1.13:80
|
||||
- **?** — `10.10.1.78:80` — http://10.10.1.78:80
|
||||
- **?** — `10.10.1.7:8080` — http://10.10.1.7:8080
|
||||
- **?** — `10.10.1.70:8080` — http://10.10.1.70:8080
|
||||
- **?** — `10.10.1.21:8080` — http://10.10.1.21:8080
|
||||
- **?** — `10.10.1.70:8888` — http://10.10.1.70:8888
|
||||
- **?** — `10.10.1.20:8888` — http://10.10.1.20:8888
|
||||
- **?** — `10.10.1.21:8888` — http://10.10.1.21:8888
|
||||
- **?** — `10.10.1.70:3030` — http://10.10.1.70:3030
|
||||
- **?** — `10.10.1.70:9080` — http://10.10.1.70:9080
|
||||
- **?** — `10.10.1.21:3002` — http://10.10.1.21:3002
|
||||
- **?** — `10.10.1.7:8281` — http://10.10.1.7:8281 _(HTTP Only)_
|
||||
- **?** — `10.10.1.70:6246` — http://10.10.1.70:6246 _(HTTP Only)_
|
||||
- **?** — `10.10.1.21:18789` — http://10.10.1.21:18789
|
||||
- **?** — `10.10.1.70:8212` — http://10.10.1.70:8212
|
||||
- **?** — `10.10.1.10:7655` — http://10.10.1.10:7655
|
||||
- **?** — `10.10.1.70:7476` — http://10.10.1.70:7476 _(HTTP Only)_
|
||||
- **?** — `10.10.1.20:9412` — http://10.10.1.20:9412
|
||||
108
STRUCTURE.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# Project Structure
|
||||
|
||||
KisaniCal (display name **Wenza**) is a personal productivity + fitness iOS app,
|
||||
built with SwiftUI and generated from `project.yml` via [XcodeGen]. It ships an
|
||||
iOS app, a WidgetKit extension, and an optional watchOS companion.
|
||||
|
||||
## Build targets
|
||||
|
||||
Defined in [`project.yml`](project.yml):
|
||||
|
||||
| Target | Type | Platform | Bundle ID |
|
||||
|---|---|---|---|
|
||||
| `KisaniCal` | application | iOS 16+ | `com.kutesir.KisaniCal` |
|
||||
| `KisaniCalWidgets` | app-extension | iOS 18+ | `com.kutesir.KisaniCal.KisaniCalWidgets` |
|
||||
| `WenzaWatch` | application | watchOS 10+ | `com.kutesir.KisaniCal.watchkitapp` |
|
||||
| `KisaniCalTests` | unit-test | iOS 16+ | — |
|
||||
|
||||
`WenzaWatch` is built/embedded only when the watchOS platform is installed
|
||||
(see the commented `embed` note in `project.yml`).
|
||||
|
||||
## Directory layout
|
||||
|
||||
```
|
||||
KisaniCal/ repo root (Gitea: kutesir/KisaniCal)
|
||||
│
|
||||
├── project.yml XcodeGen spec — source of truth for the .xcodeproj
|
||||
├── KisaniCal.xcodeproj generated (do not hand-edit)
|
||||
│
|
||||
├── KisaniCal/ 📱 main iOS app target
|
||||
│ ├── KisaniCalApp.swift @main entry point
|
||||
│ ├── ContentView.swift root view / tab shell
|
||||
│ ├── KisaniCal.entitlements
|
||||
│ ├── LaunchScreen.storyboard
|
||||
│ ├── Views/ SwiftUI screens (13)
|
||||
│ │ ├── TodayView, CalendarView, MatrixView tasks
|
||||
│ │ ├── WorkoutView, AddExerciseSheet fitness
|
||||
│ │ ├── AuthView, OnboardingView, ProfileSetupView, SplashView
|
||||
│ │ ├── SettingsView, TutorialView
|
||||
│ │ └── TaskContextMenu, TodayMenuFeatures
|
||||
│ ├── Managers/ stateful service singletons (10)
|
||||
│ │ ├── AuthManager, CloudSyncManager auth + iCloud KVS
|
||||
│ │ ├── HealthKitManager, SpeechRecognizer system frameworks
|
||||
│ │ ├── NotificationManager, LiveActivityManager, ShortcutHandler
|
||||
│ │ ├── TutorialManager
|
||||
│ │ └── AppGroup, TaskActivityAttributes shared with widgets
|
||||
│ ├── Models/ TaskItem, ExerciseModels, CalendarGrid
|
||||
│ ├── Components/ SharedComponents, FloatingTabState
|
||||
│ └── Theme/ DesignTokens
|
||||
│
|
||||
├── KisaniCalWidgets/ 🧩 WidgetKit extension (iOS 18+)
|
||||
│ ├── KisaniCalWidgets.swift, MyTasksWidget, EventCountdownWidget
|
||||
│ ├── TaskLiveActivity, WidgetViews, WidgetData
|
||||
│ └── Info.plist, .entitlements
|
||||
│
|
||||
├── WenzaWatch/ ⌚️ watchOS companion (build-optional)
|
||||
│ └── WenzaWatchApp.swift, Info.plist, .entitlements
|
||||
│
|
||||
├── KisaniCalTests/ 🧪 unit tests (the CI test target)
|
||||
│ └── CalendarGridTests, RecurrenceTests, NLRecurrenceParseTests
|
||||
│
|
||||
├── .gitea/workflows/ 🔧 CI/CD (Gitea Actions)
|
||||
│ ├── ci.yml build + test on PRs into main/develop
|
||||
│ └── release.yml archive + export IPA on push to main
|
||||
│
|
||||
├── scripts/gitea-setup.sh Gitea branch-protection / default-branch setup
|
||||
├── ExportOptions.plist IPA export template
|
||||
│
|
||||
├── CONTRIBUTING.md branch workflow + PR gate rules
|
||||
├── PRODUCT.md product brief, users, design principles
|
||||
├── SERVICES.md backing services / infra notes
|
||||
├── ISSUES.md running issue log (KC-## ids)
|
||||
└── Sanctum-Auto-FailOver-Uptime-Kuma.md
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
A flat SwiftUI **MVVM-lite** layout, organized by role rather than by feature:
|
||||
|
||||
- **Views** — SwiftUI screens, one file per screen.
|
||||
- **Managers** — `ObservableObject` service singletons holding cross-cutting
|
||||
state (auth, iCloud sync, HealthKit, notifications, speech, Live Activities).
|
||||
- **Models** — plain data types (`TaskItem`, exercise/workout models, calendar
|
||||
grid math).
|
||||
- **Components / Theme** — shared UI and design tokens.
|
||||
|
||||
Three product domains coexist in one app:
|
||||
|
||||
1. **Tasks** — Today list, Calendar, Eisenhower Matrix; natural-language quick
|
||||
add (with voice via `SpeechRecognizer`).
|
||||
2. **Fitness** — workout logging with weighted/bodyweight sets; reads/writes
|
||||
Health via `HealthKitManager`.
|
||||
3. **Calendar / events** — surfaced on the dashboard, in **widgets**, and as
|
||||
**Live Activities**.
|
||||
|
||||
### Cross-target code sharing
|
||||
|
||||
`KisaniCal/Managers/AppGroup.swift` and `TaskActivityAttributes.swift` are
|
||||
compiled into **both** the app and the widget extension (see the widget target's
|
||||
`sources` in `project.yml`), so task/activity data is shared through the App
|
||||
Group container.
|
||||
|
||||
### Persistence & sync
|
||||
|
||||
State is persisted locally and mirrored to **iCloud key-value store** via
|
||||
`CloudSyncManager`. Restore-on-launch rehydrates data after reinstall/update
|
||||
(see ISSUES.md KC-39 for the save/restore symmetry the sync layer must keep).
|
||||
|
||||
[XcodeGen]: https://github.com/yonaskolb/XcodeGen
|
||||
441
Sanctum-Auto-FailOver-Uptime-Kuma.md
Normal file
@@ -0,0 +1,441 @@
|
||||
# Sanctum Auto FailOver with Uptime Kuma
|
||||
|
||||
# Omada Dual-WAN Monitoring Architecture
|
||||
## Airtel + Canal+ with Uptime Kuma
|
||||
|
||||
---
|
||||
|
||||
# Network Layout
|
||||
|
||||
| Device | IP | Purpose |
|
||||
|--------------|----------------------|----------------------------|
|
||||
| Kuma-Canal | 10.10.1.50 | Monitor Canal+ |
|
||||
| Kuma-Airtel | 10.10.1.51 | Monitor Airtel |
|
||||
| Omada Gateway| 10.10.1.1 | Policy Routing |
|
||||
| Rackpeek | 10.10.1.70:18080 | Rack/services dashboard |
|
||||
|
||||
Rackpeek services list: <http://10.10.1.70:18080/services/list>
|
||||
|
||||
---
|
||||
|
||||
# Proxy Hosts / Services
|
||||
|
||||
Full service inventory (each service line by line, with IP and URL) lives in [SERVICES.md](SERVICES.md).
|
||||
|
||||
---
|
||||
|
||||
# Objective
|
||||
|
||||
Detect ISP failures while maintaining Internet connectivity.
|
||||
|
||||
Requirements:
|
||||
|
||||
```
|
||||
10.10.1.50 must always test through Canal+
|
||||
10.10.1.51 must always test through Airtel
|
||||
|
||||
If Canal+ fails:
|
||||
.50 fails
|
||||
If Airtel fails:
|
||||
.51 fails
|
||||
|
||||
Internet remains available through surviving ISP.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Omada Configuration
|
||||
|
||||
## Load Balancing
|
||||
|
||||
Navigate:
|
||||
|
||||
```
|
||||
Settings
|
||||
→ Wired Networks
|
||||
→ Internet
|
||||
```
|
||||
|
||||
Configure:
|
||||
|
||||
```
|
||||
Load Balancing: Enabled
|
||||
|
||||
WAN Weight:
|
||||
Airtel = 2
|
||||
Canal+ = 1
|
||||
|
||||
Application Optimized Routing:
|
||||
Disabled
|
||||
|
||||
Link Backup:
|
||||
Disabled
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Create IP Groups
|
||||
|
||||
Navigate:
|
||||
|
||||
```
|
||||
Settings
|
||||
→ Profiles
|
||||
→ Groups
|
||||
→ IP Group
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## IP Group 1
|
||||
|
||||
Name:
|
||||
|
||||
```
|
||||
Canal-Monitor
|
||||
```
|
||||
|
||||
Type:
|
||||
|
||||
```
|
||||
IP Address
|
||||
```
|
||||
|
||||
Members:
|
||||
|
||||
```
|
||||
10.10.1.50/32
|
||||
```
|
||||
|
||||
Description:
|
||||
|
||||
```
|
||||
Canal+ Monitoring Host
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## IP Group 2
|
||||
|
||||
Name:
|
||||
|
||||
```
|
||||
Airtel-Monitor
|
||||
```
|
||||
|
||||
Type:
|
||||
|
||||
```
|
||||
IP Address
|
||||
```
|
||||
|
||||
Members:
|
||||
|
||||
```
|
||||
10.10.1.51/32
|
||||
```
|
||||
|
||||
Description:
|
||||
|
||||
```
|
||||
Airtel Monitoring Host
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# IMPORTANT
|
||||
|
||||
Correct:
|
||||
|
||||
```
|
||||
10.10.1.50/32
|
||||
10.10.1.51/32
|
||||
```
|
||||
|
||||
Wrong:
|
||||
|
||||
```
|
||||
10.10.1.50/24
|
||||
10.10.1.51/24
|
||||
```
|
||||
|
||||
A /24 includes the entire subnet.
|
||||
|
||||
A /32 includes only the specified host.
|
||||
|
||||
---
|
||||
|
||||
# Create Policy Routes
|
||||
|
||||
Navigate:
|
||||
|
||||
```
|
||||
Settings
|
||||
→ Transmission
|
||||
→ Routing
|
||||
→ Policy Routing
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Rule 1 - Canal Monitor
|
||||
|
||||
Name:
|
||||
|
||||
```
|
||||
Canal-Kuma
|
||||
```
|
||||
|
||||
Status:
|
||||
|
||||
```
|
||||
Enabled
|
||||
```
|
||||
|
||||
Protocols:
|
||||
|
||||
```
|
||||
All
|
||||
```
|
||||
|
||||
WAN:
|
||||
|
||||
```
|
||||
Canal+
|
||||
```
|
||||
|
||||
Use the other WAN port if current WAN is down:
|
||||
|
||||
```
|
||||
Disabled
|
||||
```
|
||||
|
||||
Source Type:
|
||||
|
||||
```
|
||||
IP Group
|
||||
```
|
||||
|
||||
Source:
|
||||
|
||||
```
|
||||
Canal-Monitor
|
||||
```
|
||||
|
||||
Destination Type:
|
||||
|
||||
```
|
||||
IP Group
|
||||
```
|
||||
|
||||
Destination:
|
||||
|
||||
```
|
||||
IPGroup_Any
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Rule 2 - Airtel Monitor
|
||||
|
||||
Name:
|
||||
|
||||
```
|
||||
Airtel-Kuma
|
||||
```
|
||||
|
||||
Status:
|
||||
|
||||
```
|
||||
Enabled
|
||||
```
|
||||
|
||||
Protocols:
|
||||
|
||||
```
|
||||
All
|
||||
```
|
||||
|
||||
WAN:
|
||||
|
||||
```
|
||||
Airtel
|
||||
```
|
||||
|
||||
Use the other WAN port if current WAN is down:
|
||||
|
||||
```
|
||||
Disabled
|
||||
```
|
||||
|
||||
Source Type:
|
||||
|
||||
```
|
||||
IP Group
|
||||
```
|
||||
|
||||
Source:
|
||||
|
||||
```
|
||||
Airtel-Monitor
|
||||
```
|
||||
|
||||
Destination Type:
|
||||
|
||||
```
|
||||
IP Group
|
||||
```
|
||||
|
||||
Destination:
|
||||
|
||||
```
|
||||
IPGroup_Any
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Why Failover Is Disabled
|
||||
|
||||
Correct:
|
||||
|
||||
```
|
||||
Airtel fails
|
||||
↓
|
||||
10.10.1.51 loses Internet
|
||||
↓
|
||||
Kuma reports DOWN
|
||||
```
|
||||
|
||||
Wrong:
|
||||
|
||||
```
|
||||
Airtel fails
|
||||
↓
|
||||
Traffic silently moves to Canal+
|
||||
↓
|
||||
Kuma stays UP
|
||||
↓
|
||||
Failure hidden
|
||||
```
|
||||
|
||||
Monitoring hosts must fail with their assigned ISP.
|
||||
|
||||
---
|
||||
|
||||
# Uptime Kuma Configuration
|
||||
|
||||
## Kuma-Canal
|
||||
|
||||
Host:
|
||||
|
||||
```
|
||||
10.10.1.50
|
||||
```
|
||||
|
||||
Monitor:
|
||||
|
||||
```
|
||||
1.1.1.1
|
||||
8.8.8.8
|
||||
```
|
||||
|
||||
Traffic forced through Canal+.
|
||||
|
||||
---
|
||||
|
||||
## Kuma-Airtel
|
||||
|
||||
Host:
|
||||
|
||||
```
|
||||
10.10.1.51
|
||||
```
|
||||
|
||||
Monitor:
|
||||
|
||||
```
|
||||
1.1.1.1
|
||||
8.8.8.8
|
||||
```
|
||||
|
||||
Traffic forced through Airtel.
|
||||
|
||||
---
|
||||
|
||||
# Validation Tests
|
||||
|
||||
## Test Canal+
|
||||
|
||||
Disconnect Canal+.
|
||||
|
||||
Expected:
|
||||
|
||||
```
|
||||
10.10.1.50 → DOWN
|
||||
10.10.1.51 → UP
|
||||
Internet → UP
|
||||
```
|
||||
|
||||
Reconnect Canal+.
|
||||
|
||||
Expected:
|
||||
|
||||
```
|
||||
10.10.1.50 → UP
|
||||
10.10.1.51 → UP
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Airtel
|
||||
|
||||
Disconnect Airtel.
|
||||
|
||||
Expected:
|
||||
|
||||
```
|
||||
10.10.1.51 → DOWN
|
||||
10.10.1.50 → UP
|
||||
Internet → UP
|
||||
```
|
||||
|
||||
Reconnect Airtel.
|
||||
|
||||
Expected:
|
||||
|
||||
```
|
||||
10.10.1.51 → UP
|
||||
10.10.1.50 → UP
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Troubleshooting
|
||||
|
||||
If both monitors fail together:
|
||||
|
||||
Check:
|
||||
|
||||
```
|
||||
IP Groups are /32
|
||||
```
|
||||
|
||||
Check:
|
||||
|
||||
```
|
||||
Destination = IPGroup_Any
|
||||
```
|
||||
|
||||
Check:
|
||||
|
||||
```
|
||||
Use other WAN if down = Disabled
|
||||
```
|
||||
|
||||
Check:
|
||||
|
||||
```
|
||||
Application Optimized Routing = Disabled
|
||||
```
|
||||
|
||||
These four settings are responsible for most routing issues in this design.
|
||||
109
Shared/RecurrenceRule.swift
Normal file
@@ -0,0 +1,109 @@
|
||||
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)"
|
||||
}
|
||||
}
|
||||
28
WenzaWatch/Info.plist
Normal file
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Wenza</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>WKApplication</key>
|
||||
<true/>
|
||||
<key>WKCompanionAppBundleIdentifier</key>
|
||||
<string>com.kutesir.KisaniCal</string>
|
||||
</dict>
|
||||
</plist>
|
||||
8
WenzaWatch/WenzaWatch.entitlements
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
||||
<string>$(TeamIdentifierPrefix)com.kutesir.KisaniCal</string>
|
||||
</dict>
|
||||
</plist>
|
||||
221
WenzaWatch/WenzaWatchApp.swift
Normal file
@@ -0,0 +1,221 @@
|
||||
import SwiftUI
|
||||
import WatchKit
|
||||
|
||||
// MARK: - Shared iCloud task store
|
||||
//
|
||||
// The Watch is a separate device and can't read the iPhone's App Group, so it
|
||||
// reads/writes the SAME tasks via the iCloud key-value store (NSUbiquitousKeyValueStore)
|
||||
// that CloudSyncManager already mirrors on the phone. Writes here propagate back to
|
||||
// the phone, which applies external KV changes into its App Group.
|
||||
|
||||
private func tasksKey() -> String {
|
||||
let uid = NSUbiquitousKeyValueStore.default.string(forKey: "kisani.activeUserId") ?? "shared"
|
||||
return "kisani.tasks.v2.\(uid)"
|
||||
}
|
||||
|
||||
private let occFmt: DateFormatter = {
|
||||
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f
|
||||
}()
|
||||
|
||||
struct WatchTask: Identifiable {
|
||||
let id: UUID
|
||||
var title: String
|
||||
var dueDate: Date?
|
||||
var isComplete: Bool
|
||||
var isRecurring: Bool
|
||||
var recurrenceLabel: String?
|
||||
var completedOccurrences: [String]
|
||||
}
|
||||
|
||||
final class WatchStore: ObservableObject {
|
||||
@Published var tasks: [WatchTask] = []
|
||||
private let kv = NSUbiquitousKeyValueStore.default
|
||||
private let cal = Calendar.current
|
||||
|
||||
init() {
|
||||
NotificationCenter.default.addObserver(
|
||||
self, selector: #selector(externalChange),
|
||||
name: NSUbiquitousKeyValueStore.didChangeExternallyNotification, object: kv)
|
||||
kv.synchronize()
|
||||
load()
|
||||
}
|
||||
|
||||
@objc private func externalChange() {
|
||||
kv.synchronize()
|
||||
DispatchQueue.main.async { [weak self] in self?.load() }
|
||||
}
|
||||
|
||||
func load() {
|
||||
guard let data = kv.data(forKey: tasksKey()),
|
||||
let arr = (try? JSONSerialization.jsonObject(with: data)) as? [[String: Any]] else {
|
||||
tasks = []; return
|
||||
}
|
||||
tasks = arr.compactMap { d in
|
||||
guard let idStr = d["id"] as? String, let id = UUID(uuidString: idStr),
|
||||
let title = d["title"] as? String else { return nil }
|
||||
let due = (d["dueDate"] as? Double).map { Date(timeIntervalSinceReferenceDate: $0) }
|
||||
return WatchTask(id: id, title: title, dueDate: due,
|
||||
isComplete: d["isComplete"] as? Bool ?? false,
|
||||
isRecurring: d["isRecurring"] as? Bool ?? false,
|
||||
recurrenceLabel: d["recurrenceLabel"] as? String,
|
||||
completedOccurrences: d["completedOccurrences"] as? [String] ?? [])
|
||||
}
|
||||
}
|
||||
|
||||
// Today's actionable tasks: dated today/overdue, plus recurring occurrences due today.
|
||||
var todayTasks: [WatchTask] {
|
||||
let end = cal.date(byAdding: .day, value: 1, to: cal.startOfDay(for: Date()))!
|
||||
return tasks.filter { t in
|
||||
if t.isRecurring { return occursToday(t) && !completedToday(t) }
|
||||
guard !t.isComplete, let due = t.dueDate else { return false }
|
||||
return due < end
|
||||
}
|
||||
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
|
||||
}
|
||||
|
||||
private func completedToday(_ t: WatchTask) -> Bool {
|
||||
t.completedOccurrences.contains(occFmt.string(from: Date()))
|
||||
}
|
||||
|
||||
private func occursToday(_ t: WatchTask) -> Bool {
|
||||
guard let due = t.dueDate else { return false }
|
||||
let now = Date()
|
||||
if due > cal.date(byAdding: .day, value: 1, to: cal.startOfDay(for: now))! { return false }
|
||||
switch t.recurrenceLabel {
|
||||
case "Daily": return true
|
||||
case "Every Weekday": let wd = cal.component(.weekday, from: now); return wd >= 2 && wd <= 6
|
||||
case "Weekly": return cal.component(.weekday, from: due) == cal.component(.weekday, from: now)
|
||||
case "Monthly": return cal.component(.day, from: due) == cal.component(.day, from: now)
|
||||
case "Yearly": return cal.component(.month, from: due) == cal.component(.month, from: now)
|
||||
&& cal.component(.day, from: due) == cal.component(.day, from: now)
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
func toggle(_ task: WatchTask) {
|
||||
mutate { arr in
|
||||
for i in arr.indices where (arr[i]["id"] as? String) == task.id.uuidString {
|
||||
if task.isRecurring {
|
||||
var occ = arr[i]["completedOccurrences"] as? [String] ?? []
|
||||
let k = occFmt.string(from: Date())
|
||||
if let idx = occ.firstIndex(of: k) { occ.remove(at: idx) } else { occ.append(k) }
|
||||
arr[i]["completedOccurrences"] = occ
|
||||
} else {
|
||||
let done = (arr[i]["isComplete"] as? Bool) ?? false
|
||||
arr[i]["isComplete"] = !done
|
||||
if !done { arr[i]["completedAt"] = Date().timeIntervalSinceReferenceDate }
|
||||
else { arr[i].removeValue(forKey: "completedAt") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func add(_ title: String) {
|
||||
let t = title.trimmingCharacters(in: .whitespaces)
|
||||
guard !t.isEmpty else { return }
|
||||
mutate { arr in
|
||||
// All non-optional TaskItem keys must be present (synthesized Decodable
|
||||
// throws on missing keys); raw values mirror the iOS model.
|
||||
arr.append([
|
||||
"id": UUID().uuidString,
|
||||
"title": t,
|
||||
"dueDate": self.cal.startOfDay(for: Date()).timeIntervalSinceReferenceDate,
|
||||
"hasTime": false,
|
||||
"isComplete": false,
|
||||
"quadrant": "Urgent + Important",
|
||||
"category": "Reminder",
|
||||
"taskColor": "orange",
|
||||
"isRecurring": false,
|
||||
"isPinned": false,
|
||||
"isAllDay": false,
|
||||
"priority": "None",
|
||||
"constantReminder": false,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
private func mutate(_ change: (inout [[String: Any]]) -> Void) {
|
||||
let key = tasksKey()
|
||||
var arr: [[String: Any]] = []
|
||||
if let data = kv.data(forKey: key),
|
||||
let parsed = (try? JSONSerialization.jsonObject(with: data)) as? [[String: Any]] { arr = parsed }
|
||||
change(&arr)
|
||||
if let out = try? JSONSerialization.data(withJSONObject: arr) {
|
||||
kv.set(out, forKey: key)
|
||||
kv.synchronize()
|
||||
}
|
||||
load()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UI
|
||||
|
||||
@main
|
||||
struct WenzaWatchApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup { TaskListView() }
|
||||
}
|
||||
}
|
||||
|
||||
struct TaskListView: View {
|
||||
@StateObject private var store = WatchStore()
|
||||
@State private var showAdd = false
|
||||
|
||||
private let timeFmt: DateFormatter = {
|
||||
let f = DateFormatter(); f.dateFormat = "h:mm a"; return f
|
||||
}()
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Button { showAdd = true } label: {
|
||||
Label("Add Task", systemImage: "plus.circle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
|
||||
if store.todayTasks.isEmpty {
|
||||
Text("All caught up")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.footnote)
|
||||
} else {
|
||||
ForEach(store.todayTasks) { task in
|
||||
Button { withAnimation { WKInterfaceDevice.current().play(.success); store.toggle(task) } } label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "circle")
|
||||
.foregroundStyle(.orange)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(task.title).lineLimit(2)
|
||||
if let due = task.dueDate, !task.isRecurring {
|
||||
Text(timeFmt.string(from: due))
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Today")
|
||||
.sheet(isPresented: $showAdd) {
|
||||
AddTaskView { store.add($0); showAdd = false }
|
||||
}
|
||||
.onAppear { store.reload() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AddTaskView: View {
|
||||
let onAdd: (String) -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var text = ""
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 10) {
|
||||
TextField("New task", text: $text) // Watch dictation / Scribble
|
||||
Button("Add") { onAdd(text) }
|
||||
.disabled(text.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
Button("Cancel", role: .cancel) { dismiss() }
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
}
|
||||
75
docs/CICD.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# CI/CD Flow
|
||||
|
||||
How a change travels from a local branch to TestFlight, and where the gates are.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["feature/* branch · local<br/><i>pre-push hook blocks main</i>"]
|
||||
B["Pull request → develop<br/><i>develop is the default branch</i>"]
|
||||
C["CI · ci.yml (macOS runner)<br/><i>xcodebuild build + test</i>"]
|
||||
D{"Merge gate<br/>CI green + 1 approval"}
|
||||
E["Pull request develop → main<br/><i>same gate</i>"]
|
||||
F["Release · release.yml<br/><i>archive → IPA → TestFlight</i>"]
|
||||
|
||||
A -->|git push · open PR| B
|
||||
B -->|PR triggers CI| C
|
||||
C -->|status check: build-and-test| D
|
||||
D -->|promote when ready| E
|
||||
E -->|on merge to main| F
|
||||
```
|
||||
|
||||
## Steps
|
||||
|
||||
1. **`feature/*` branch (local).** Branch off `develop`. A local
|
||||
`.git/hooks/pre-push` hook rejects any direct push to `main`, so nothing
|
||||
reaches the protected branch outside a PR.
|
||||
|
||||
2. **PR → `develop`.** `develop` is the default branch and the integration
|
||||
target for all feature work.
|
||||
|
||||
3. **CI runs** — [`.gitea/workflows/ci.yml`](../.gitea/workflows/ci.yml) on the
|
||||
self-hosted **macOS runner**. It runs `xcodebuild build` then
|
||||
`xcodebuild test` (the `KisaniCalTests` target on an iOS simulator). The
|
||||
result is published as a commit status named **`build-and-test`**.
|
||||
|
||||
4. **Merge gate.** Gitea branch protection blocks the merge until:
|
||||
- the `build-and-test` status check is **green**,
|
||||
- there is **≥ 1 approval**,
|
||||
- there are no rejected reviews, and
|
||||
- the branch is up to date with its base.
|
||||
|
||||
The same gate guards both `develop` and `main`.
|
||||
|
||||
5. **Promote `develop` → `main`** via a second PR when a release is ready. It
|
||||
passes through the identical gate.
|
||||
|
||||
6. **Release runs** — [`.gitea/workflows/release.yml`](../.gitea/workflows/release.yml)
|
||||
fires **only** on push to `main` (i.e. after a PR merges). It archives,
|
||||
exports an IPA via [`ExportOptions.plist`](../ExportOptions.plist), and
|
||||
uploads to **TestFlight** (currently a commented-out placeholder awaiting the
|
||||
App Store Connect API-key secrets).
|
||||
|
||||
## Two automation halves
|
||||
|
||||
- **CI** (steps 3–4) gates **every** PR into `main`/`develop`.
|
||||
- **Release** (step 6) runs **only** on `main`.
|
||||
|
||||
Blue/manual steps are actions you take; CI and Release run unattended on the
|
||||
runner.
|
||||
|
||||
## Requirements & gotchas
|
||||
|
||||
- **Runner labels.** Both workflows use `runs-on: [self-hosted, macOS]`. The
|
||||
registered runner must carry **both** labels or jobs queue forever. Check at
|
||||
Settings → Actions → Runners.
|
||||
- **Status-check name.** Branch protection requires a check literally named
|
||||
`build-and-test` (the CI job name). If the rendered context differs, update
|
||||
the rule (`scripts/gitea-setup.sh`, `STATUS_CONTEXT`).
|
||||
- **Simulator.** The runner needs an installed iOS runtime (Xcode → Settings →
|
||||
Components) — CI auto-selects the first available iPhone simulator.
|
||||
- **Signing.** CI builds with `CODE_SIGNING_ALLOWED=NO`. The Release job needs a
|
||||
distribution cert + profile in the runner's login keychain.
|
||||
- **Build number.** `CURRENT_PROJECT_VERSION` in `project.yml` must increase for
|
||||
every TestFlight upload — App Store Connect rejects a repeated build number.
|
||||
|
||||
See also [CONTRIBUTING.md](../CONTRIBUTING.md) for the branch workflow.
|
||||
95
project.yml
@@ -6,6 +6,12 @@ options:
|
||||
xcodeVersion: "15.0"
|
||||
createIntermediateGroups: true
|
||||
|
||||
settings:
|
||||
base:
|
||||
DEVELOPMENT_TEAM: K8BLMMR883
|
||||
MARKETING_VERSION: "2.0"
|
||||
CURRENT_PROJECT_VERSION: "10"
|
||||
|
||||
schemes:
|
||||
KisaniCal:
|
||||
build:
|
||||
@@ -15,6 +21,8 @@ schemes:
|
||||
config: Debug
|
||||
test:
|
||||
config: Debug
|
||||
targets:
|
||||
- KisaniCalTests
|
||||
profile:
|
||||
config: Release
|
||||
analyze:
|
||||
@@ -29,20 +37,99 @@ targets:
|
||||
deploymentTarget: "16.0"
|
||||
sources:
|
||||
- path: KisaniCal
|
||||
- path: Shared
|
||||
dependencies:
|
||||
- target: KisaniCalWidgets
|
||||
embed: true
|
||||
# WenzaWatch is built/embedded only when the watchOS platform is installed.
|
||||
# Re-enable shipping it on the Watch by uncommenting the embed below (requires
|
||||
# Xcode > Settings > Components > watchOS):
|
||||
# - target: WenzaWatch
|
||||
# embed: true
|
||||
settings:
|
||||
base:
|
||||
SWIFT_VERSION: 5.9
|
||||
TARGETED_DEVICE_FAMILY: "1,2"
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation: YES
|
||||
INFOPLIST_KEY_UILaunchStoryboardName: ""
|
||||
INFOPLIST_KEY_CFBundleDisplayName: "Kisani Cal"
|
||||
INFOPLIST_KEY_UILaunchStoryboardName: LaunchScreen
|
||||
INFOPLIST_KEY_CFBundleDisplayName: "Wenza"
|
||||
INFOPLIST_KEY_UIStatusBarStyle: UIStatusBarStyleDefault
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
|
||||
INFOPLIST_KEY_NSSupportsLiveActivities: YES
|
||||
CODE_SIGN_STYLE: Automatic
|
||||
CODE_SIGN_ENTITLEMENTS: KisaniCal/KisaniCal.entitlements
|
||||
ENABLE_PREVIEWS: YES
|
||||
INFOPLIST_KEY_NSCalendarsUsageDescription: "Kisani Cal shows your calendar events alongside tasks."
|
||||
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription: "Kisani Cal shows your calendar events alongside tasks."
|
||||
INFOPLIST_KEY_NSCalendarsUsageDescription: "Wenza shows your calendar events alongside tasks."
|
||||
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription: "Wenza shows your calendar events alongside tasks."
|
||||
INFOPLIST_KEY_NSHealthShareUsageDescription: "Wenza reads your steps, energy, heart rate, and workouts to show health stats on your dashboard."
|
||||
INFOPLIST_KEY_NSHealthUpdateUsageDescription: "Wenza saves workouts you complete to the Health app."
|
||||
INFOPLIST_KEY_NSMicrophoneUsageDescription: "Wenza uses your microphone to convert speech into task text."
|
||||
INFOPLIST_KEY_NSSpeechRecognitionUsageDescription: "Wenza uses speech recognition so you can add tasks by voice."
|
||||
|
||||
KisaniCalTests:
|
||||
type: bundle.unit-test
|
||||
platform: iOS
|
||||
deploymentTarget: "16.0"
|
||||
sources:
|
||||
- path: KisaniCalTests
|
||||
dependencies:
|
||||
- target: KisaniCal
|
||||
settings:
|
||||
base:
|
||||
SWIFT_VERSION: 5.9
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
|
||||
KisaniCalWidgets:
|
||||
type: app-extension
|
||||
platform: iOS
|
||||
deploymentTarget: "18.0"
|
||||
sources:
|
||||
- path: KisaniCalWidgets
|
||||
- path: KisaniCal/Managers/AppGroup.swift
|
||||
- path: KisaniCal/Managers/TaskActivityAttributes.swift
|
||||
- path: Shared
|
||||
info:
|
||||
path: KisaniCalWidgets/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: "Wenza Widgets"
|
||||
CFBundleShortVersionString: "$(MARKETING_VERSION)"
|
||||
CFBundleVersion: "$(CURRENT_PROJECT_VERSION)"
|
||||
NSExtension:
|
||||
NSExtensionPointIdentifier: com.apple.widgetkit-extension
|
||||
settings:
|
||||
base:
|
||||
SWIFT_VERSION: 5.9
|
||||
CODE_SIGN_STYLE: Automatic
|
||||
GENERATE_INFOPLIST_FILE: NO
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.kutesir.KisaniCal.KisaniCalWidgets
|
||||
LD_RUNPATH_SEARCH_PATHS: "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"
|
||||
APPLICATION_EXTENSION_API_ONLY: YES
|
||||
CODE_SIGN_ENTITLEMENTS: KisaniCalWidgets/KisaniCalWidgets.entitlements
|
||||
|
||||
WenzaWatch:
|
||||
type: application
|
||||
platform: watchOS
|
||||
deploymentTarget: "10.0"
|
||||
sources:
|
||||
- path: WenzaWatch
|
||||
info:
|
||||
path: WenzaWatch/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: "Wenza"
|
||||
CFBundleShortVersionString: "$(MARKETING_VERSION)"
|
||||
CFBundleVersion: "$(CURRENT_PROJECT_VERSION)"
|
||||
WKApplication: true
|
||||
WKCompanionAppBundleIdentifier: com.kutesir.KisaniCal
|
||||
settings:
|
||||
base:
|
||||
SWIFT_VERSION: 5.9
|
||||
GENERATE_INFOPLIST_FILE: NO
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.kutesir.KisaniCal.watchkitapp
|
||||
TARGETED_DEVICE_FAMILY: "4"
|
||||
CODE_SIGN_STYLE: Automatic
|
||||
CODE_SIGN_ENTITLEMENTS: WenzaWatch/WenzaWatch.entitlements
|
||||
INFOPLIST_KEY_CFBundleDisplayName: "Wenza"
|
||||
|
||||
82
scripts/gitea-setup.sh
Executable file
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# One-shot Gitea setup for KisaniCal:
|
||||
# 1. Creates the `develop` branch on the server (from main) if missing.
|
||||
# 2. Sets `develop` as the repo's default branch.
|
||||
# 3. Adds branch protection on `main` (require PR + 1 approval + status check,
|
||||
# block direct pushes / force pushes / deletion).
|
||||
#
|
||||
# USAGE:
|
||||
# export GITEA_TOKEN=xxxxxxxxxxxxxxxxxxxx # a Gitea access token with repo scope
|
||||
# ./scripts/gitea-setup.sh
|
||||
#
|
||||
# Create the token in Gitea: click your avatar → Settings → Applications →
|
||||
# "Generate New Token" (scopes: at least write:repository ).
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
GITEA_URL="http://10.10.1.21:3002"
|
||||
OWNER="kutesir"
|
||||
REPO="KisaniCal"
|
||||
|
||||
# The commit-status context that must pass before merge into main.
|
||||
# For Gitea Actions this is the JOB name from ci.yml ("build-and-test").
|
||||
# After your first CI run, confirm the exact context string at:
|
||||
# ${GITEA_URL}/${OWNER}/${REPO}/commits (hover the check) and adjust if needed.
|
||||
STATUS_CONTEXT="build-and-test"
|
||||
|
||||
: "${GITEA_TOKEN:?Set GITEA_TOKEN env var first (see header).}"
|
||||
|
||||
API="${GITEA_URL}/api/v1"
|
||||
AUTH=(-H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json")
|
||||
|
||||
echo "==> 1/3 Ensuring 'develop' branch exists on server..."
|
||||
if curl -fsS "${AUTH[@]}" "${API}/repos/${OWNER}/${REPO}/branches/develop" >/dev/null 2>&1; then
|
||||
echo " develop already exists."
|
||||
else
|
||||
curl -fsS -X POST "${AUTH[@]}" \
|
||||
"${API}/repos/${OWNER}/${REPO}/branches" \
|
||||
-d '{"new_branch_name":"develop","old_branch_name":"main"}' >/dev/null
|
||||
echo " created develop from main."
|
||||
fi
|
||||
|
||||
echo "==> 2/3 Setting default branch to 'develop'..."
|
||||
curl -fsS -X PATCH "${AUTH[@]}" \
|
||||
"${API}/repos/${OWNER}/${REPO}" \
|
||||
-d '{"default_branch":"develop"}' >/dev/null
|
||||
echo " default branch = develop."
|
||||
|
||||
echo "==> 3/3 Applying branch protection on 'main'..."
|
||||
# If a rule already exists this POST returns 409; we then PUT-update it.
|
||||
PROTECT_PAYLOAD=$(cat <<JSON
|
||||
{
|
||||
"branch_name": "main",
|
||||
"rule_name": "main",
|
||||
"enable_push": false,
|
||||
"enable_push_whitelist": false,
|
||||
"required_approvals": 1,
|
||||
"enable_approvals_whitelist": false,
|
||||
"block_on_rejected_reviews": true,
|
||||
"dismiss_stale_approvals": true,
|
||||
"enable_status_check": true,
|
||||
"status_check_contexts": ["${STATUS_CONTEXT}"],
|
||||
"block_on_outdated_branch": true
|
||||
}
|
||||
JSON
|
||||
)
|
||||
|
||||
if curl -fsS -X POST "${AUTH[@]}" \
|
||||
"${API}/repos/${OWNER}/${REPO}/branch_protections" \
|
||||
-d "${PROTECT_PAYLOAD}" >/dev/null 2>&1; then
|
||||
echo " created protection rule for main."
|
||||
else
|
||||
echo " rule may already exist — updating it..."
|
||||
curl -fsS -X PATCH "${AUTH[@]}" \
|
||||
"${API}/repos/${OWNER}/${REPO}/branch_protections/main" \
|
||||
-d "${PROTECT_PAYLOAD}" >/dev/null
|
||||
echo " updated protection rule for main."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Done. Note: Gitea automatically blocks force-pushes and deletion of a"
|
||||
echo "protected branch, so 'main' is now safe from both."
|
||||
98
support-site/index.html
Normal file
@@ -0,0 +1,98 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Wenza — Support</title>
|
||||
<meta name="description" content="Support for Wenza — a personal tasks, workout, and calendar companion for iPhone." />
|
||||
<style>
|
||||
:root {
|
||||
--bg: #1a1d22;
|
||||
--surface: #21252b;
|
||||
--border: #2e333b;
|
||||
--text: #f2f3f5;
|
||||
--muted: #9aa3ad;
|
||||
--accent: #ff7a1a;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html { -webkit-text-size-adjust: 100%; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 7vh 24px 64px;
|
||||
}
|
||||
.wrap { width: 100%; max-width: 560px; }
|
||||
.mark {
|
||||
width: 44px; height: 44px; border-radius: 12px;
|
||||
background: var(--accent);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 700; font-size: 22px; color: var(--bg);
|
||||
margin-bottom: 28px;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
h1 { font-size: 30px; font-weight: 700; letter-spacing: -0.6px; margin-bottom: 10px; }
|
||||
.lede { color: var(--muted); font-size: 17px; margin-bottom: 44px; }
|
||||
h2 {
|
||||
font-size: 13px; font-weight: 600; text-transform: uppercase;
|
||||
letter-spacing: 0.08em; color: var(--muted); margin-bottom: 14px;
|
||||
}
|
||||
section { margin-bottom: 40px; }
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 20px 22px;
|
||||
}
|
||||
.card a { color: var(--accent); text-decoration: none; font-weight: 600; }
|
||||
.card a:hover { text-decoration: underline; }
|
||||
.faq { border-bottom: 1px solid var(--border); padding: 16px 0; }
|
||||
.faq:first-child { padding-top: 0; }
|
||||
.faq:last-child { border-bottom: none; padding-bottom: 0; }
|
||||
.faq h3 { font-size: 16px; font-weight: 600; margin-bottom: 4px; }
|
||||
.faq p { color: var(--muted); font-size: 15px; }
|
||||
footer { color: var(--muted); font-size: 13px; margin-top: 8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="mark">W</div>
|
||||
<h1>Wenza Support</h1>
|
||||
<p class="lede">A quiet, personal companion for your tasks, your training, and your calendar.</p>
|
||||
|
||||
<section>
|
||||
<h2>Contact</h2>
|
||||
<div class="card">
|
||||
Questions, bugs, or feedback? Email
|
||||
<a href="mailto:support@example.com">support@example.com</a>.
|
||||
I read every message and usually reply within a couple of days.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Common questions</h2>
|
||||
<div class="card">
|
||||
<div class="faq">
|
||||
<h3>Does Wenza collect my data?</h3>
|
||||
<p>No. Your tasks, workouts, and health data stay on your device and in your own iCloud. Nothing is sent to a server or any third party.</p>
|
||||
</div>
|
||||
<div class="faq">
|
||||
<h3>Will my data sync across devices?</h3>
|
||||
<p>Settings and data sync through your private iCloud account, so they survive reinstalls and new devices signed in to the same Apple ID.</p>
|
||||
</div>
|
||||
<div class="faq">
|
||||
<h3>Why does Wenza ask for Health and Calendar access?</h3>
|
||||
<p>Health access shows your steps, energy, heart rate, and workouts on your dashboard. Calendar access lets your events appear alongside your tasks. Both are optional.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>© 2026 Wenza. All rights reserved.</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||