Compare commits

...

133 Commits

Author SHA1 Message Date
kutesir
887a75cc4f docs: log KC-72 (StreakLogicTests fix + runner recovery)
All checks were successful
CI / build-and-test (push) Successful in 1m33s
CI / build-and-test (pull_request) Successful in 1m40s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 01:36:19 +03:00
kutesir
3f8c9e1456 test: mark StreakLogicTests @MainActor to fix Swift 6 compile error (KC-72)
Some checks failed
CI / build-and-test (push) Has been cancelled
CI / build-and-test (pull_request) Successful in 1m51s
The CI runner was broken all session (registered but never actually
polled for jobs - separate infra bug, fixed alongside this). First
real CI run surfaced a genuine, pre-existing compile error unrelated
to tonight's work: StreakLogicTests called @MainActor-isolated
WorkoutViewModel/TaskViewModel static methods from a non-isolated
test class. RecurrenceTests.swift in the same target already uses
the correct @MainActor class annotation - StreakLogicTests was just
missing it.

Verified locally: full suite now passes, 78/78 tests, 0 failures,
on a real simulator (iPhone 17 Pro) - not just self-review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 01:35:26 +03:00
kutesir
266c3a6dda ci: finish release.yml's TestFlight upload, rule out Xcode Cloud for this repo (KC-71)
Some checks failed
CI / build-and-test (push) Failing after 39s
CI / build-and-test (pull_request) Failing after 30s
Spent considerable effort trying to connect Xcode Cloud directly to
this repo's self-hosted Gitea - confirmed not achievable. GitHub
Enterprise's provider option hits Gitea's missing /api/v3/ path;
GitLab Self-Managed rejects Gitea's UUID-format OAuth2 Client IDs
against App Store Connect's 64-char-hex requirement (verified by
actually creating a real OAuth2 app and having it rejected). Also
stood up Tailscale Funnel on the Gitea host for real public HTTPS
access regardless (was LAN-only before), and switched origin to it.

Replaced release.yml's commented-out altool placeholder (defunct -
Apple retired that upload path in 2023) with the current supported
approach: xcodebuild -exportArchive with API-key auth flags handles
export and TestFlight upload in a single step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 00:03:19 +03:00
kutesir
c992554f99 tasks: add "every N weeks/months/days/years" recurrence (KC-70)
Some checks failed
CI / build-and-test (push) Has been cancelled
Recurrence previously only supported 5 fixed rules with no interval.
Adds TaskItem.recurrenceInterval (optional, nil = 1, so every existing
task is unaffected), a stepper in the shared create/edit sheet, quick-add
NLP for phrases like "every 2 weeks"/"biweekly"/"fortnightly", and
threads the interval through both countdown widgets so their progress
bars size correctly for biweekly/etc tasks too.

Built on KC-69's consolidated RecurrenceRule engine. Verified two things
by actually compiling and running standalone swiftc drivers rather than
just reading: the core interval math (44/44, unchanged from KC-69) and
the new quick-add regex patterns against realistic phrases including
"look into tru housing options every two weeks" (11/11 passing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 00:15:45 +03:00
kutesir
6915e64b67 recurrence: consolidate 3 duplicate implementations into one shared, tested engine (KC-69)
Some checks failed
CI / build-and-test (push) Has been cancelled
Before adding "every N weeks/months" support, found the recurrence
window math was independently hand-written in three places (main app's
TaskItem.isOccurrence, and two separate widget extensions' countdown
progress calculations) with no shared code to keep them in sync.

Extracted the math into Shared/RecurrenceRule.swift (pure Foundation,
no framework deps) compiled into both the app and widget extension
targets via a new project.yml Shared/ source path, and pointed all
three call sites at it. Verified every case is identical to prior
behavior at interval==1 by compiling and running a standalone test
driver directly against the real file with swiftc - 38/38 passing,
including Jan-31-monthly and Feb-29-leap-year-yearly edge cases.

Groundwork only - no recurrenceInterval field on TaskItem yet, the
actual interval feature comes next on top of this.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 23:56:58 +03:00
kutesir
eff5cc9330 workout: restore confirmed Undo for manually-logged days only (KC-68)
Some checks failed
CI / build-and-test (push) Has been cancelled
KC-67 removed Undo entirely on the reasoning that Watch/Health is the
source of truth once a day is logged - correct for a Watch-detected
day, but it also blocked undoing a day logged manually via the
in-app Finish button, leaving no way back (hit while testing KC-67
itself). Added manualWorkoutDates to track which completions came
from the app's own Finish button vs HealthKit sync, and only offer
Undo (now confirmed via a dialog) for those.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 03:16:53 +03:00
kutesir
b0afa17a28 workout: fix iCloud sync stomping the Watch-logged streak, remove Undo (KC-67)
Some checks failed
CI / build-and-test (push) Has been cancelled
Root cause: CloudSyncManager.kvStoreChanged blindly overwrote any
externally-changed iCloud key, including workoutDates - a lifetime
tally that every local write path (HealthKit sync, manual finish)
carefully dedups/unions. A stale iCloud snapshot racing a fresh
Watch-sync write could silently replace the correct array with an
older, smaller one. Now unions instead of overwriting for that key
specifically.

Also: removed the Undo action/row from DotProgressCard entirely (Watch/
Health is the source of truth once a day is logged), and restored the
confirmation dialog on Finish Workout so every state-changing action
confirms first.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 01:52:56 +03:00
kutesir
3512d55503 liveactivity+widgets: fix iOS 16.2 build error, extend createdAt to Bars widget (KC-66)
Some checks failed
CI / build-and-test (push) Has been cancelled
end(_:dismissalPolicy:) requires iOS 16.2 but LiveActivityManager.end
only gated to 16.1 (Live Activities' own minimum) - branch on
#available with the deprecated overload as fallback. Separately,
auditing all countdown widgets per user request found the "Event
Countdown (Bars)" widget family had its own independent start-date
resolution that never considered the task's real creation date at
all - EventEntity now carries createdAt and resolveSpan prefers it
the same way KC-65 already does for the dot-grid widget.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 22:52:12 +03:00
kutesir
b1b33b6014 model: give TaskItem a real createdAt so countdown widgets stop resetting (KC-65)
Some checks failed
CI / build-and-test (push) Has been cancelled
The Track Countdown widget already preferred task.createdAt as its
progress start date, but TaskItem never had that field — it was
always nil, so every task fell back to a fragile "first render"
anchor that can reset to now. New tasks now record a real creation
timestamp; old tasks keep the existing fallback since there's no way
to know when they were actually made.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 22:46:58 +03:00
kutesir
3c71a233d7 liveactivity: fix deprecated end(using:dismissalPolicy:) warning (KC-64)
Some checks failed
CI / build-and-test (push) Has been cancelled
Omitting the content argument on activity.end(dismissalPolicy:)
resolved to the deprecated iOS 16.1 overload. Pass nil explicitly to
bind to end(content:dismissalPolicy:) instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 22:43:06 +03:00
kutesir
f9852751da workout: revert Finish-at-100% confirmation, keep dots-always-visible fix (KC-63 follow-up)
Some checks failed
CI / build-and-test (push) Has been cancelled
Confirming every Finish tap was unwanted friction once all sets are
already checked. Restored the one-tap Finish at 100% (partial
completion still confirms via "Finish Anyway"); the always-visible
progress dots from the first part of KC-63 are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 22:40:54 +03:00
kutesir
9e06b56a43 workout: keep progress dots visible after finish, confirm every Finish tap (KC-63)
Some checks failed
CI / build-and-test (push) Has been cancelled
The KC-52 redesign swapped the whole progress-dots view for a green
"Workout logged" takeover on finish, with no way back short of Undo,
and let Finish skip confirmation whenever all sets were already
checked. Dots now stay visible in every state; Finish always confirms,
matching Clear All / Didn't Train.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 22:35:58 +03:00
kutesir
c8723714af tasks: typography polish on task rows, no size changes (KC-62 follow-up 2)
Some checks failed
CI / build-and-test (push) Has been cancelled
Title weight bumped to medium for hierarchy, tracking added to the
mono date/countdown lines for legibility at small size, slightly
looser line spacing. Impeccable's detect engine only parses HTML, not
Swift, so this was done by hand rather than via the skill.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 14:38:34 +03:00
kutesir
57e8c5a34b tasks: rebalance row layout, countdown+icons move to right column (KC-62 follow-up)
Some checks failed
CI / build-and-test (push) Has been cancelled
The category icon column sat empty for most rows (default tasks have
no glyph/alarm/series), leaving a lone repeat icon floating in dead
space on the right per user's device screenshot. Move the countdown
label out from under the title into a trailing column paired with the
indicator icons, so both sides of the row carry equal weight.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 14:26:40 +03:00
kutesir
d8f7abff92 tasks: replace category text pill with TickTick-style icon glyphs (KC-62)
Some checks failed
CI / build-and-test (push) Has been cancelled
Every task row was tagging itself "Reminder" via a text badge for the
default category, adding no information. Swapped for icon-only glyphs
(nil for the generic .reminder case) folded into the existing alarm/
repeat indicator row; countdown/date info was already present or
redundant with the leading time-track, so no separate countdown text
was added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 14:17:18 +03:00
kutesir
9723775cb6 onboarding+liveactivity+habits: 3 fixes from device feedback (KC-61)
Some checks failed
CI / build-and-test (push) Has been cancelled
1. Onboarding Habits caption now explicitly mentions the toggles are
   changeable in Settings, not just times/custom reminder.

2. TaskLiveActivity: removed the checkbox-look circle glyph (hourglass
   instead). iOS has no API for an app to block the system's swipe/dismiss
   gesture on a Live Activity -- not attempted. Added a real Stop control via
   a new StopCountdownIntent (LiveActivityIntent, iOS 17+) embedded directly
   in the card (lock screen '×' + Dynamic Island 'Stop Countdown' button) --
   the actual iOS-supported equivalent of 'long press for options', since
   Live Activities don't support notification-style long-press menus.

3. Habit notifications gain 'Not Today' (dismiss only, intentional no-op --
   the lifetime-tally philosophy has no concept of a logged miss) and 'Stop
   Tracking' (destructive-styled; flips the habit's master toggle off and
   cancels every pending notification for that type across all its slots).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 13:33:32 +03:00
kutesir
dd69a6a2e4 tutorial: introduce habit reminders in the Settings tutorial too (KC-60)
Some checks failed
CI / build-and-test (push) Has been cancelled
Habit reminders (KC-55) were in Onboarding but not the app's separate
spotlight/coachmark tutorial system -- skipping onboarding meant no second
chance to discover Prayer/Bed/Coffee reminders exist.

Adds a fourth TutorialManager track (settingsTips/settingsStep/settingsDone),
matching the existing Today/Workout tip-track pattern exactly, and wires it
into SettingsView via the same shared InViewTutorialCard component -- shown
once, first time Settings opens.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 12:47:23 +03:00
kutesir
a5f7bce09f tasks: add 'Complete & Stop Series' for recurring tasks (KC-59)
Some checks failed
CI / build-and-test (push) Has been cancelled
Recurring tasks only had Delete (erases the task + its entire completion
history) as a way to stop a series -- no in-between option to wind one down
while keeping its record.

TaskViewModel.completeAndStopSeries(_🔛) marks the occurrence complete and
sets recurrenceEnd to that day -- a field that already existed in the model
and was already respected by isOccurrence, just had no UI. Task, title, and
full completedOccurrences history stay intact; only future occurrences stop.

New menu item in the shared TaskMenuItems (Today/Matrix/Calendar), shown only
for recurring tasks. Wired through all 8 call sites, several via a new
optional closure threaded through intermediate wrapper views (QuadrantCard,
DayTimelineView, OverdueCard, UpcomingSection).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 03:32:37 +03:00
kutesir
467045365d Bump build to 2.0 (10)
Some checks failed
CI / build-and-test (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 03:09:14 +03:00
kutesir
a3a1a46afd habits: clarify the custom coffee reminder row (KC-58)
Some checks failed
CI / build-and-test (push) Has been cancelled
The custom-slot row just said 'Coffee' -- identical-looking to the card's own
'Coffee reminders' title, with no affordance signaling it's an editable,
user-nameable 5th slot (distinct from the 4 fixed Morning/Midday/Afternoon/
Evening ones).

Added a small pencil + 'YOUR OWN REMINDER' label above the field; changed the
default from pre-filled 'Coffee' to empty so the placeholder ('Name it, e.g.
"Second coffee"') actually does its job. NotificationManager falls back to
'Coffee' for the notification text only if the user never typed a name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 02:39:35 +03:00
kutesir
8f6980df13 fix(theme): add AppColors.coffeeBrown -- Color(r:g:b:) is file-private (KC-57)
Some checks failed
CI / build-and-test (push) Has been cancelled
Build error: Color(r:g:b:) is a private extension scoped to DesignTokens.swift
(only UIColor's sibling init is also private, both file-scoped by design).
KC-55/56 called it directly from SettingsView.swift and OnboardingView.swift
for the coffee icon color -- invisible there, so the compiler fell back to
unrelated overloads ('Cannot convert Int to Color', 'Extra argument b').

Added AppColors.coffeeBrown (defined where the private init is visible),
replacing both inline Color(r:g:b:) call sites -- matches how every other
color in the app is centralized and accessible everywhere.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 02:19:32 +03:00
kutesir
97782a82bc habits: add a 'Done' notification action + private day-count badge (KC-56)
Some checks failed
CI / build-and-test (push) Has been cancelled
Follow-up to KC-55's design question: habit reminders (Prayer/Bed/Coffee) stay
out of Today/Matrix/Activity History (would add 3-9 daily checkboxes forever,
against 'nothing shouts'), but now get a 'Done' action on the notification
itself -- like the workout confirm flow's 'Yes, log it' -- that logs a private
timestamp.

New HABIT_LOG category/HABIT_DONE action; every habit notification carries
userInfo['habitType'] (bed/prayer/coffee -- slot detail collapses to the type).
logHabitDone/habitDoneCount store a deduped date array in UserDefaults.kisani,
mirroring the lifetime-tally streak philosophy from KC-40 rather than a
breakable streak. Settings shows a quiet 'Xd' badge per habit -- the only
place the count appears; nothing touches the task list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 02:12:16 +03:00
kutesir
dea8d313e4 habits: add Prayer, Bed-making, and Coffee/Caffeine reminders (KC-55)
Some checks failed
CI / build-and-test (push) Has been cancelled
Three new opt-in daily local-notification habits, toggleable in both Settings
and Onboarding, backed by the same UserDefaults.kisani (App Group) keys:

- Prayer: up to 4 independently-toggleable times/day (Morning/Afternoon/
  Evening/Night), 'Have you had a chance to pray and give thanks?'
- Bed-making: one daily nudge, default 8am (research-backed first win of the
  day per the user's note).
- Coffee/Caffeine: up to 4 named times + one custom-labeled reminder.

NotificationManager.scheduleHabitReminders() schedules/cancels all slots
idempotently via fixed identifiers, wired into the existing reschedule()
orchestration. Onboarding gets 3 restrained master toggles with sensible
defaults (no per-slot editors, to avoid a long form); Settings gets the full
Habit Reminders sheet with per-slot times and the custom coffee reminder.

Found and worked around a pre-existing store mismatch: workoutCheckInEnabled/
workoutConfirmEnabled etc. write to UserDefaults.standard (no explicit store:)
but NotificationManager reads them from UserDefaults.kisani -- not fixed here
(out of scope), but all new keys explicitly use store: UserDefaults.kisani so
this feature actually fires.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 02:04:48 +03:00
kutesir
083a51065b tasks: fix timed recurring tasks vanishing from Today after their time passes (KC-54)
Some checks failed
CI / build-and-test (push) Has been cancelled
Real bug: todayTasks excludes timed tasks whose time has passed ('belongs in
overdue'), but overdueTasks deliberately excludes ALL recurring tasks (it feeds
postpone/postponeAllOverdue, which mutate dueDate by id -- for a recurring task
that's the recurrence anchor, not just today's occurrence; including them there
would let Postpone All corrupt the whole series). Recurring tasks fell into the
gap between the two rules and vanished from the Today tab entirely once their
time passed, while Calendar's day view (no time-of-day gating) kept showing
them fine.

Fix: todayTasks only excludes past-time NON-recurring tasks. Recurring
occurrences always stay in Today, matching Calendar, without ever touching the
overdue/postpone path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 23:05:18 +03:00
kutesir
7bfa16aec4 widgets: add 'This Year' mode to Progress Dots (KC-53)
Some checks failed
CI / build-and-test (push) Has been cancelled
The Progress Dots lock-screen widget already supports Day/Week/Month via its
configuration picker — no code needed for those. Year was the only mode
missing. Added ProgressDotMode.year, a matching provider branch
(Calendar.dateInterval(of: .year)), and yearTitle(for:), mirroring the
existing day/week/month pattern exactly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 22:59:23 +03:00
kutesir
017937bbff workout: stop auto-completing the day when sets are ticked (KC-52 follow-up 3)
Some checks failed
CI / build-and-test (push) Has been cancelled
Real bug, not cosmetic: setAllSetsDone/toggleSet/setExerciseDone still
auto-called logWorkoutCompleted() whenever doneSets == totalSets — leftover
pre-redesign behavior that fought the new completion card. Ticking the last
box (or tapping 'Check all sets') silently counted the day and snapped the UI
to the green 'done' takeover without the user ever hitting Finish.

Removed the auto-log from all three set-mutation methods. Only the card's
explicit Finish Workout/Finish Anyway (-> markWorkoutDone) now counts a day as
done. Check all sets just checks boxes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 13:54:13 +03:00
kutesir
5b3d4e5f9e workout: drop seal icon, confirm every destructive quick action (KC-52 follow-up 2)
Some checks failed
CI / build-and-test (push) Has been cancelled
checkmark.seal.fill (unused elsewhere, reads as a generic 'AI verified badge')
replaced with checkmark.circle.fill, the app's actual convention. All four
state-changing card actions (Finish Anyway, Check All Sets, Clear All Sets,
Didn't Train) now confirm via one PendingAction enum + confirmationDialog
before executing, each with a tailored message; destructive ones use the
.destructive role. The header menu's Clear All Sets (any completion level)
also now confirms instead of executing instantly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 13:48:26 +03:00
kutesir
5123a38ea9 workout: restrained button, finish confirmation, clear toggle (KC-52 follow-up)
Some checks failed
CI / build-and-test (push) Has been cancelled
Addresses feedback on the completion card: the primary button was a
permanently-filled accent pill (violates 'nothing shouts'/'color earns its
place') and Finish Anyway skipped confirming before overriding real progress.

- QuietAccentButtonStyle: outlined at rest, fills solid accent only while
  pressed.
- confirmationDialog on Finish Anyway showing the logged % (skipped at 100%).
- Check all sets flips to Clear all sets once fully checked — undo is the
  button itself.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 13:23:56 +03:00
kutesir
c521c43f9a workout: bring completion controls onto the main page (KC-52)
Some checks failed
CI / build-and-test (push) Has been cancelled
DotProgressCard becomes a 3-state completion card (pending/done/missed) with a
primary Finish Workout/Finish Anyway action that captures today's workout
honestly — regardless of how many sets got ticked, since Health/Watch already
confirms the workout independently. Secondary actions: check all sets, mark
not completed. Done/missed states show an Undo.

Adds WorkoutViewModel.unmarkWorkoutDone/unmarkWorkoutMissed. Removes 'Mark All
Complete' from the header '...' menu (superseded by the card); keeps 'Clear
All Sets' as a distinct reset utility.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 10:57:05 +03:00
kutesir
708bf95e2a fix(healthkit): use let for interval captured across async let tasks
Some checks failed
CI / build-and-test (push) Has been cancelled
Build error (Xcode, Swift 6): 'interval' was a var built via two statements
then captured by 3 concurrent async let tasks in dailyReference — Swift 6
requires captured values to be immutable. It was never mutated after setup;
switched to a single-expression let (DateComponents(day: 1)).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 10:46:25 +03:00
kutesir
eba39b0475 fix(analytics): mark WorkoutSnapshot.init(vm:) @MainActor
Some checks failed
CI / build-and-test (push) Has been cancelled
Build error (Xcode, Swift 6 strict concurrency): WorkoutSnapshot.init(vm:) read
history/workoutDates/schedule/missedDates from @MainActor WorkoutViewModel in a
nonisolated init. The init is only ever called from AnalyticsService.reconcile
(@MainActor) — mark it @MainActor to match its actual call site.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 10:44:31 +03:00
kutesir
839ac72846 analytics(healthkit): wire historical HealthKit reference into reports (KC-51 ph4e)
Some checks failed
CI / build-and-test (push) Has been cancelled
Completes 'available HealthKit trends' for weekly/monthly reports. Adds
HealthKitManager.dailyReference (3 parallel bucketed HKStatisticsCollectionQuery
for steps/active-calories/resting-HR over ~13 months), AnalyticsService.
refreshHealthReference to cache and merge it into DaySamples, and avgSteps/
avgActiveCalories/avgRestingHR on PeriodSummary (nil when absent — honest).
Surfaced in AnalyticsView's weekly/monthly cards.

Verified standalone (swiftc, 8/8): aggregation correct when present, nil when
absent, workout metrics unaffected, reports stay deterministic. Tests added to
AnalyticsEngineTests. This completes all analytics logic — remaining work is the
first Xcode build (device, user-run) to catch any SwiftUI compile issues.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 10:39:00 +03:00
kutesir
67863fca44 analytics(service): capture snapshot value in coordinator closures (concurrency hardening)
Some checks failed
CI / build-and-test (push) Has been cancelled
Moves sample-building to nonisolated static funcs over a captured WorkoutSnapshot
so the coordinator's escaping closures don't touch @MainActor state — avoids a
main-actor isolation error. Matches the app's existing @MainActor + static shared
pattern (HealthKitManager).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:39:39 +03:00
kutesir
c56a6a9d21 analytics(deeplink): route report notification tap to its report (KC-51 ph4c)
Some checks failed
CI / build-and-test (push) Has been cancelled
Completes deep-link routing (goal #7): AnalyticsDeepLink is Identifiable; a
tapped weekly/monthly report notification is consumed in ContentView on
launch/active and presents AnalyticsView opened to that area. Core round-trip
re-verified standalone; SwiftUI wiring needs an Xcode build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:36:45 +03:00
kutesir
7f11b0e8c1 analytics(ui): AnalyticsView (6 areas + states + charts) + Workout entry (KC-51 ph4b)
Some checks failed
CI / build-and-test (push) Has been cancelled
Self-contained Analytics UI: Overview/Daily/Weekly/Monthly/12-Month/Exercises
with color+glyph status, empty/partial states, Swift Charts, VoiceOver labels,
Wenza tokens, and the non-medical disclaimer. Entry via an IButton in the
Workout header (sheet). Added Identifiable chart points.

Self-reviewed for compile issues (fixed tuple key-paths, a11y trait literal,
format string) but NOT build-verified — no iOS runtime; SwiftUI/app deps. Logged
in ISSUES.md as device-required.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:23:54 +03:00
kutesir
d25cf9ba80 analytics(app): AnalyticsService + lifecycle reconcile + deep-linked notifications (KC-51 ph4a)
Some checks failed
CI / build-and-test (push) Has been cancelled
Wires the verified analytics core into the app: AnalyticsService feeds live
WorkoutViewModel data into the reconcile coordinator and exposes UI reads;
ContentView reconciles on launch + foreground; NotificationManager fires one
permission-aware, deduped weekly/monthly report notification carrying an
AnalyticsDeepLink, captured on tap for routing.

NOT build-verified (no iOS runtime; app-module/SwiftUI deps). Logged in
ISSUES.md; historical HealthKit reference + user-scheduled notification time are
follow-ups.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:05:02 +03:00
kutesir
065066c8f7 analytics(integration): adapter + reconcile coordinator + deep links (KC-51 ph3)
Some checks failed
CI / build-and-test (push) Has been cancelled
Phase 3 core (verifiable): WorkoutAnalyticsAdapter (app data → DaySample),
AnalyticsCoordinator (idempotent backfill of missing weekly/monthly reports +
retention prune, injectable providers), AnalyticsDeepLink (wenza://analytics
report routing).

Fixed a reconcile bug caught by tests: a retention-boundary week was generated
then immediately pruned, regenerating every run — aligned completedWeekStartKeys
to the prune cutoff → idempotent.

Tests: AnalyticsAdapterTests + AnalyticsCoordinatorTests. Verified standalone via
swiftc (~90 analytics assertions total across engine/store/adapter/coordinator).
Remaining (app wiring, notifications, UI) requires an Xcode build — logged in
ISSUES.md as device-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 22:53:20 +03:00
kutesir
80591c31a6 analytics(persistence): file-based store + reconciliation selection (KC-51 ph2)
Some checks failed
CI / build-and-test (push) Has been cancelled
Phase 2: AnalyticsStore (JSON files under Application Support) with immutable
frozen daily snapshots + immutable, idempotent, deduped weekly/monthly reports,
and 12-month retention pruning. Engine gains completedWeekStartKeys/
completedMonthKeys/missingKeys for idempotent reconciliation selection.

AnalyticsStoreTests: 6 XCTest cases. Verified standalone via swiftc — 23-
assertion temp-dir harness all pass. Registered files via xcodegen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 22:34:32 +03:00
kutesir
dd4d196129 analytics(engine): pure deterministic AnalyticsEngine + models + tests (KC-51 ph1)
Some checks failed
CI / build-and-test (push) Has been cancelled
Phase 1 of the analytics system (/goal): the framework-independent analysis
engine. Value-type models (MetricDelta, TrendResult, DaySample, WeeklyReport,
MonthlyReport, ...) and AnalyticsEngine with documented formulas (volume,
%change, consistency, trend) and honest degradation (nil/zero/tiny baseline,
new exercise, rest day, outliers via winsorize, unit-dimension compatibility,
divide-by-zero). Calendar math takes an injected Calendar (locale/firstWeekday/
timezone/DST-safe).

AnalyticsEngineTests: 35 XCTest cases. Independently verified by compiling the
engine with swiftc and running a 42-assertion harness — all pass. (Xcode test
target not runnable in this env; assertions mirror the XCTest file.)

Registered the new files via xcodegen. Logs KC-51 phase-1 progress in ISSUES.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 22:06:59 +03:00
kutesir
cc0dc02be0 docs(issues): KC-51 — analytics system plan, persistence eval, blockers
Some checks failed
CI / build-and-test (push) Has been cancelled
Records the /goal analytics-system implementation plan: reusable-functionality
survey, SwiftData/CoreData/current persistence evaluation, proposed architecture
(stores, immutable reports, pure analysis engine, reconciliation, notifications,
UI, tests), and the two blockers (build-verification unavailable; persistence +
min-iOS decision).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 01:42:45 +03:00
kutesir
532bd57866 Calendar: force timeline fill (dead space) + long-press menu on blocks
Some checks failed
CI / build-and-test (push) Has been cancelled
KC-49: week/3-day dead space — the outer .frame only allowed fill; force the
timeline ScrollView greedy (.frame maxHeight .infinity) and pin each timeline
root VStack top, so the header pins to the top instead of floating mid-screen.

KC-50: timeline blocks now have a long-press context menu — Complete, Snooze
(15m/1h/tomorrow), and the shared TaskMenuItems (reschedule/move/priority/
category/edit/delete). Events get no menu. CVTimeBlock takes @ViewBuilder menu.

Logs KC-49 (supersedes KC-48) and KC-50 in ISSUES.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 01:35:25 +03:00
kutesir
543a1c3ee0 docs(issues): log KC-40–48 (streak tally, stats, history, banner, calendar rework)
Some checks failed
CI / build-and-test (push) Has been cancelled
Records this session's work in the issue tracker: workout streak → lifetime
tally (KC-40), recurring-aware stats (KC-41), Activity History (KC-42), neutral
Today checkbox (KC-43), swipeable streak banner (KC-44), continuous year view
(KC-45), month TickTick bars+collapse reverted (KC-46), week timeline + drag +
grid toggle (KC-47), week/3-day dead-space fix (KC-48).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 01:17:18 +03:00
kutesir
3134c064e9 Calendar: fix week/3-day dead space (pin content to top)
Some checks failed
CI / build-and-test (push) Has been cancelled
The timeline views' content is a thin header + a greedy ScrollView (≈0 ideal
height), so the top-level VStack shrank and the bottomTrailing ZStack floated
it to the bottom — leaving a big empty gap above the day header. Month view
has a tall fixed grid so it never surfaced. Pin the VStack to fill height and
top-align.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 01:13:23 +03:00
kutesir
0a87769561 Revert "Calendar month view: TickTick-style bars + collapse-to-week"
This reverts commit 2ec3278632.
2026-07-07 01:12:14 +03:00
kutesir
e8d74d4f59 Revert "Calendar month: drag-to-collapse + full-column selection highlight"
This reverts commit bd43eecfbe.
2026-07-07 01:09:58 +03:00
kutesir
152b0bf9b5 Calendar week view: timeline ⇄ grid layout toggle
Some checks failed
CI / build-and-test (push) Has been cancelled
Completes the TickTick weekly reference — a toggle flips the week between the
timeline and a grid of day-cards (2 columns), each card listing that day's
schedule as colored bars. Tapping a card opens that day. Both layouts swipe
by week.

- weekGrid state + toggle button (timeline/grid icons)
- weekGridLayout + weekDayCard

(Not build-verified — sim runtime removed to reclaim disk.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 00:39:29 +03:00
kutesir
372da6df5a Calendar timeline: drag a task block to reschedule its time
Some checks failed
CI / build-and-test (push) Has been cancelled
The headline of the TickTick weekly timeline — task blocks are now draggable
vertically to change their time, snapped to 15-minute steps. Applies on the
week, 3-day, and day timelines.

- CVTimeItem carries taskID + recurring; task items are tagged, events are not
- CVTimeBlock: a draggable block (GestureState offset while dragging, commits
  on release); events and recurring tasks render fixed (non-draggable)
- rescheduleTask: clamps to the visible hour range and writes the new time via
  taskVM.setDate on the same day

(Not build-verified — sim runtime removed to reclaim disk.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 00:35:36 +03:00
kutesir
c5285a2e30 Calendar week view: 7-day timeline (TickTick-style)
Some checks failed
CI / build-and-test (push) Has been cancelled
Replaces the mini-month + agenda-list split with a proper weekly timeline —
hours down the left axis, one column per weekday, events as time-positioned
blocks — reusing the same cvTimeGrid/cvDayColumnHeader the day and 3-day
views already use. Horizontal swipe navigates by week (navigateWeek).

Removes the now-unused cvAgendaItems helper.

(Not build-verified — sim runtime removed to reclaim disk; reuses existing
timeline components.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 00:29:43 +03:00
kutesir
bd43eecfbe Calendar month: drag-to-collapse + full-column selection highlight
Some checks failed
CI / build-and-test (push) Has been cancelled
Two refinements to get closer to the TickTick reference:
- Drag up on the calendar collapses to the selected week; drag down expands
  to the full month (horizontal swipe still changes month). One direction-
  aware DragGesture handles both axes.
- The selected day now gets a rounded full-column highlight behind its
  number + bars when collapsed (as in the tapped-day screenshot), instead
  of only the day circle.

(Not build-verified — sim runtime removed to reclaim disk.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 00:19:38 +03:00
kutesir
2ec3278632 Calendar month view: TickTick-style bars + collapse-to-week
Some checks failed
CI / build-and-test (push) Has been cancelled
Matches the referenced TickTick monthly view:
- Day cells now show stacked colored schedule BARS (workout/events/tasks,
  up to 4) instead of dots — a glanceable overview of the month.
- Selecting a day COLLAPSES the grid to just that week, giving the day's
  agenda room below (as in the tapped-day screenshot). A chevron grabber
  expands back to the full month.

- DayCell: bars param + stacked RoundedRectangles, top-aligned, height 54
- monthView: renders week rows (all when expanded, only the selected week
  when collapsed) via weekRows()/selectedWeekRow(); grabber toggles
- eventDots cap raised to 4 for the bar stack

(Not build-verified — sim runtime removed to reclaim disk.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 00:17:11 +03:00
kutesir
6456d8f69a Calendar: header follows the scrolled-to year in continuous year view
Some checks failed
CI / build-and-test (push) Has been cancelled
The top title now tracks whichever year is at the top of the year scroll
(e.g. scroll into 2027 and the header reads '2027'), instead of staying on
the displayed month. In month/week/day modes it still shows month + year.

- visibleYear state driven by a YearTopKey preference reporting each year
  block's top offset in the 'yearScroll' coordinate space
- headerTitle switches on viewMode
- iOS 16-safe (GeometryReader + PreferenceKey, no scrollPosition API)

(Not build-verified — sim runtime removed to reclaim disk.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 00:09:22 +03:00
kutesir
a37807aff0 Calendar year view: continuous TickTick-style multi-year scroll
Some checks failed
CI / build-and-test (push) Has been cancelled
Replaces the single paged year (< 2026 >, with dead space below December)
with an infinite vertical scroll where years flow one after another. Opens
anchored on the current year (accent-colored); scroll up for past years,
down for future. Bottom padding clears the floating tab bar / + button.

- yearView: ScrollViewReader + LazyVStack over a year range, scrollTo(current)
- yearBlock(_:): bold year label + 12 mini-months (reuses cvMiniMonth)
- Removed navigateYear + the ◀ year ▶ header (superseded by inline labels)

(Not build-verified — sim runtime removed to reclaim disk; standard SwiftUI
+ existing cvMiniMonth helper.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 00:03:06 +03:00
kutesir
cac8cba38a Workout streak banner: swipeable week / year / vs-last-week pages
Some checks failed
CI / build-and-test (push) Has been cancelled
Replaces the single lifetime-tally banner (which used a meaningless streak%7
tick bar) with a 3-page paged TabView:
  1. This week — count + 7 per-day ticks for the current week
  2. This year — count + 12 month ticks, with all-time total underneath
  3. vs last week — this week's count and the delta (▲ more / ▼ fewer / same)

Adds WorkoutViewModel breakdowns: workoutsThisWeek/LastWeek/ThisYear,
currentWeekDays (7 flags), currentYearMonths (12 flags).

(Not build-verified — sim runtime removed to reclaim disk; uses only APIs
already present in the original banner.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 23:54:25 +03:00
kutesir
28b4039593 Today list: neutral checkbox stroke (color already on accent bar + chip)
Some checks failed
CI / build-and-test (push) Has been cancelled
The Tomorrow/Later rows carry their quadrant color on the left accent bar
and the category chip, so tinting the checkbox too was redundant. Match the
neutral text3 stroke the timeline rows already use.

(Not build-verified — local sim runtime removed to reclaim disk; trivial
one-line swap to an API already used elsewhere in the same file.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 13:19:56 +03:00
cbf369b754 Merge pull request 'docs: add CICD.md (pipeline flow)' (#27) from docs/cicd-flow into develop
Some checks failed
CI / build-and-test (push) Has been cancelled
2026-07-05 19:40:41 +00:00
5e7d476b59 Merge pull request 'docs: add STRUCTURE.md' (#26) from docs/project-structure into develop
Some checks failed
CI / build-and-test (push) Has been cancelled
2026-07-05 19:40:38 +00:00
ce97de37bd Merge pull request 'ci: add Gitea Actions CI/CD, branch workflow, and contributor docs' (#25) from feature/cicd-setup into develop
Some checks failed
CI / build-and-test (push) Has been cancelled
2026-07-05 19:40:22 +00:00
kutesir
c0dd298e5b Workout streak: lifetime tally that only grows (never resets)
Per user model: the workout count should reflect every workout achieved and
never break. Replaces the consecutive/weekly-goal streak with a simple count
of distinct workout days (totalAchievedWorkoutDays). A below-goal week (4/5)
and a full week (5/5) both count fully (=9); a missed day or blank week never
reduces it; duplicate HealthKit days count once.

- ExerciseModels: streakDays = Set(workoutDates).count
- StreakLogicTests: retargeted to tally semantics (4/5+5/5=9, blank-week,
  duplicates, skipped-day, empty)
- SettingsView: goal caption no longer claims the streak can be lost

NOTE: not built/tested locally — CLI xcodebuild wedges on actool via the
broken CoreSimulator daemon (missing sim runtimes, machine-wide). Verify in
Xcode GUI (prefs now fixed) or on device.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 19:51:46 +03:00
d235cce510 ci: lowercase runner label (runner registered as self-hosted,macos)
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-07-05 12:07:52 +00:00
9e93172199 ci: lowercase runner label (runner registered as self-hosted,macos) 2026-07-05 12:07:52 +00:00
kutesir
6b5f6bd3ea Stats: schedule-aware weekly streaks, recurring-aware counts, activity history
- Workout streak now honors the weekly goal ('reach your goal every week'):
  counts workout days across consecutive kept weeks, so rest days and a
  skipped day within goal no longer reset it to 1 (KC: profile showed
  streak 1 with 5 workouts that week).
- Task 7-day bars / 'this week' now count recurring occurrences
  (completedOccurrences), fixing permanently-empty bars alongside a
  nonzero done rate.
- New task day-streak stat on the profile Tasks card.
- New ActivityHistoryView: 90-day day-by-day archive of completed tasks
  (times, repeat markers), workout logs (sets/volume), missed/made-up days.
- StreakLogicTests: 8 unit tests; algorithm additionally verified via
  standalone harness (7/7 scenarios incl. the skipped-Thursday case).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 15:05:14 +03:00
kutesir
b540bddbe9 docs: add CICD.md with pipeline flow diagram and gate rules
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 02:01:11 +03:00
kutesir
9ae94babb6 docs: add STRUCTURE.md describing targets, layout, and architecture
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 01:50:30 +03:00
kutesir
8d37021f78 ci: add Gitea Actions CI/CD, branch workflow, and contributor docs
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
- .gitea/workflows/ci.yml: build + test KisaniCal scheme on a self-hosted
  macOS runner for every PR into main/develop (and pushes to develop).
- .gitea/workflows/release.yml: archive + export IPA on push to main, with
  a commented TestFlight upload placeholder (App Store Connect API key).
- ExportOptions.plist: export template (Team ID K8BLMMR883, app-store method).
- scripts/gitea-setup.sh: idempotent Gitea API setup for develop default
  branch + main branch protection.
- CONTRIBUTING.md: feature/* -> develop -> main workflow and PR gate rules.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 01:38:22 +03:00
kutesir
dd22e94efe Set build to 2.0 (9)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 01:10:36 +03:00
kutesir
b78b965938 Tasks: square the remaining checkboxes I missed (Today timeline, Next-3, Matrix rows)
Follow-up after the first pass only caught the list-style rows. Converted the
actual tap-to-complete toggles + markers that were still circular:
- Today timeline (tlRows): marker complete/all-day → rounded square (circleD
  18→16); trailing SF-symbol toggle → rounded-square checkbox.
- Today Next-3-Days row: 20pt circle → 16pt rounded square.
- Today "Completed Today" row indicator: green circle → green rounded square.
- Calendar Month agenda (DayTimelineView): trailing SF-symbol toggle → square.
- Matrix quadrant row checkbox: circles → rounded squares.

Left intentionally circular: multi-select sheet (selection, not completion) and
workout-set checkboxes (separate domain).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 01:07:50 +03:00
kutesir
1c325c108e Tasks: unify checkboxes to 16pt rounded square across Calendar & Matrix
Match the Today list checkbox (16pt, cornerRadius 5):
- Calendar list-view row: cr3/18 → cr5/16.
- Calendar day task-list row (CalTaskRow): cr6/22 → cr5/16.
- Calendar Month agenda markers (DayTimelineView): task complete + all-day
  markers circle → rounded square, circleD 18 → 16 (workout stays a circle, the
  timed bullet stays a dot, so those remain visually distinct).
- Matrix row (QDTaskRow): 22 → 16.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 00:56:48 +03:00
kutesir
24219f8289 Bump build to 2.0 (10)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 00:40:38 +03:00
kutesir
5a83f232a6 Tasks: square (rounded) checkbox, slightly smaller
TaskRowView checkbox: 18pt circle -> 16pt rounded square (cornerRadius 5),
matching the requested look. Keeps the quadrant-color stroke and 36x44 tap target.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 00:28:47 +03:00
kutesir
4a05844ee3 Voice: fix audio-session leak causing insufficientPriority (561017449)
Decoded the device error: NSOSStatusErrorDomain 561017449 = '!pri' =
AVAudioSession insufficientPriority — the mic was denied because a session was
left active (which also blocks the keyboard's own dictation).

Root cause: the early-return guards (recognizer unavailable / invalid format) ran
AFTER setActive(true) but never deactivated, leaking an active session. Also the
.measurement + .duckOthers config is more prone to priority denials.

Fixes:
- Deactivate any stale active session before starting.
- Use .record / .default (least-exclusive recording config).
- stopEngine() now always releases the session (no early-out), and the bailout
  guards call it so the session is never left active.
- Map 561017449 to an actionable message (close other audio apps / restart).

Device-specific; not reproducible on the sim (blocks at the permission dialog).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 21:56:45 +03:00
kutesir
09acf7afa5 Workout: restore history/missed/compensations from iCloud (KC-39)
save() pushed all 7 workout keys to iCloud KVS, but init only restored 4 —
workout history, missed dates, and rest-day compensations were uploaded yet
never restored, so they were lost on reinstall/update. Added the three missing
restoreIfMissing calls so restore mirrors save.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 21:52:16 +03:00
kutesir
b6c9f5b0ca Fix: restore token highlighting in Quick Add field (regression)
A fast-path early-return added in the voice-binding fix
(`if tv.textColor == .label && tv.text == text && editing { return }`) skipped
rebuilding the attributed text while editing — so token highlights never
reapplied as the parser updated highlightRanges on each keystroke. Removed it so
highlights always render. Also switched the highlight tint from systemBlue to the
app accent (orange) to match the established design.

Verified on simulator: "Remind me to pray everyday" → "Remind me to" + "everyday"
highlighted in accent orange; chips Today / Daily.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 21:36:21 +03:00
kutesir
c9f0fd0777 Voice: harden startEngine against "Could not start recording"
Addresses the most common causes of the engine failing to start:
- Defensive teardown before starting (removeTap + stop + reset) — a leftover tap
  from a prior attempt makes installTap throw "already has a tap".
- Validate the input node's hardware format (sampleRate/channels > 0) and fail
  with a clear "No microphone input" message instead of throwing.
- Use inputNode.inputFormat(forBus:0) for the tap.
- Category .playAndRecord + [.duckOthers, .defaultToSpeaker] (more permissive
  than .record with .duckOthers).
- Surface the underlying NSError domain+code in the banner + log to pinpoint any
  remaining device-specific failure.

DEBUG-only KISANI_VOICE_START hook to drive the real engine on a sim.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 21:33:16 +03:00
kutesir
ad304913e4 Voice: show dictated transcript in the Quick Add field
The mic captured speech and the transcript reached rawText, but it never appeared
in the field. NLTaskField.updateUIView (UITextView representable) returned early
when the field wasn't focused (`guard coordinator.isEditing`), so externally-set
text (voice dictation, the normal not-focused case) was dropped and the
placeholder stayed.

Fix: render non-empty `text` even when not editing, placing the caret at the end;
keep the cursor-preserving fast path while actively editing. No new voice system —
this only fixes the existing mic button's transcript→field binding.

Verified on simulator via the real speech.transcript path: "Buy milk tomorrow" →
field shows it (chip: Tomorrow); "Call Romeo at 11 AM" → field shows it (chip:
Today 11:00). Adds DEBUG-only test hooks KISANI_AUTOADD / KISANI_VOICE_SIM
(compiled out of Release). Logged under KC-38.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 21:22:45 +03:00
kutesir
f8d0a80f17 docs: log KC-38 (voice input — speech-to-task via mic button)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 21:13:26 +03:00
kutesir
e8c2c5c8ea docs: log KC-32–37 (calendar views, grid alignment, future-task confirm, visual cleanup, dynamic version, everyday recurrence)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 21:05:33 +03:00
kutesir
8370125ec0 Quick-add: detect "everyday" and "every weekday" recurrence
parseRecurrence required a space ("every day"), so "everyday" (one word) was
missed. Now \bevery\s*day\b matches both, plus "each day". Added Every Weekday
detection ("every weekday", "weekdays") ahead of the broader weekly/daily
patterns. Sub-day cadence ("every hour"/"hourly") is intentionally NOT matched —
the occurrence engine is day-granular, so it stays a one-off rather than being
mislabeled. 6 parser tests added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 21:02:57 +03:00
kutesir
af2996876c Tests: recurring-task occurrence expansion (date mapping)
8 tests covering daily / weekly / every-weekday / monthly / yearly expansion,
repeat-until cutoff, time-of-day preservation on the mapped local day, and
non-recurring single-day placement. Uses Calendar.current so it holds in any
timezone. Closes the recurrence-coverage gap from the calendar fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 20:59:43 +03:00
kutesir
eb7d563bbd feat(voice): wire existing mic button to live speech recognition
The mic icon in AddTaskSheet was a static Image with no action and no
speech service behind it. Full implementation:

SpeechRecognizer.swift (new):
- @MainActor ObservableObject wrapping SFSpeechRecognizer + AVAudioEngine
- Requests speech recognition then microphone permission in sequence
- Streams partial results into @Published transcript; final result stops engine
- Suppresses transient error 301 (no speech detected)
- reset() cleans up on sheet dismiss
- [VOICE] log trail at every step: tapped → granted → started → transcript → saved

TodayView.swift — AddTaskSheet:
- @StateObject private var speech = SpeechRecognizer()
- onChange(speech.transcript) writes transcript to rawText (task field)
- Mic Image → Button { speech.toggle() } with mic.fill + pulse animation while recording
- .safeAreaInset shows a red dismissible error banner on permission denial
- submit() stops recording before saving; logs task title
- .onDisappear resets speech state

project.yml:
- NSMicrophoneUsageDescription
- NSSpeechRecognitionUsageDescription

Co-Authored-By: Kutesir <tqwyy79vzn@privaterelay.appleid.com>
2026-06-24 19:48:00 +03:00
kutesir
716e6e8f31 Notifications: drop decorative ✓ glyphs from action title and done body
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 19:24:54 +03:00
kutesir
38db257c4a Calendar: fix weekday-header/week-number alignment to firstWeekday + tests
Root cause: a data-integrity bug, not visual. The month grid is built from
.weekOfMonth (respects Calendar.current.firstWeekday), but the weekday header
was hardcoded Sunday-first ("S M T W T F S") and the week-number label was gated
on isSunday (weekday == 1). On any non-Sunday-first locale (or when the user sets
"Start Week On" = Monday) every column was mislabeled by one and the W-number
landed on the wrong column — e.g. June 1 2026 (a Monday) appearing under "S".

Fix:
- Extract pure, testable date math into CalendarGrid (monthGrid + weekdaySymbols).
- Header now derived from firstWeekday, so it always matches the grid.
- Week-number label gated on isWeekStart (weekday == firstWeekday), not Sunday.
- Document that week numbers are locale weekOfYear (consistent with the layout),
  not ISO.
- gridDays() and the year-view mini-month dedupe to CalendarGrid.

Verification:
- New KisaniCalTests target with 9 tests, all passing on simulator: exact
  Sunday-first June 2026 grid, header↔grid alignment for firstWeekday 1...7,
  cell date identity, month navigation (May/Jul/Feb 2026 + Feb 2028 leap +
  Dec 2026→Jan 2027), and event-day bucketing (all-day, late-night, midnight-
  crossing) across 4 timezones.
- Verified in the running app: header S M T W T F S, May 31 in the Sunday
  column, June 1 under Monday, June 24 (Wednesday) selected.

Adds a DEBUG-only KISANI_INITIAL_TAB launch env (compiled out of Release) used
to screenshot the calendar past the sign-in gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 19:24:49 +03:00
kutesir
eeb60dc51b fix(tasks): timed tasks become overdue immediately when time passes (TickTick behavior)
Previous logic compared dueDate against startOfDay for all tasks, so a task
due at 2:00 PM stayed in "Today" until midnight even after the time passed.

TickTick rule:
- hasTime=true  → overdue the moment dueDate < now (wall clock)
- hasTime=false → overdue at start of next day (dueDate < startOfDay)

overdueTasks: now uses `dueDate < now` for timed tasks, `dueDate < today`
for date-only tasks.

todayTasks: now excludes timed tasks whose time has already passed — those
belong in overdue, not today — while keeping date-only tasks in Today for
the full calendar day.

Co-Authored-By: Kutesir <tqwyy79vzn@privaterelay.appleid.com>
2026-06-23 19:00:57 +03:00
kutesir
23d3d201f7 Settings/widget: drop decorative emoji; show real bundle version
- Remove 🎉 from the My Tasks widget empty state.
- Remove 𝕏 👾 📸 from Settings → Follow Us row.
- About row and About sheet now read the version from the bundle
  (new Bundle.marketingVersion / buildNumber / versionDisplay helpers)
  instead of a hardcoded "v1.0.0", so it always tracks the real build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 20:35:41 +03:00
kutesir
5cf32a4370 Calendar: fix Day/3-Day/Week/List task rendering; lighter checkbox; confirm future completes
Calendar timeline views only populated in Month. Fixes:
- Day/3-Day time grid dropped untimed (midnight) tasks and ignored
  recurrence. Now uses occurrences(on:) + hasTime, and adds an all-day
  band for untimed tasks, all-day events, and the day's workout.
- Week showed abstract color bars; now lists real task/event titles
  (up to 4/day + "N more") via a shared cvAgendaItems helper.
- List view and the task-list toggle now use occurrences(on:) so
  recurring tasks appear.

TodayView:
- Slimmer task checkbox (18pt open ring vs 22pt filled donut).
- Confirm before completing a future-dated task (Next 7 Days / Later)
  to prevent accidental completion; today/overdue still toggle instantly.

Bump build to 2.0 (9). Add App Store support-site one-pager.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 03:03:28 +03:00
kutesir
6a5275b60b Apply Xcode recommended project settings + lock to portrait
- Bump LastUpgradeCheck/scheme to Xcode 2620
- Enable DEAD_CODE_STRIPPING, ENABLE_USER_SCRIPT_SANDBOXING, and
  STRING_CATALOG_GENERATE_SYMBOLS across targets
- Set CURRENT_PROJECT_VERSION to 8
- Lock iPhone orientation to portrait; normalize iPad orientations
- Use explicitFileType for app/appex product references

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 19:07:48 +03:00
kutesir
9e01a7e7b5 Settings: add "Time Milestones" toggle for period notifications (KC-31 #2)
Adds a Customize → "Time Milestones" toggle bound to the App-Group
kisani.periodMilestones flag (so NotificationManager reads the same value).
Flipping it reschedules immediately — off clears the day/week/month/year
notifications, on re-schedules them. Defaults on. Closes the pending #2 from the
KC-31 audit; #6 (configurable thresholds/snooze) remains pending.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 00:10:47 +03:00
kutesir
694db506b6 Audit fixes: lock-screen sync, live notification urgency, batch postpone (KC-31)
- Lock-screen Mark Complete / Snooze now push to iCloud (CloudSyncManager) and
  reload widget timelines, so background completions reach iCloud/Watch/widgets
  immediately instead of waiting for the next app run.
- Notification copy ("Urgent — tap to mark complete" + evening check-in) now uses
  the live computed Matrix quadrant: reschedule snapshots urgent task IDs from
  displayQuadrant and threads them into scheduleTasks/notifBody, instead of the
  stale stored task.quadrant.
- postponeAllOverdue now mirrors single postpone (clears urgencyOverride and
  re-syncs the stored quadrant per task).
- ISSUES.md: added KC-31 (audit), corrected KC-30 (midnight day/week/month/year);
  left #2 (period-notif Settings toggle) and #6 (configurable thresholds/snooze)
  as pending; noted #1 (Move lists 3 quadrants) as intentional KC-25 behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 00:03:22 +03:00
kutesir
cb0dc3e271 Add Apple Watch app (TickTick-style) + midnight period notifications
WenzaWatch (new watchOS target, embed disabled until the watchOS platform is
installed — see project.yml note):
- Today list of actionable tasks (dated today/overdue + recurring occurrences),
  tap-to-complete with haptics, and add-via-dictation.
- Reads/writes the same tasks through the shared iCloud KV store that
  CloudSyncManager mirrors on the phone; watch entitlement uses the iOS app's
  kvstore identifier so they share one store. New tasks include all required
  TaskItem keys so the phone decodes them.
- NOTE: not compiled here (watchOS SDK absent on this machine); iOS build
  verified green with the embed commented out.

Period notifications: day/week/month/year now all fire at midnight (00:00),
adding the daily "One more day passed." (Pretty Progress style).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 23:50:37 +03:00
kutesir
bcb66ef691 Workout "mark all complete" + recurring-task done confirmation notification
- Workout window: the header ⋯ menu gains "Mark All Complete" and "Clear All
  Sets" (Manage Workouts preserved). New WorkoutViewModel.setAllSetsDone(_:)
  ticks every set across all sections/exercises and runs the normal completion/
  logging path.
- Recurring tasks: completing an occurrence now posts a quiet "✓ Done. Repeats
  <tomorrow / in 1 week / …>" confirmation via NotificationManager
  .notifyRecurringCompleted, phrased from the next active occurrence. Hooked
  into TaskViewModel.toggleOccurrence; un-completing does nothing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 23:20:13 +03:00
kutesir
e7e8d07100 ISSUES.md: log KC-27–30 and KC-26 tracked-event follow-up
- KC-26: documented the tracked-event auto-advance follow-up (trackedTask).
- KC-27: Today ⋯ menu — Background, Group & Sort, Select.
- KC-28: clearer Snooze labels on notifications and the in-app Postpone menu.
- KC-29: lock-screen Progress Dots legibility.
- KC-30: period milestone notifications (week / month / year).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 02:43:31 +03:00
kutesir
48e2f4fc10 Widgets/notifications: tracked-event auto-advance, lock-screen dots, Snooze labels, period milestones
- Bars widget: tracked ("Track Countdown") events now also advance — a completed
  or past (non-recurring) tracked event falls through to the next-nearest, and the
  refresh boundary covers the tracked event's expiry. Recurring tracked events
  still roll forward. Completes the KC-26 auto-advance for all paths.
- Progress Dots on the lock screen (accessoryRectangular): show 28 larger dots
  instead of 100 tiny ones, render with .primary for the vibrant tint, and use
  AccessoryWidgetBackground so it's legible (Home Screen keeps the 100-dot grid).
- Notifications: snooze action labels now read "Snooze 15 min/30 min/1 hour/
  2 hours" (lock screen) and "Snooze 15 Minutes…" (in-app Postpone menu).
- New period milestone notifications: recurring "One more week/month/year passed"
  at end of week (Sun 20:00), month (1st 09:00), and year (Jan 1 09:00); gated by
  a kisani.periodMilestones flag, scheduled via the existing reschedule path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 02:12:52 +03:00
kutesir
87c999c676 Today menu features, Matrix Move trim, widget auto-advance, Wenza logo (KC-23–26)
- Today ⋯ menu: implemented the placeholder actions.
  - Background: 5 selectable themes (Default/Warm/Cool/Mono/Paper), persisted.
  - Group & Sort: group by Date (default)/Priority/Category/Quadrant and sort by
    Smart/Due/Priority/Title; non-date groupings render via UpcomingSection.
  - Select: multi-select sheet with Select All/Deselect All and bulk
    Complete/Priority/Move/Delete.
  New isolated file TodayMenuFeatures.swift; added TaskViewModel.allActiveRows.
- Matrix "Move" submenu now lists only the other three quadrants (excludes the
  current one) using clear matrix labels.
- Event Countdown (Bars) widget auto-advances: each timeline entry re-resolves
  "nearest to finish" as of its own time and refreshes at the moment the current
  event expires, so it rolls to the next event on expiry/completion.
- Splash wordmark rebranded to "wenza." (regular-weight typewriter + orange dot).
- ISSUES.md: logged KC-23 (Pick Date + time-based reminders), KC-24 (Wenza
  rebrand), KC-25 (Move trim), KC-26 (widget auto-advance).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 19:53:14 +03:00
kutesir
81813530c8 Rebrand app to "Wenza" (display name + user-facing copy)
Renames the user-facing app to Wenza without changing identity:
- CFBundleDisplayName → "Wenza" (app) and "Wenza Widgets" (widget extension).
- Calendar/Health permission prompts now reference Wenza.
- In-app copy updated: sidebar title, Auth + Onboarding screens, Settings
  about/help rows, feedback email subject, tutorial text, widget description.

Bundle identifier, App Group, UserDefaults storage keys, team ID, and the
internal Xcode target/project names are intentionally unchanged, so this stays
the same App Store app with no user data loss. App Store listing name must be
changed separately in App Store Connect.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 18:31:51 +03:00
kutesir
ac147c55b0 Matrix/menus: Pick Date everywhere, time-based reminders, notification snooze (KC-21)
- Date menu now offers "Pick Date" (and Edit) in the Matrix grid cards and the
  Calendar reschedule/move menu, matching the tasks view — both open the shared
  TaskEditSheet via a new editingTask sheet.
- Reworked the reminder picker (TaskDatePickerSheet, shared by all entry points)
  from day/week offsets to sensible time-based ones relative to the event time:
  None, On time, 5 min, 30 min, 1 hour, 1 day, plus a Minutes/Hours/Days custom
  wheel. Existing absolute reminderDates migrate to the nearest minute offset.
- Notifications gain Snooze actions (15/30/60/120 min) that reschedule the task
  reminder; postpone now clears the Matrix urgency override.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 18:14:10 +03:00
kutesir
4b16e8587d Matrix: TickTick-faithful placement — axis-split urgency, category windows, Manual override (KC-21)
Refines the Eisenhower Matrix toward TickTick's "view over attributes" model:

- Split the single quadrant override into two axes: importance is always
  Priority (High = top row); urgency is date-derived unless manually pinned
  via the new `urgencyOverride` flag. Horizontal drags pin urgency instead of
  fabricating/destroying a real due date; vertical drags set Priority directly.
- A drag only flags "Manual" when the target column disagrees with the
  date-derived urgency, so dropping into a task's natural column stays on Auto.
- Tiles show a "Manual" badge; the task menu gains "Reset Matrix Urgency".
- Urgency window is now 3 days by default, widened to 14 for prep-heavy event
  types (birthday/domain/annual + subscription/renewal/exam/deadline titles).
- New tasks no longer default to today — no date stays no date (not urgent).
- Stored `quadrant` is kept synced to the computed placement so task color and
  widget accents follow the Matrix; Settings stats count by displayQuadrant.
- Completed tasks are hidden from the Matrix grid by default (toggle to show).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 13:22:25 +03:00
kutesir
fae56cb2a8 Matrix: backfill legacy task priorities so birthdays place correctly (KC-21)
The Priority defaults only fired at creation, so tasks saved before Priority
existed (e.g. birthdays) all had `.none` and fell to the Matrix bottom row
instead of Q2. Added a one-time, flag-guarded migration on load that applies
the KisaniCal category defaults (birthday/domain/annual/exam/subscription →
High, workout → Medium) to existing `.none`-priority tasks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 12:59:48 +03:00
kutesir
3592d2a9d8 Matrix: TickTick-style Priority×deadline view + Progress Dots widget (KC-21)
Matrix (KC-21): the Eisenhower Matrix is now a computed view of tasks.
Importance = Priority (High = top row), urgency = deadline proximity, so
tasks auto-place into Q1–Q4. Added a persisted `matrixOverride` so a manual
drag pins the task and syncs its Priority to that row (top → High, moving
down demotes High → Medium) and never gets forced back; editing Priority,
due date, or the editor clears the override to re-place live. New tasks get
KisaniCal priority defaults (birthday/domain/annual/exam/subscription → High,
workout → Medium). AddTaskSheet drops the manual quadrant picker for a
Priority menu (pick Priority + Date only).

Also includes in-progress Progress Dots widget work (day/week/month/custom
event modes, App-Group anchor, recurrence-aware spans).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 12:40:39 +03:00
kutesir
2b48509a35 Bars widget: recurrence-aware progress in ALL paths (KC-20 follow-up)
The real bug behind "Leona's Birthday, 5 days left" showing 0% filled:
Mode B (recurring span: previous -> next occurrence) only ran for
app-tracked events, while the upcoming-queue / picked-event paths still
anchored from first-seen (so a yearly birthday started at 0%).

- EventEntity now carries isRecurring/recurrenceLabel (populated from the
  shared task data) so the auto-select and picker paths know the rule.
- resolveSpan() centralizes the window: recurring -> prev/next occurrence
  everywhere; one-off -> explicit start / tracking date / first-seen.
- A yearly birthday 5 days away now reads ~98% (≈360/365 days elapsed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 23:20:46 +03:00
kutesir
b6eaba78ba Bars widget: app-driven "Track Countdown" with zero config (KC-20)
- Task long-press menu gains Track/Stop Tracking Countdown, storing the
  tracked event id + tracking date in the App Group (CountdownTracker)
  and reloading widget timelines.
- The widget reads the tracked event directly and bypasses its config:
  Mode A (one-off) = tracking date -> event date; Mode B (recurring) =
  previous occurrence -> next occurrence via the task's recurrence rule.
- WidgetTask decodes isRecurring/recurrenceLabel from the shared JSON.
- With nothing tracked, the configured modes remain the fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 23:05:33 +03:00
kutesir
a7639f6454 Widgets: align every widget to the exact logo palette (KC-19)
WidgetTheme now uses the true brand colors — accent RGB(232,98,42) on
the logo's dark RGB(26,29,34) — replacing a near-miss orange on slate.
Bars countdown, My Tasks, and Day Progress inherit it; Task Live
Activity drops its hardcoded accent/black tint for the theme; lock
screen rings tint brand orange.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 19:33:01 +03:00
kutesir
0606d9c3aa Bars widget: fix empty progress bar + dead-feeling date pickers (KC-18)
- Persist a per-event "first tracked" anchor in the App Group instead of
  resetting the progress start to now on every refresh (auto mode), so
  the bar actually fills toward the event. Explicit "Counting from" wins
  when it's before the event date.
- Make Event date / Counting from optional ("Choose") — the same-second
  defaults produced a zero-length span (always 0%) and looked broken.
- Pre-render timeline entries every 30 min (12h horizon) so the bar and
  time-left label move between refreshes instead of only at midnight.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 18:16:43 +03:00
kutesir
49b22b5f2f Widgets: remove Event Countdown (dots) + Tasks Done Today (KC-17)
Drop the dot-grid EventCountdownWidget and TasksCompleteWidget from the
gallery: widget structs, their views, the todayTaskCompletion() helper
(only consumer), and bundle registrations. Shared event plumbing
(EventConfigIntent/Provider/Entry, CycleCountdownUnitIntent) stays for
the surviving Event Countdown (Bars); DotGrid stays for Day Progress.
Remaining widgets untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 11:49:35 +03:00
kutesir
443fe8504f Workout check-in: missed state + rest-day compensation (KC-16)
- Notification actions on workout reminders/check-ins: "Yes, I completed
  it", "No, I didn't" (marks the day missed, then asks to compensate on a
  rest day), "Remind me in 1 hour" (snooze).
- VM: missedDates + compensations (persisted/synced), per-date
  markWorkoutDone/Missed, workoutStatus, upcomingRestDays,
  scheduleCompensation, promptCompensation, checkPendingMissed.
  program(for:) returns a compensation program on its rest day.
- CompensationSheet: pick a rest day or keep as missed; compensation days
  get a one-off "Missed workout recovery" reminder.
- Long-press menus: workout row (Done / Not Done / Compensate) in Today +
  Calendar; exercise cards (Mark as Done / Not Done). All actions are
  per-date — one occurrence never affects another.
- Workout row shows Pending / Done / Missed + "Compensation" label.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 23:06:58 +03:00
kutesir
f252fc7707 fix: remove unused 'cal' in statBuckets (warning)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 17:36:35 +03:00
kutesir
0362d586e5 Widgets: bars countdown widget + unified slate/orange theme (KC-15)
- New EventBarsWidget ("Event Countdown (Bars)") using BarGrid + EventBarsView,
  with a fine "X hr, Y min left" label; registered in the bundle.
- Re-themed every home-screen widget to the shared WidgetTheme (dark slate
  background + brand orange): Day Progress, Tasks Done Today, Event Countdown
  (dots), My Tasks. Lock-screen widgets keep .thinMaterial (system-tinted).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 17:31:55 +03:00
kutesir
063df4ba03 Widgets: add shared WidgetTheme + BarGrid (foundation)
Groundwork for the slate-background / brand-orange widget re-theme and the
new bar-style event countdown:
- WidgetTheme (dark slate background, brand orange accent, text tones).
- BarGrid: vertical-bar (comb) progress view.

Re-theming the existing widgets and adding the bars widget follows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 17:18:52 +03:00
kutesir
a191cebeed chore: set version 2.0 (5) to match current uploads
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 10:29:17 +03:00
kutesir
9768af0017 Add Workout Stats screen (daily/weekly/monthly) — KC-14
Aggregate the per-day workout logs into performance stats:
- WorkoutDayLog.volume + StatPeriod/WorkoutStat/StatBucket.
- WorkoutViewModel.stat/statInterval/statBuckets (current vs previous).
- WorkoutStatsView: Day/Week/Month selector, Workouts/Sets/Volume cards
  with %-delta vs previous period, metric chooser, and a Swift Charts bar
  trend. Reached via a chart button in the Workout header.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 10:21:12 +03:00
kutesir
c0eb1ffa23 docs: log KC-13 (recurrence redesign + classic Matrix)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 10:09:55 +03:00
kutesir
2f1bc5d1c3 Matrix: classic Eisenhower — you set importance, deadline sets urgency
Quadrant is now computed (importance x urgency) instead of a fixed
assignment. Importance comes from the task's assigned row (Q1/Q2 vs
Q3/Q4); urgency is derived from the (next active) due date being within
urgentWindowDays (7). So important tasks slide Q2->Q1 as they near,
unimportant slide Q4->Q3, and recurring tasks roll forward (a completed
occurrence is replaced by the next, never piling up in a quadrant).

- VM: displayQuadrant/isUrgent/effectiveDue; matrixTasks groups by the
  computed quadrant and uses each recurring task's single next occurrence.
- MatrixView grid + drill-down (overdue/later/completed) group by it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 10:00:29 +03:00
kutesir
b4e36db763 Tasks: single-occurrence recurrence + Today/Tomorrow/Next 7/Later + icons
Switch lists from calendar-expansion (every occurrence in every section)
to the TickTick model: one task contributes ONE next-active occurrence.

- VM: nextActiveOccurrence + activeRows; replace todayTasks/next3/upcoming
  with todayTasks/tomorrowTasks/next7DaysTasks/laterTasks buckets. A
  recurring task shows a single row; completing it advances to the next.
- TodayView: sections are now Today / Tomorrow / Next 7 Days / Later
  (UpcomingSection gained a title param).
- TaskRowView: add  (reminder) and 🔁 (recurring) indicators on the right;
  fix the hardcoded "Annual" label to show the real frequency.

Calendar grid keeps per-date occurrences (it's a date grid, not a list).
Matrix dynamic promotion (Problem 4) handled separately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 19:36:10 +03:00
kutesir
0870359ae8 Recurrence: add "repeat until" end date UI (KC-12)
Thread recurrenceEnd through NLParsed, TaskDatePickerSheet, the add/edit
sheets, and addTask/updateTask. The date picker now shows a "Repeat until"
row (graphical picker, default "Forever", with Clear) whenever a
recurrence is set; the engine already honored recurrenceEnd.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 10:47:13 +03:00
kutesir
6817d3e24a Recurring tasks: occurrence engine on calendar + lists (KC-12)
Recurring tasks only ever showed on their single dueDate. Add a
recurrence engine that expands occurrences per-date with independent
per-occurrence completion.

- TaskItem: recurrenceEnd + completedOccurrences (optional, safe decode).
- TaskViewModel: isOccurrence/occurrenceComplete/toggleOccurrence/
  occurrenceCopy/nextOccurrence/occurrences(on:); Daily/Weekly/Monthly/
  Yearly/Every-Weekday; respects recurrenceEnd; works across years.
- toggle() routes recurring rows to per-occurrence completion.
- Date filters re-inject per-date occurrence copies; recurring excluded
  from overdue. Calendar dots + day detail use occurrences(on:).

Also log KC-11 (year display) + KC-12.

Pending: "repeat until" date UI (engine already honors recurrenceEnd).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 01:41:15 +03:00
kutesir
7b2b61a228 Calendar: show the year (fix "can't navigate past 2026")
Navigation was unbounded, but headers only showed the month name so the
year change was invisible and future months felt unreachable.

- Main calendar header now shows "Month Year".
- Date-picker grid label shows the year.
- Year view gets a chevron year navigator (unbounded) + year label.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 00:48:17 +03:00
kutesir
d09a6c1fe3 Fix: per-date workout completion in calendar (KC-10)
The calendar marked every occurrence of a program complete once any one
was finished — including future dates — because DayTimelineView read the
program's live shared doneSets.

- Add WorkoutViewModel.isWorkoutComplete(on:) backed by workoutDates.
- DayTimelineView takes workoutDone and uses it instead of live sets;
  Calendar passes the selected date's state, Today passes today's.
- A date now shows complete only if that specific date was finished.

Log KC-10 in ISSUES.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 00:29:48 +03:00
kutesir
966c1b35e9 Workout: per-day history + log KC-8/KC-9
- Add per-day workout history: WorkoutDayLog/LoggedExercise/LoggedSet
  models stored at kisani.workout.history.<uid> (iCloud-synced).
- snapshotDay(_:) captures the active program's completed sets on full
  completion and before the daily reset; only days with >=1 done set.
- New WorkoutHistoryView (clock button in the Workout header) lists past
  days newest-first with per-exercise breakdown.
- Log KC-8 (auto-switch / Rest Day) and KC-9 (history) in ISSUES.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 23:12:46 +03:00
kutesir
2abb0c54e4 chore: stop tracking Xcode xcuserstate (UI-state noise)
The existing ignore rules were anchored to repo root, so the nested
*.xcodeproj/project.xcworkspace/xcuserdata/ never matched and kept
showing up as a change on every commit. Add **/xcuserdata/ and
*.xcuserstate and untrack the file.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 23:06:05 +03:00
kutesir
f9081cfcf7 Workout: auto-switch to today's program + Rest Day state
The Workout tab kept showing the last-used program (with its old
completion) regardless of the weekly schedule, so a rest day still
displayed a stale, fully-checked workout.

- On a new day, the active program now follows the weekly schedule
  (applyScheduledProgramForToday in resetSetsForNewDayIfNeeded).
- Add isRestDayToday + a Rest Day card shown when today has no scheduled
  program, with a "Work out anyway" override. Header reads "Rest Day".
- Daily set reset (KC-1) unchanged — no day starts pre-checked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 15:47:49 +03:00
kutesir
8e4a42666c docs: log KC-7 (iPad launch-screen upload failure) + fast-fix checklist
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 14:11:26 +03:00
kutesir
345415ee7b Bump build number to 2 for re-upload
The previous App Store upload failed on the iPad launch-screen check
because it was a stale archive built before the LaunchScreen fix
(72f4566). Bump CURRENT_PROJECT_VERSION so a fresh archive uploads
cleanly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 14:07:55 +03:00
kutesir
af72c4cc4e docs: trim proxy-hosts table from Sanctum dual-WAN runbook
Removes the published Proxy Hosts / Services table from the Sanctum
Auto-FailOver + Uptime Kuma runbook.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 12:44:06 +03:00
kutesir
7b98be1d8d Event widget: upcoming-queue auto-select + cleaner config & preview
- Add "Upcoming queue" toggle to the Event Countdown widget. When on, it
  auto-tracks the Nth nearest upcoming event (Nearest / 2nd … 5th, new
  AutoSelectRank enum). When off, track a picked or fully custom event.
- parameterSummary now shows only the relevant fields per mode (toggle +
  auto-select, or event/name/date), so the date input only appears for
  custom events.
- Replace the dense 380-day placeholder with a ~90-day window so the
  gallery preview shows a legible dot grid.

(The interactive task-checkbox widget already exists as "My Tasks".)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 12:43:02 +03:00
kutesir
72f4566d91 Fix iPad launch-screen validation failure
App Store upload rejected the bundle: iPad-multitasking apps must declare
a launch screen, but project.yml set an empty UILaunchStoryboardName ("")
and provided no UILaunchScreen.

Add a minimal LaunchScreen.storyboard (blank, systemBackgroundColor) and
point INFOPLIST_KEY_UILaunchStoryboardName at it. Regenerated the project.
Bundle now ships UILaunchStoryboardName=LaunchScreen + compiled
LaunchScreen.storyboardc; builds clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 12:20:41 +03:00
kutesir
196dd7b78e Calendar: shared store reliability + event notifications
KC-5: Consolidate calendar access into CalendarStore.shared so Today,
Calendar, Onboarding, and Settings share one permission state and event
cache. requestAccess() debounces and reads authoritative status; turning
on "Show Calendar Events" while unauthorized now prompts. New Settings →
Data → Calendar row shows Connected/Connect/Denied.

KC-6: Add calendar-event notifications. CalendarStore.upcomingEventNotifs
snapshots visible events (next 7 days); NotificationManager schedules at
start time plus each event alarm (all-day at 9am), capped at ~20 to stay
under iOS's 64-notification limit. Wired into reschedule().

Log KC-5 and KC-6 in ISSUES.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 04:08:23 +03:00
kutesir
0a4545f042 Share a single CalendarStore for unified permission state
Introduce CalendarStore.shared so Today, Calendar, and Onboarding read
the same auth status and event cache instead of each spinning up its own
EKEventStore. Trust the system's authoritative authorization status over
the granted bool, debounce repeat permission requests, route denied users
to Settings, and prompt when enabling local calendars without access.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 03:27:24 +03:00
kutesir
c0e80ab4ce docs: add Sanctum dual-WAN failover + Uptime Kuma runbook
Omada policy-routing setup that pins each monitoring host to a specific
ISP (Canal+/Airtel) so Uptime Kuma surfaces per-link failures instead of
masking them behind WAN failover.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 03:27:17 +03:00
kutesir
6be52fdac1 docs: log KC-4 (task menu + Live Activity) with test steps
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 19:46:39 +03:00
kutesir
d0d982f4f5 Add unified task menu, Live Activity, and calendar event actions
Task context menu (new shared TaskMenuItems) consistent across Today,
Matrix, and Calendar: Pin · Date · Move · Priority · Tags · Add to Live
Activity · Delete. Adds TaskViewModel.setPriority.

Live Activity (ActivityKit):
- TaskActivityAttributes shared between app and widget targets
- LiveActivityManager (start/update/end/toggle, iOS 16.1+)
- TaskLiveActivity widget: lock-screen banner + Dynamic Island
- NSSupportsLiveActivities via project.yml; project regenerated

Calendar events (non-subscription, writable only):
- Edit via system EKEventEditViewController (EventEditView wrapper)
- Delete via new CalendarStore.deleteEvent

Also sweeps in prior in-progress edits already present in the working
tree (AuthManager, NotificationManager, TaskItem, task views) and the
updated ISSUES.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 19:29:25 +03:00
kutesir
a92e9d4c09 Today view: reorder sections + keep RSS feeds out of tasks
- Move the Overdue card to sit directly below today's active tasks
  (above Next 3 Days) so your real tasks stay at the top.
- Filter subscription/RSS calendar feeds (F1, TV shows, etc.) out of the
  Today/Tasks timeline; they remain in the Calendar tab. Birthdays and
  personal calendars are unaffected.

Note: also carries prior in-progress TodayView edits that were already
in the working tree before this session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 19:29:25 +03:00
kutesir
800c7db76d fix: reset workout log each new day + new app logo
KC-1: Set completion was stored permanently on the program, so the same
program scheduled on back-to-back days (e.g. Shoulders Mon + Tue) kept
yesterday's checkmarks. Add resetSetsForNewDayIfNeeded() to clear every
set's isDone when the calendar day changes (cold launch, onAppear, and
scenePhase active). Streak history is untouched.

KC-2: Replace all 12 AppIcon sizes with the new KisaniCal mark, alpha
flattened onto RGB(26,29,34) so the 1024 App Store icon is fully opaque.

Add KisaniCal/ISSUES.md tracker (KC-1, KC-2: In Progress).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 19:29:25 +03:00
Robin Kutesa
324ddf9b0a feat: overdue tasks in red with quick postpone + task context menu
- Overdue card: red task titles, per-row "+1d" postpone, tappable complete.
- Task rows (overdue + day timeline) gain a press menu: Complete, Postpone,
  Pin/Unpin, Edit, Share, Delete.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 13:51:33 +03:00
Robin Kutesa
646cdf6a9b feat: My Tasks widget, event countdown redesign, completed section
- My Tasks widget: today's tasks with interactive check-off (ToggleTaskIntent)
  and a "+" that opens the app's add-task sheet; writes preserve all task
  fields and sync via the App Group.
- Event Countdown: AZURE/AWS-style header (accent name + countdown label +
  percent); tap the label to cycle days → weeks → time remaining.
- Today: collapsible "Completed" archive section below Upcoming.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 13:31:04 +03:00
Robin Kutesa
8b094691f5 feat: home/lock widgets, Health-aware streaks, workout editor, and entitlement fixes
Widgets (new KisaniCalWidgets extension)
- Event Countdown widget: configurable via AppIntent, pick from your events
  or set a custom name/date; dot-grid progress toward the event.
- Day Progress and Tasks-Done-Today widgets; lock screen widgets.
- Shared dot grid sizes circles to fill the widget evenly at any count.
- Tasks/calendar prefs now persist to the App Group so widgets read live data.

Health & streaks
- Persist HealthKit authorization and re-establish on launch (fixes the
  Today dashboard and streak sync disappearing after relaunch).
- Add a Health permission toggle to onboarding; unify Settings/Profile on the
  manager's state.
- Day streak counts from a logged Health workout OR marking all exercises
  complete; nudge to mark exercises complete even when Health logged the workout.

Workout editor
- Drag to reorder exercises and move them across sections (drag-and-drop);
  swipe to delete.
- Add-exercise sheet: global search and keep-adding-multiple with a running count.

Fixes
- Restore app entitlements link (Sign in with Apple, HealthKit, App Group, iCloud).
- Add HealthKit usage strings; widget Info.plist NSExtension + nested bundle id.
- Calendar "Connect" routes to Settings when access was denied.
- Bake DEVELOPMENT_TEAM and shared versions into project.yml.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 12:27:45 +03:00
88 changed files with 14438 additions and 789 deletions

86
.gitea/workflows/ci.yml Normal file
View 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

View 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
View File

@@ -1,4 +1,6 @@
# Xcode
**/xcuserdata/
*.xcuserstate
*.xcodeproj/xcuserdata/
*.xcworkspace/xcuserdata/
*.xcworkspace/contents.xcworkspacedata

61
CONTRIBUTING.md Normal file
View 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
View 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>

View File

@@ -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 = (

View File

@@ -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">

View 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() }
}

View 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)
}
}

View 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) [01]
// 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`, oldestnewest.
/// 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`, oldestnewest.
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) }
}
}

View 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 }
}

View 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 (dateKeyvolume) 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
}
}

View 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
}
}

View 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)
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@@ -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) }
}
}
}

View File

@@ -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

View File

@@ -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 voicefield 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

File diff suppressed because it is too large Load Diff

View 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>

View 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>

View 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
}
}

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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> {

View 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)
}
}
}
}
}

View File

@@ -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) }

View 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
}
}

View 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
}

View File

@@ -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 } }
}

View 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] }
}
}

View File

@@ -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) {

View File

@@ -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 27 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()
}
}

View File

@@ -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

View 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))
}
}

View File

@@ -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())

View 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))
}
}

View File

@@ -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.")

File diff suppressed because it is too large Load Diff

View File

@@ -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()
}
}

View File

@@ -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 {

View File

@@ -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))

View File

@@ -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)

View 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
}
}

View 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 }) }
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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(

View File

@@ -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 {

View 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
}
}

View 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
}
}

View 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))
}
}

View 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")
}
}

View 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:30pm11: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))
}
}

View 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"))
}
}

View 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)))
}
}

View 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)
}
}

View 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 (01).
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])
}
}

View 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>

View 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>

View 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()
}
}

View 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)
}
}

View 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()
}
}

View 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)
}
}
}
}

View 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
}

View 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 // 01
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
View 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
View 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

View 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
View 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 MonFri, 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
View 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>

View 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>

View 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
View 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 34) 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.

View File

@@ -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
View 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
View 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>