Compare commits

...

50 Commits

Author SHA1 Message Date
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
58 changed files with 7240 additions and 554 deletions

2
.gitignore vendored
View File

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

View File

@@ -7,63 +7,124 @@
objects = {
/* Begin PBXBuildFile section */
06CA0F336E64D9F6D56F7472 /* CloudSyncManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 449C34805DC6B2CB66886544 /* CloudSyncManager.swift */; };
096804560A2F0A7D74E64780 /* TaskItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC2AFB4FA765383740767CB /* TaskItem.swift */; };
12E42CE8B8E535FAE6268A0C /* AppGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF905C574F34B4EE51A8D21E /* AppGroup.swift */; };
13E4B9854F595394FC9D5912 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC5E179A9189B0A8C3F856F6 /* ContentView.swift */; };
1A22EE21460821170E44B1DF /* ExerciseModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5722CC4B59E3939724142710 /* ExerciseModels.swift */; };
205846F9651EDCE0D8207358 /* TaskLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0513EB8C7F6B6853B9E93E09 /* TaskLiveActivity.swift */; };
2CD2313EDC4BFCE6E89C6787 /* AppGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF905C574F34B4EE51A8D21E /* AppGroup.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 */; };
45AA93D76970B39DB8BA6A5B /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = C5EF0E7944C7F0763B83BB0F /* LaunchScreen.storyboard */; };
497732557745AE9BDA44FB2F /* ShortcutHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 326B77A7B317A7DB44E13EA5 /* ShortcutHandler.swift */; };
552E2F85B01C9CC437D383B5 /* EventCountdownWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC17C7BA15BD2E84AE5F77CF /* EventCountdownWidget.swift */; };
55F00C433F853F7B54F136B3 /* WidgetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 429806CE1021C8DE2EB770CE /* WidgetViews.swift */; };
5EF4A5B6CE91ADA0CCF72D0D /* ProfileSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C96EC00F6B021DBA3CC1A79 /* ProfileSetupView.swift */; };
67CE1747E5DB3FDA79D0FDFD /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 106EEF572C6F8990408329F0 /* OnboardingView.swift */; };
6921CB73A3257502FF778381 /* SplashView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE2D50B4B4AC227BD21F4B60 /* SplashView.swift */; };
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 */; };
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 */; };
9E3F966F6B6AF2E4F8109E51 /* WorkoutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20DAD771EFCC8B3B1DBD4DD6 /* WorkoutView.swift */; };
A1B2C3D4E5F6A7B8C9D0E1F2 /* TutorialManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0B1C2D3E4F5A6B7C8D9E0F1 /* TutorialManager.swift */; };
A1DBC4D2F09C02B8CEE6449E /* AddExerciseSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 768D4E314252E2A90A06CCF2 /* AddExerciseSheet.swift */; };
A3B2D6622A2EE35C8D5A3C9B /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72308FEE0226F45414C04DDD /* SettingsView.swift */; };
A9FF93259AE8FF0ABF69D71A /* DesignTokens.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD4A35C0E1270E2E15C03F23 /* DesignTokens.swift */; };
B2C3D4E5F6A7B8C9D0E1F2A3 /* TutorialView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C2D3E4F5A6B7C8D9E0F1A2 /* TutorialView.swift */; };
B38135962FCAF9CE0037DC41 /* HealthKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B38135952FCAF9CE0037DC41 /* HealthKitManager.swift */; };
AC0D814DA54D5EF5E25CEB99 /* WidgetData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61BFF3BA28331A16D0ADE9D0 /* WidgetData.swift */; };
BD0DB4B0AA8A63D124EDFF2C /* HealthKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF5AFD143B693B77D07FBDA4 /* HealthKitManager.swift */; };
BEAFF968632A34C70B11C5AC /* MatrixView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D44530A77DF12A17E52AAF34 /* MatrixView.swift */; };
C7767143D9617ECA04ED1935 /* KisaniCalWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42650F655DB8B320C7C03929 /* KisaniCalWidgets.swift */; };
C79C33BE2802E81AA00175CC /* TodayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C786EBC7DF879D64EB28165E /* TodayView.swift */; };
CBE7295BF5ADE08FE93AFAAF /* FloatingTabState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9100804DB1E61EA882CC54DA /* FloatingTabState.swift */; };
D591A72235A53D4038FBC2B4 /* KisaniCalApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001F67ADC72FBAEC03EB7E01 /* KisaniCalApp.swift */; };
CC11DD22EE33FF44AA55BB66 /* CloudSyncManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC00DD11EE22FF33AA44BB55 /* CloudSyncManager.swift */; };
ECEAA5CF7309E5993D12B571 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AD014B7E3E30A34E18696A0 /* AuthView.swift */; };
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 */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
D04FCEE0BDF30AEFD1C969B6 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = AF6DE7A812408E3742522E90 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 28B43516AD88946E21D9BFE0;
remoteInfo = KisaniCalWidgets;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
B3BE3000B362E4DDC4EE9E76 /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
79FC1DF6762C6F02D01AB643 /* KisaniCalWidgets.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
001F67ADC72FBAEC03EB7E01 /* KisaniCalApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KisaniCalApp.swift; sourceTree = "<group>"; };
01A27D42E141DC056D32C1A3 /* TutorialManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialManager.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; };
106EEF572C6F8990408329F0 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
1AA7498EFED7692022F3E7E1 /* KisaniCal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = KisaniCal.entitlements; 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>"; };
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>"; };
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>"; };
72308FEE0226F45414C04DDD /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
72FDF9C8DD37134576356B89 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
768D4E314252E2A90A06CCF2 /* AddExerciseSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddExerciseSheet.swift; sourceTree = "<group>"; };
7A9D47B284FD6A217AEF813B /* WenzaWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = WenzaWatch.app; sourceTree = BUILT_PRODUCTS_DIR; };
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>"; };
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>"; };
9BD1F84B4F45B12CB9B9F1B2 /* KisaniCal.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = KisaniCal.app; sourceTree = BUILT_PRODUCTS_DIR; };
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>"; };
D44530A77DF12A17E52AAF34 /* MatrixView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MatrixView.swift; sourceTree = "<group>"; };
DCC2AFB4FA765383740767CB /* TaskItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskItem.swift; sourceTree = "<group>"; };
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>"; };
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 */
@@ -85,10 +146,22 @@
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 */,
E8D727EEA0C3A4B8006FB087 /* KisaniCalWidgets */,
48146B56E740528496663D47 /* WenzaWatch */,
51BD1B5DEDE9FAD9CA2FF6DA /* Products */,
);
sourceTree = "<group>";
@@ -97,6 +170,8 @@
isa = PBXGroup;
children = (
9BD1F84B4F45B12CB9B9F1B2 /* KisaniCal.app */,
0D6D6740721F01A74E404EB9 /* KisaniCalWidgets.appex */,
7A9D47B284FD6A217AEF813B /* WenzaWatch.app */,
);
name = Products;
sourceTree = "<group>";
@@ -104,16 +179,18 @@
5E9A0E064E153429180400E6 /* Views */ = {
isa = PBXGroup;
children = (
F7A8B9C0D1E2F3A4B5C6D7E8 /* AuthView.swift */,
F8A9B0C1D2E3F4A5B6C7D8E9 /* ProfileSetupView.swift */,
B1C2D3E4F5A6B7C8D9E0F1A2 /* TutorialView.swift */,
768D4E314252E2A90A06CCF2 /* AddExerciseSheet.swift */,
4AD014B7E3E30A34E18696A0 /* AuthView.swift */,
ADF6CCD95A587E26E30F5712 /* CalendarView.swift */,
D44530A77DF12A17E52AAF34 /* MatrixView.swift */,
106EEF572C6F8990408329F0 /* OnboardingView.swift */,
0C96EC00F6B021DBA3CC1A79 /* ProfileSetupView.swift */,
72308FEE0226F45414C04DDD /* SettingsView.swift */,
BE2D50B4B4AC227BD21F4B60 /* SplashView.swift */,
670A8C6F8243EC973A1BC431 /* TaskContextMenu.swift */,
ED5DB5340BEB3EF7A78CA153 /* TodayMenuFeatures.swift */,
C786EBC7DF879D64EB28165E /* TodayView.swift */,
FA3D5289C5F93484E22DEB63 /* TutorialView.swift */,
20DAD771EFCC8B3B1DBD4DD6 /* WorkoutView.swift */,
);
path = Views;
@@ -135,13 +212,30 @@
path = Theme;
sourceTree = "<group>";
};
E8D727EEA0C3A4B8006FB087 /* KisaniCalWidgets */ = {
isa = PBXGroup;
children = (
FC17C7BA15BD2E84AE5F77CF /* EventCountdownWidget.swift */,
8DC8687EA2FBA9FB2EEE51C6 /* Info.plist */,
8E7FC6446D5C5B2A2D9387DA /* KisaniCalWidgets.entitlements */,
42650F655DB8B320C7C03929 /* KisaniCalWidgets.swift */,
208F82348DEBD9FF7B0DCF17 /* MyTasksWidget.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 */,
21B93C269F283F11B415B18C /* Components */,
FB9BF734B9E493EEB09ACE21 /* Managers */,
23CBCF100C5EF55E737379CA /* Models */,
@@ -155,12 +249,15 @@
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 */,
FEEB07EB89E89383C32ADB34 /* TaskActivityAttributes.swift */,
01A27D42E141DC056D32C1A3 /* TutorialManager.swift */,
);
path = Managers;
sourceTree = "<group>";
@@ -168,16 +265,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 +302,23 @@
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";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -193,9 +326,18 @@
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 2620;
LastUpgradeCheck = 1500;
TargetAttributes = {
28B43516AD88946E21D9BFE0 = {
DevelopmentTeam = K8BLMMR883;
ProvisioningStyle = Automatic;
};
297C0C2BFBA10AB52D5D49CE = {
DevelopmentTeam = K8BLMMR883;
ProvisioningStyle = Automatic;
};
403D989BDC6268361CFFB479 = {
DevelopmentTeam = K8BLMMR883;
ProvisioningStyle = Automatic;
};
};
@@ -215,6 +357,8 @@
projectRoot = "";
targets = (
297C0C2BFBA10AB52D5D49CE /* KisaniCal */,
28B43516AD88946E21D9BFE0 /* KisaniCalWidgets */,
403D989BDC6268361CFFB479 /* WenzaWatch */,
);
};
/* End PBXProject section */
@@ -225,6 +369,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 +378,75 @@
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
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 */,
A1DBC4D2F09C02B8CEE6449E /* AddExerciseSheet.swift in Sources */,
2CD2313EDC4BFCE6E89C6787 /* AppGroup.swift in Sources */,
3B57EA3600AFE975850DF39A /* AuthManager.swift in Sources */,
ECEAA5CF7309E5993D12B571 /* AuthView.swift in Sources */,
9070521B1D36A5551976C275 /* CalendarView.swift in Sources */,
06CA0F336E64D9F6D56F7472 /* CloudSyncManager.swift in Sources */,
13E4B9854F595394FC9D5912 /* ContentView.swift in Sources */,
A9FF93259AE8FF0ABF69D71A /* DesignTokens.swift in Sources */,
1A22EE21460821170E44B1DF /* ExerciseModels.swift in Sources */,
CBE7295BF5ADE08FE93AFAAF /* FloatingTabState.swift in Sources */,
BD0DB4B0AA8A63D124EDFF2C /* HealthKitManager.swift in Sources */,
D591A72235A53D4038FBC2B4 /* KisaniCalApp.swift in Sources */,
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 */,
67CE1747E5DB3FDA79D0FDFD /* OnboardingView.swift in Sources */,
5EF4A5B6CE91ADA0CCF72D0D /* ProfileSetupView.swift in Sources */,
A3B2D6622A2EE35C8D5A3C9B /* SettingsView.swift in Sources */,
8BE9D3D650B0F06169EC7048 /* SharedComponents.swift in Sources */,
497732557745AE9BDA44FB2F /* ShortcutHandler.swift in Sources */,
6921CB73A3257502FF778381 /* SplashView.swift in Sources */,
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 */,
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 */,
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 */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
0CEA6637F45C32A237FBA20D /* Release */ = {
isa = XCBuildConfiguration;
@@ -274,27 +456,24 @@
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_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;
@@ -336,11 +515,11 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 9;
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 +529,33 @@
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;
};
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 +590,11 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 9;
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 +610,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 +682,24 @@
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_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 +710,24 @@
/* 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;
};
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"
@@ -38,6 +40,8 @@
</MacroExpansion>
<Testables>
</Testables>
<CommandLineArguments>
</CommandLineArguments>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
@@ -59,6 +63,8 @@
ReferencedContainer = "container:KisaniCal.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
@@ -76,6 +82,8 @@
ReferencedContainer = "container:KisaniCal.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">

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

@@ -1,4 +1,5 @@
import SwiftUI
import WidgetKit
struct ContentView: View {
@StateObject private var taskVM = TaskViewModel()
@@ -67,6 +68,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 +93,43 @@ 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()
// Pull workouts from Health, then reschedule so a detected workout updates reminders.
Task {
await workoutVM.syncFromHealthKit()
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
}
}
.onChange(of: scenePhase) { phase in
if phase == .active {
taskVM.reload()
consumeWidgetAddTask()
workoutVM.resetSetsForNewDayIfNeeded()
workoutVM.checkPendingHealthConfirm()
workoutVM.checkPendingMissed()
CloudSyncManager.shared.backupSettings()
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
Task { await workoutVM.syncFromHealthKit() }
Task {
await workoutVM.syncFromHealthKit()
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
}
}
}
.onChange(of: taskVM.tasks) { _ in
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
WidgetCenter.shared.reloadAllTimelines()
}
.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)
@@ -199,7 +230,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 {

1021
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)
}
}
@@ -85,7 +85,7 @@ final class CloudSyncManager {
for key in changedKeys {
if let val = kv.object(forKey: key) {
UserDefaults.standard.set(val, forKey: key)
UserDefaults.kisani.set(val, forKey: key)
}
}

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 {

View File

@@ -0,0 +1,68 @@
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 {
await activity.end(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,11 @@ 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"
override private init() {
super.init()
@@ -26,12 +30,23 @@ 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])
@@ -40,7 +55,7 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
// MARK: - Complete task directly in UserDefaults (called from background)
private func markTaskComplete(taskId: String, userId: String) {
let key = "kisani.tasks.v2.\(userId)"
guard let data = UserDefaults.standard.data(forKey: key),
guard let data = UserDefaults.kisani.data(forKey: key),
var tasks = try? JSONDecoder().decode([TaskItem].self, from: data),
let uuid = UUID(uuidString: taskId),
let idx = tasks.firstIndex(where: { $0.id == uuid })
@@ -48,10 +63,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 +119,195 @@ 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.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: - 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 +322,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 +336,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 +363,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 +376,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 +432,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 +463,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 +503,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,7 +529,7 @@ extension NotificationManager: UNUserNotificationCenterDelegate {
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
let userId = UserDefaults.standard.string(forKey: AuthManager.activeUserIdKey) ?? "shared"
let userId = UserDefaults.kisani.string(forKey: AuthManager.activeUserIdKey) ?? "shared"
let info = response.notification.request.content.userInfo
let deeplink = info["deeplink"] as? String
let taskId = info["taskId"] as? String
@@ -287,10 +539,31 @@ extension NotificationManager: UNUserNotificationCenterDelegate {
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)
))
default:
if let taskId { markTaskComplete(taskId: taskId, userId: userId) }

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.",

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,6 +233,16 @@ class WorkoutViewModel: ObservableObject {
@Published var activeProgramId: UUID
@Published var activeSections: [WorkoutSection]
@Published var workoutDates: [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
@@ -208,6 +274,10 @@ class WorkoutViewModel: ObservableObject {
private let kSchedule: String
private let kActivePid: String
private let kWorkoutDates: 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,6 +285,10 @@ class WorkoutViewModel: ObservableObject {
self.kSchedule = "kisani.workout.schedule.\(uid)"
self.kActivePid = "kisani.workout.activePid.\(uid)"
self.kWorkoutDates = "kisani.workout.completedDates.\(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)
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.programs.\(uid)")
@@ -223,7 +297,7 @@ class WorkoutViewModel: ObservableObject {
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.completedDates.\(uid)")
// Attempt to restore persisted state
if let data = UserDefaults.standard.data(forKey: kPrograms),
if let data = UserDefaults.kisani.data(forKey: kPrograms),
let saved = try? JSONDecoder().decode([WorkoutProgram].self, from: data),
!saved.isEmpty {
@@ -254,7 +328,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 +336,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,25 +357,156 @@ class WorkoutViewModel: ObservableObject {
activeProgramId = blank.id
activeSections = blank.sections
}
workoutDates = UserDefaults.standard.stringArray(forKey: kWorkoutDates) ?? []
workoutDates = UserDefaults.kisani.stringArray(forKey: kWorkoutDates) ?? []
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(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)
}
// 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)
save()
@@ -326,8 +531,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 +568,90 @@ 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))
}
// 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
}
/// 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 +689,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,6 +743,8 @@ 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()
}
@@ -456,6 +753,37 @@ class WorkoutViewModel: ObservableObject {
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()
if doneSets == totalSets && totalSets > 0 { logWorkoutCompleted() }
}
/// Mark every set of the active workout done/undone the "Select all & mark
/// complete" quick action. Logs completion when everything is checked.
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()
if done, doneSets == totalSets && totalSets > 0 { logWorkoutCompleted() }
}
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 +808,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 +850,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 +859,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 +882,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,4 +1,5 @@
import SwiftUI
import WidgetKit
// MARK: - Task Model
struct TaskItem: Identifiable, Codable, Equatable {
@@ -18,6 +19,13 @@ struct TaskItem: Identifiable, Codable, Equatable {
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
}
enum Priority: String, Codable, CaseIterable, Identifiable {
@@ -129,17 +137,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 +179,96 @@ 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()
}
}
var overdueTasks: [TaskItem] {
let startOfDay = Calendar.current.startOfDay(for: Date())
return tasks.filter { !$0.isComplete && ($0.dueDate ?? .distantFuture) < startOfDay }
}
var todayTasks: [TaskItem] {
let start = Calendar.current.startOfDay(for: Date())
let end = Calendar.current.date(byAdding: .day, value: 1, to: start)!
return tasks
.filter { !$0.isComplete && ($0.dueDate ?? .distantFuture) >= start && ($0.dueDate ?? .distantFuture) < end }
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
}
var upcomingTasks: [TaskItem] {
// 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
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 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
}
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
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) }
return dayTasks.isEmpty ? nil : (day, dayTasks)
}
var overdueTasks: [TaskItem] {
let today = Calendar.current.startOfDay(for: Date())
// Non-recurring past-due only recurring surfaces its next occurrence instead.
return tasks.filter { !recurs($0) && !$0.isComplete && ($0.dueDate ?? .distantFuture) < today }
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
}
var todayTasks: [TaskItem] { bucket(from: 0, to: 1) } // due today
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) }
}
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 +280,185 @@ 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).
func isOccurrence(_ t: TaskItem, on date: Date) -> Bool {
guard recurs(t), let start = t.dueDate else { return false }
let cal = Calendar.current
let d0 = cal.startOfDay(for: date), s0 = cal.startOfDay(for: start)
guard d0 >= s0 else { return false }
if let end = t.recurrenceEnd, d0 > cal.startOfDay(for: end) { return false }
switch t.recurrenceLabel {
case "Daily": return true
case "Every Weekday":
let wd = cal.component(.weekday, from: d0) // 1=Sun 7=Sat
return wd >= 2 && wd <= 6
case "Weekly": return ((cal.dateComponents([.day], from: s0, to: d0).day ?? 0) % 7) == 0
case "Monthly": return cal.component(.day, from: s0) == cal.component(.day, from: d0)
case "Yearly": return cal.component(.day, from: s0) == cal.component(.day, from: d0)
&& cal.component(.month, from: s0) == cal.component(.month, from: d0)
default: return false
}
}
func occurrenceComplete(_ t: TaskItem, on date: Date) -> Bool {
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)
}
}
/// 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,38 +468,90 @@ 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,
endDate: Date? = nil, isAllDay: Bool = false,
priority: Priority = .none, reminderDate: Date? = nil) {
tasks.append(TaskItem(title: title, dueDate: dueDate, hasTime: hasTime,
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: priority, reminderDate: reminderDate))
priority: resolvedPriority, reminderDate: reminderDate,
constantReminder: constantReminder)
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) {
guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
tasks[idx].title = title
tasks[idx].dueDate = dueDate
@@ -253,6 +561,10 @@ class TaskViewModel: ObservableObject {
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 +572,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 +599,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 +610,3 @@ class TaskViewModel: ObservableObject {
save()
}
}

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

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

View File

@@ -1,10 +1,25 @@
import SwiftUI
import EventKit
import EventKitUI
// Plain snapshot of a calendar event for scheduling notifications off the main actor.
struct CalEventNotif {
let id: String
let title: String
let start: Date
let isAllDay: Bool
let alarmOffsets: [TimeInterval] // seconds relative to start (negative = before)
}
// MARK: - Calendar Store
@MainActor
final class CalendarStore: ObservableObject {
/// Single shared source of truth so every screen (Today, Calendar, Onboarding,
/// Settings) sees the same permission state and event cache.
static let shared = CalendarStore()
@Published var authStatus: EKAuthorizationStatus = .notDetermined
@Published private(set) var isRequesting = false
@Published var monthEvents: [EKEvent] = []
private let ekStore = EKEventStore()
@@ -22,9 +37,13 @@ final class CalendarStore: ObservableObject {
init() {
authStatus = EKEventStore.authorizationStatus(for: .event)
hiddenCalendarIDs = Set(UserDefaults.standard.stringArray(forKey: "kisani.cal.hiddenIDs") ?? [])
localCalendarsEnabled = UserDefaults.standard.object(forKey: "kisani.cal.localEnabled") as? Bool ?? true
doNotDisturb = UserDefaults.standard.bool(forKey: "kisani.cal.dnd")
// Migrate legacy prefs from UserDefaults.standard into the App Group (one-time).
Self.migrateCalPrefIfNeeded(hiddenIDsKey)
Self.migrateCalPrefIfNeeded(calEnabledKey)
Self.migrateCalPrefIfNeeded(dndKey)
hiddenCalendarIDs = Set(UserDefaults.kisani.stringArray(forKey: hiddenIDsKey) ?? [])
localCalendarsEnabled = UserDefaults.kisani.object(forKey: calEnabledKey) as? Bool ?? true
doNotDisturb = UserDefaults.kisani.bool(forKey: dndKey)
changeToken = NotificationCenter.default.addObserver(
forName: .EKEventStoreChanged,
object: ekStore,
@@ -45,7 +64,10 @@ final class CalendarStore: ObservableObject {
let current = EKEventStore.authorizationStatus(for: .event)
guard current != authStatus else { return }
authStatus = current
if isAuthorized { fetchMonth(containing: fetchedMonth ?? Date()) }
if isAuthorized {
fetchMonth(containing: fetchedMonth ?? Date())
loadLocalCalendars()
}
}
deinit {
@@ -55,20 +77,32 @@ final class CalendarStore: ObservableObject {
// MARK: - Permission
func requestAccess() {
// Debounce rapid taps; only the system prompt can move us off .notDetermined.
guard !isRequesting else { return }
guard authStatus == .notDetermined else {
// Already decided (granted/denied/write-only) re-sync from the system.
refreshStatus()
return
}
isRequesting = true
// Trust the system's authoritative status, not just the `granted` bool.
let finish: () -> Void = { [weak self] in
Task { @MainActor [weak self] in
guard let self else { return }
self.authStatus = EKEventStore.authorizationStatus(for: .event)
self.isRequesting = false
if self.isAuthorized {
self.fetchMonth(containing: self.fetchedMonth ?? Date())
self.loadLocalCalendars()
}
}
}
if #available(iOS 17.0, *) {
ekStore.requestFullAccessToEvents { [weak self] granted, _ in
Task { @MainActor [weak self] in
self?.authStatus = granted ? .fullAccess : .denied
if granted { self?.fetchMonth(containing: Date()); self?.loadLocalCalendars() }
}
}
ekStore.requestFullAccessToEvents { _, _ in finish() }
} else {
ekStore.requestAccess(to: .event) { [weak self] granted, _ in
Task { @MainActor [weak self] in
self?.authStatus = granted ? .authorized : .denied
if granted { self?.fetchMonth(containing: Date()); self?.loadLocalCalendars() }
}
}
ekStore.requestAccess(to: .event) { _, _ in finish() }
}
}
@@ -108,6 +142,51 @@ final class CalendarStore: ObservableObject {
return authStatus == .authorized
}
/// Upcoming visible events over the next `days`, as plain snapshots for the
/// notification scheduler. Honors the calendar-enabled flag and hidden calendars.
func upcomingEventNotifs(days: Int) -> [CalEventNotif] {
guard isAuthorized, localCalendarsEnabled else { return [] }
let now = Date()
guard let end = Calendar.current.date(byAdding: .day, value: days, to: now) else { return [] }
let cals = ekStore.calendars(for: .event).filter { !hiddenCalendarIDs.contains($0.calendarIdentifier) }
guard !cals.isEmpty else { return [] }
let pred = ekStore.predicateForEvents(withStart: now, end: end, calendars: cals)
return ekStore.events(matching: pred)
.sorted { ($0.startDate ?? .distantFuture) < ($1.startDate ?? .distantFuture) }
.compactMap { ev in
guard let start = ev.startDate else { return nil }
let offsets: [TimeInterval] = (ev.alarms ?? []).map { alarm in
if let abs = alarm.absoluteDate { return abs.timeIntervalSince(start) }
return alarm.relativeOffset
}
return CalEventNotif(
id: ev.eventIdentifier ?? UUID().uuidString,
title: ev.title ?? "Event",
start: start,
isAllDay: ev.isAllDay,
alarmOffsets: offsets
)
}
}
/// Exposed so the edit sheet can hand it to EKEventEditViewController.
var eventStore: EKEventStore { ekStore }
/// Delete a real (writable) calendar event, then refresh the month cache.
func deleteEvent(_ event: EKEvent) {
guard event.calendar?.allowsContentModifications == true else { return }
do {
try ekStore.remove(event, span: .thisEvent)
fetchMonth(containing: event.startDate ?? Date())
} catch {
print("Delete event failed: \(error.localizedDescription)")
}
}
/// Only `.notDetermined` can show the system prompt; otherwise we must deep-link to Settings.
var canRequest: Bool { authStatus == .notDetermined }
var isDenied: Bool { authStatus == .denied || authStatus == .restricted }
// MARK: - Local Calendar Management
func loadLocalCalendars() {
@@ -132,17 +211,30 @@ final class CalendarStore: ObservableObject {
func setLocalCalendarsEnabled(_ enabled: Bool) {
localCalendarsEnabled = enabled
saveCalPrefs()
// Turning it on with no access yet should prompt, not silently no-op.
if enabled && !isAuthorized { requestAccess() }
if let m = fetchedMonth { fetchMonth(containing: m) }
}
func setDoNotDisturb(_ on: Bool) {
doNotDisturb = on
UserDefaults.standard.set(on, forKey: dndKey)
UserDefaults.kisani.set(on, forKey: dndKey)
CloudSyncManager.shared.push(value: on, forKey: dndKey)
}
private func saveCalPrefs() {
UserDefaults.standard.set(Array(hiddenCalendarIDs), forKey: hiddenIDsKey)
UserDefaults.standard.set(localCalendarsEnabled, forKey: calEnabledKey)
UserDefaults.kisani.set(Array(hiddenCalendarIDs), forKey: hiddenIDsKey)
UserDefaults.kisani.set(localCalendarsEnabled, forKey: calEnabledKey)
CloudSyncManager.shared.push(value: Array(hiddenCalendarIDs), forKey: hiddenIDsKey)
CloudSyncManager.shared.push(value: localCalendarsEnabled, forKey: calEnabledKey)
}
private static func migrateCalPrefIfNeeded(_ key: String) {
if UserDefaults.kisani.object(forKey: key) == nil,
let legacy = UserDefaults.standard.object(forKey: key) {
UserDefaults.kisani.set(legacy, forKey: key)
UserDefaults.standard.removeObject(forKey: key)
}
}
func groupedCalendars() -> [CalendarGroup] {
@@ -194,7 +286,7 @@ struct CalendarView: View {
@Environment(\.scenePhase) private var scenePhase
@EnvironmentObject var taskVM: TaskViewModel
@EnvironmentObject var workoutVM: WorkoutViewModel
@StateObject private var calStore = CalendarStore()
@StateObject private var calStore = CalendarStore.shared
@State private var selectedDate: Date = Date()
@State private var displayMonth: Date = {
let c = Calendar.current
@@ -205,6 +297,8 @@ struct CalendarView: View {
@State private var showAddTask = false
@State private var showCalendarConnect = false
@State private var showWorkoutSession = false
@State private var editingEvent: EditableEvent? = nil
@State private var editingTask: TaskItem? = nil
private let cal = Calendar.current
private let weekdays = ["S","M","T","W","T","F","S"]
@@ -233,9 +327,9 @@ struct CalendarView: View {
}
.menuStyle(.automatic)
// Center: month name
// Center: month + year
Spacer()
Text(monthShortTitle)
Text(monthTitle)
.font(AppFonts.sans(17, weight: .semibold))
.foregroundColor(AppColors.text(cs))
Spacer()
@@ -312,6 +406,18 @@ struct CalendarView: View {
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
.sheet(item: $editingEvent) { wrapper in
EventEditView(event: wrapper.event, store: calStore.eventStore) {
calStore.fetchMonth(containing: selectedDate)
}
.ignoresSafeArea()
}
.sheet(item: $editingTask) { task in
TaskEditSheet(task: task)
.environmentObject(taskVM)
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
}
.onAppear {
calStore.refreshStatus()
if calStore.isAuthorized { calStore.fetchMonth(containing: displayMonth) }
@@ -364,9 +470,9 @@ struct CalendarView: View {
dots.append(calColor)
}
}
let taskDots = taskVM.tasks
// Includes recurring occurrences that land on this date.
let taskDots = taskVM.occurrences(on: date)
.filter { !$0.isComplete }
.filter { guard let d = $0.dueDate else { return false }; return cal.isDate(d, inSameDayAs: date) }
.prefix(max(0, 3 - dots.count))
.map { $0.quadrant.color }
dots.append(contentsOf: taskDots)
@@ -376,10 +482,7 @@ struct CalendarView: View {
// MARK: - Task list mode (toggle view)
private var calDayTaskListView: some View {
let all = taskVM.tasks.filter { t in
guard let d = t.dueDate else { return false }
return cal.isDate(d, inSameDayAs: selectedDate)
}
let all = taskVM.occurrences(on: selectedDate)
let incomplete = all.filter { !$0.isComplete }
let complete = all.filter { $0.isComplete }
@@ -482,12 +585,18 @@ struct CalendarView: View {
ScrollView(showsIndicators: false) {
DayTimelineView(
date: selectedDate,
tasks: taskVM.tasks.filter { t in
guard let d = t.dueDate else { return false }
return cal.isDate(d, inSameDayAs: selectedDate)
},
tasks: taskVM.occurrences(on: selectedDate),
calEvents: calStore.isAuthorized ? calStore.events(for: selectedDate) : [],
workout: workoutVM.program(for: selectedDate),
workoutDone: workoutVM.isWorkoutComplete(on: selectedDate),
workoutMissed: workoutVM.isWorkoutMissed(on: selectedDate),
workoutCompensation: workoutVM.isCompensation(on: selectedDate),
onWorkoutDone: { workoutVM.markWorkoutDone(on: selectedDate) },
onWorkoutMissed: { workoutVM.markWorkoutMissed(on: selectedDate) },
onWorkoutCompensate: {
workoutVM.markWorkoutMissed(on: selectedDate)
workoutVM.promptCompensation(forMissed: selectedDate)
},
onWorkoutTap: {
if let w = workoutVM.program(for: selectedDate),
workoutVM.activeProgramId != w.id { workoutVM.switchProgram(to: w.id) }
@@ -495,7 +604,18 @@ struct CalendarView: View {
},
onToggle: { task in
withAnimation(KisaniSpring.snappy) { taskVM.toggle(task) }
}
},
onPin: { taskVM.pin($0) },
onEdit: { editingTask = $0 },
onDelete: { taskVM.delete($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) },
onEditEvent: { editingEvent = EditableEvent(event: $0) },
onDeleteEvent: { calStore.deleteEvent($0) }
)
.padding(.horizontal, 16).padding(.top, 12).padding(.bottom, 20)
}
@@ -510,10 +630,7 @@ struct CalendarView: View {
Divider().background(AppColors.border(cs)).padding(.horizontal, 16)
ScrollView(showsIndicators: false) {
VStack(spacing: 0) {
let dayTasks = taskVM.tasks.filter { t in
guard !t.isComplete, let d = t.dueDate else { return false }
return cal.isDate(d, inSameDayAs: selectedDate)
}
let dayTasks = taskVM.occurrences(on: selectedDate).filter { !$0.isComplete }
let events = calStore.isAuthorized ? calStore.events(for: selectedDate) : []
if dayTasks.isEmpty && events.isEmpty {
cvEmptyDay
@@ -565,7 +682,25 @@ struct CalendarView: View {
private var yearView: some View {
let year = cal.component(.year, from: displayMonth)
let months: [Date] = (1...12).compactMap { cal.date(from: DateComponents(year: year, month: $0, day: 1)) }
return ScrollView(showsIndicators: false) {
return VStack(spacing: 0) {
// Year header with navigation (unbounded go as far forward/back as you like).
HStack {
Button { navigateYear(-1) } label: {
Image(systemName: "chevron.left").font(.system(size: 14, weight: .semibold))
.foregroundColor(AppColors.text(cs)).frame(width: 40, height: 40)
}
Spacer()
Text(String(year))
.font(AppFonts.sans(20, weight: .bold)).foregroundColor(AppColors.text(cs))
Spacer()
Button { navigateYear(1) } label: {
Image(systemName: "chevron.right").font(.system(size: 14, weight: .semibold))
.foregroundColor(AppColors.text(cs)).frame(width: 40, height: 40)
}
}
.padding(.horizontal, 16).padding(.top, 4)
ScrollView(showsIndicators: false) {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 24) {
ForEach(months, id: \.self) { m in
cvMiniMonth(m)
@@ -574,6 +709,13 @@ struct CalendarView: View {
.padding(16)
}
}
}
private func navigateYear(_ offset: Int) {
withAnimation(KisaniSpring.snappy) {
displayMonth = cal.date(byAdding: .year, value: offset, to: displayMonth) ?? displayMonth
}
}
private func cvMiniMonth(_ month: Date) -> some View {
let mDays = cvMiniGridDays(month)
@@ -657,7 +799,7 @@ struct CalendarView: View {
ForEach(cvWeekDays(), id: \.self) { date in
let isTod = cal.isDateInToday(date)
let isSel = cal.isDate(date, inSameDayAs: selectedDate)
let dots = eventDots(for: date)
let items = cvAgendaItems(date)
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 4) {
Text(cvDayFmt.string(from: date))
@@ -667,12 +809,20 @@ struct CalendarView: View {
.font(AppFonts.sans(12, weight: isTod ? .bold : .medium))
.foregroundColor(isTod ? AppColors.accent : AppColors.text(cs))
}
if dots.isEmpty {
if items.isEmpty {
Text("Free").font(AppFonts.sans(9)).foregroundColor(AppColors.text3(cs))
} else {
ForEach(dots.indices, id: \.self) { i in
RoundedRectangle(cornerRadius: 2).fill(dots[i].opacity(0.5))
.frame(maxWidth: .infinity).frame(height: 7)
ForEach(Array(items.prefix(4).enumerated()), id: \.offset) { _, item in
HStack(spacing: 5) {
RoundedRectangle(cornerRadius: 1.5).fill(item.color)
.frame(width: 2.5, height: 11)
Text(item.title).font(AppFonts.sans(9))
.foregroundColor(AppColors.text2(cs)).lineLimit(1)
}
}
if items.count > 4 {
Text("+\(items.count - 4) more").font(AppFonts.sans(8))
.foregroundColor(AppColors.text3(cs))
}
}
}
@@ -742,7 +892,66 @@ struct CalendarView: View {
private func cvTimeGrid(days: [Date]) -> some View {
let totalH = CGFloat(cvEndH - cvStartH) * cvHourH
return ZStack(alignment: .topLeading) {
return VStack(spacing: 0) {
cvAllDayBand(days: days)
cvHourGrid(days: days, totalH: totalH)
}
}
/// Untimed tasks, all-day events and the day's workout these have no slot on
/// the hour grid, so they sit in a compact band above it (one column per day).
private func cvAllDayItems(_ date: Date) -> [CVTimeItem] {
var items: [CVTimeItem] = []
if let w = workoutVM.program(for: date) {
items.append(CVTimeItem(title: w.name, color: AppColors.blue, startMin: -1, endMin: -1))
}
for t in taskVM.occurrences(on: date) where !t.isComplete && !t.hasTime {
items.append(CVTimeItem(title: t.title, color: t.quadrant.color, startMin: -1, endMin: -1))
}
for ev in (calStore.isAuthorized ? calStore.events(for: date) : []) where ev.isAllDay {
items.append(CVTimeItem(title: ev.title ?? "Event", color: Color(cgColor: ev.calendar.cgColor), startMin: -1, endMin: -1))
}
return items
}
/// Everything happening on a date all-day items first, then timed for compact
/// agenda listings (e.g. the Week column). Recurrence-aware.
private func cvAgendaItems(_ date: Date) -> [CVTimeItem] {
(cvAllDayItems(date) + cvTimeItems(date)).sorted { $0.startMin < $1.startMin }
}
@ViewBuilder
private func cvAllDayBand(days: [Date]) -> some View {
let perDay = days.map { cvAllDayItems($0) }
if perDay.contains(where: { !$0.isEmpty }) {
HStack(alignment: .top, spacing: 2) {
Text("all-day")
.font(AppFonts.mono(7, weight: .semibold))
.foregroundColor(AppColors.text3(cs))
.frame(width: 36, alignment: .trailing)
ForEach(Array(days.enumerated()), id: \.offset) { idx, _ in
VStack(spacing: 2) {
ForEach(Array(perDay[idx].enumerated()), id: \.offset) { _, item in
Text(item.title)
.font(AppFonts.sans(8.5, weight: .medium))
.foregroundColor(item.color)
.lineLimit(1)
.padding(.horizontal, 5).padding(.vertical, 3)
.frame(maxWidth: .infinity, alignment: .leading)
.background(item.color.opacity(0.18))
.clipShape(RoundedRectangle(cornerRadius: 4))
}
}
.frame(maxWidth: .infinity)
}
}
.padding(.horizontal, 8).padding(.vertical, 6)
Divider().background(AppColors.border(cs))
}
}
private func cvHourGrid(days: [Date], totalH: CGFloat) -> some View {
ZStack(alignment: .topLeading) {
VStack(spacing: 0) {
ForEach(cvStartH...cvEndH, id: \.self) { h in
HStack(spacing: 0) {
@@ -848,10 +1057,11 @@ struct CalendarView: View {
private func cvTimeItems(_ date: Date) -> [CVTimeItem] {
var items: [CVTimeItem] = []
for task in taskVM.tasks where !task.isComplete {
guard let d = task.dueDate, cal.isDate(d, inSameDayAs: date) else { continue }
// Recurrence-aware, and only *timed* tasks belong on the hour grid
// untimed tasks are surfaced separately in the all-day band.
for task in taskVM.occurrences(on: date) where !task.isComplete && task.hasTime {
guard let d = task.dueDate else { continue }
let h = cal.component(.hour, from: d), m = cal.component(.minute, from: d)
guard h != 0 || m != 0 else { continue }
items.append(CVTimeItem(title: task.title, color: task.quadrant.color, startMin: h * 60 + m, endMin: h * 60 + m + 30))
}
for ev in calStore.isAuthorized ? calStore.events(for: date) : [] {
@@ -890,6 +1100,8 @@ struct CalendarConnectSheet: View {
@ObservedObject var calStore: CalendarStore
@Environment(\.colorScheme) var cs
@Environment(\.dismiss) var dismiss
@Environment(\.openURL) private var openURL
@Environment(\.scenePhase) private var scenePhase
var body: some View {
NavigationStack {
@@ -906,10 +1118,19 @@ struct CalendarConnectSheet: View {
.clipShape(RoundedRectangle(cornerRadius: 9))
VStack(alignment: .leading, spacing: 2) {
Text("iPhone Calendar").font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text(cs))
Text("Tap to connect and show events").font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
Text(calStore.isDenied
? "Access denied — enable in Settings"
: "Tap to connect and show events")
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
}
Spacer()
Button("Connect") { calStore.requestAccess() }
Button(calStore.isDenied ? "Settings" : "Connect") {
if calStore.canRequest {
calStore.requestAccess()
} else if let url = URL(string: UIApplication.openSettingsURLString) {
openURL(url)
}
}
.font(AppFonts.mono(9.5, weight: .bold))
.foregroundColor(AppColors.accent)
.padding(.horizontal, 10).padding(.vertical, 5)
@@ -992,6 +1213,10 @@ struct CalendarConnectSheet: View {
.foregroundColor(AppColors.accent)
}
}
.onAppear { calStore.refreshStatus() }
.onChange(of: scenePhase) { phase in
if phase == .active { calStore.refreshStatus() }
}
}
}
}
@@ -1405,6 +1630,7 @@ private struct DayTLEntry: Identifiable {
var sortGroup: Int = 0 // 0 = active, 1 = completed, 2 = overdue
var taskRef: TaskItem?
var workoutRef: WorkoutProgram?
var eventRef: EKEvent?
}
struct DayTimelineView: View {
@@ -1415,8 +1641,27 @@ struct DayTimelineView: View {
var overdueTasks: [TaskItem] = []
let calEvents: [EKEvent]
let workout: WorkoutProgram?
var workoutDone: Bool = false // per-DATE completion (from workoutDates), not live sets
var workoutMissed: Bool = false
var workoutCompensation: Bool = false
var onWorkoutDone: (() -> Void)? = nil
var onWorkoutMissed: (() -> Void)? = nil
var onWorkoutCompensate: (() -> Void)? = nil
var onWorkoutTap: (() -> Void)? = nil
var onToggle: ((TaskItem) -> Void)? = nil
var onPin: ((TaskItem) -> Void)? = nil
var onPostpone: ((TaskItem) -> Void)? = nil
var onEdit: ((TaskItem) -> Void)? = nil
var onDelete: ((TaskItem) -> Void)? = nil
var onSetDate: ((TaskItem, Date?) -> Void)? = nil
var onPostponeMinutes: ((TaskItem, Int) -> Void)? = nil
var onMove: ((TaskItem, Quadrant) -> Void)? = nil
var onSetPriority: ((TaskItem, Priority) -> Void)? = nil
var onSetCategory: ((TaskItem, TaskCategory) -> Void)? = nil
var onResetMatrixUrgency: ((TaskItem) -> Void)? = nil
// Calendar events (non-subscription only): edit/delete in the real calendar.
var onEditEvent: ((EKEvent) -> Void)? = nil
var onDeleteEvent: ((EKEvent) -> Void)? = nil
private let cal = Calendar.current
private static let overdueFmt: DateFormatter = {
@@ -1444,18 +1689,22 @@ struct DayTimelineView: View {
timeLabel: "All Day",
title: ev.title ?? "Event",
color: Color(cgColor: ev.calendar.cgColor),
isComplete: false, isAllDay: true, sortKey: -1, sortGroup: 0
isComplete: false, isAllDay: true, sortKey: -1, sortGroup: 0,
eventRef: ev
))
}
// Workout always pinned first (sortGroup -1)
// Workout always pinned first (sortGroup -1). Completion is tied to THIS
// date (workoutDates), never the program's shared live set state.
if let w = workout {
let done = w.doneSets == w.totalSets && w.totalSets > 0
let done = workoutDone
let status = done ? "Done" : workoutMissed ? "Missed" : "Pending"
let kind = workoutCompensation ? "Compensation · " : ""
out.append(DayTLEntry(
timeLabel: "Workout",
title: w.name,
subtitle: "\(w.totalExercises) exercise\(w.totalExercises == 1 ? "" : "s")",
color: AppColors.blue,
subtitle: "\(kind)\(w.totalExercises) exercise\(w.totalExercises == 1 ? "" : "s") · \(status)",
color: workoutMissed && !done ? AppColors.accent : AppColors.blue,
isComplete: done, isAllDay: true, sortKey: -2, sortGroup: -1, workoutRef: w
))
}
@@ -1492,7 +1741,8 @@ struct DayTimelineView: View {
timeLabel: "\(hd)\(ms)", ampm: h < 12 ? "AM" : "PM",
title: ev.title ?? "Event", subtitle: endLabel,
color: Color(cgColor: ev.calendar.cgColor),
isComplete: false, isAllDay: false, sortKey: h * 60 + m, sortGroup: 0
isComplete: false, isAllDay: false, sortKey: h * 60 + m, sortGroup: 0,
eventRef: ev
))
}
@@ -1536,7 +1786,7 @@ struct DayTimelineView: View {
} else {
VStack(spacing: 0) {
ForEach(Array(entries.enumerated()), id: \.element.id) { i, entry in
DayTLRow(
let row = DayTLRow(
entry: entry,
isFirst: i == 0,
isLast: i == entries.count - 1,
@@ -1544,10 +1794,75 @@ struct DayTimelineView: View {
onWorkoutTap: entry.workoutRef != nil ? onWorkoutTap : nil,
onToggle: entry.taskRef.map { t in { onToggle?(t) } }
)
if let t = entry.taskRef {
row.contextMenu { taskContextMenu(t) }
} else if entry.workoutRef != nil,
onWorkoutDone != nil || onWorkoutMissed != nil {
row.contextMenu { workoutContextMenu }
} else if let ev = entry.eventRef,
onEditEvent != nil || onDeleteEvent != nil,
ev.calendar?.type != .subscription,
ev.calendar?.allowsContentModifications == true {
row.contextMenu { eventContextMenu(ev) }
} else {
row
}
}
}
}
}
@ViewBuilder
private func taskContextMenu(_ task: TaskItem) -> some View {
TaskMenuItems(
task: task,
onPin: { onPin?($0) },
onSetDate: { onSetDate?($0, $1) },
onPostponeMinutes: { onPostponeMinutes?($0, $1) },
onMove: { onMove?($0, $1) },
onSetPriority: { onSetPriority?($0, $1) },
onSetCategory: { onSetCategory?($0, $1) },
onResetMatrixUrgency: onResetMatrixUrgency,
onLiveActivity:{ LiveActivityManager.toggle(for: $0) },
onEdit: onEdit,
onDelete: { onDelete?($0) }
)
}
// Quick actions for the day's workout applies to THIS date only.
@ViewBuilder
private var workoutContextMenu: some View {
if let onWorkoutDone {
Button { onWorkoutDone() } label: {
Label("Mark as Done", systemImage: "checkmark.circle")
}
}
if let onWorkoutMissed {
Button { onWorkoutMissed() } label: {
Label("Mark as Not Done", systemImage: "xmark.circle")
}
}
if let onWorkoutCompensate {
Divider()
Button { onWorkoutCompensate() } label: {
Label("Move to Rest Day / Compensate", systemImage: "arrow.uturn.forward")
}
}
}
// Edit / Delete for a real (non-subscription) calendar event.
@ViewBuilder
private func eventContextMenu(_ event: EKEvent) -> some View {
if let onEditEvent {
Button { onEditEvent(event) } label: { Label("Edit", systemImage: "pencil") }
}
if let onDeleteEvent {
Divider()
Button(role: .destructive) { onDeleteEvent(event) } label: {
Label("Delete", systemImage: "trash")
}
}
}
}
private struct DayTLRow: View {
@@ -1745,3 +2060,40 @@ struct WorkoutSectionBar: View {
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
// MARK: - Event Edit Sheet
/// Identifiable wrapper so an EKEvent can drive `.sheet(item:)`.
struct EditableEvent: Identifiable {
let id = UUID()
let event: EKEvent
}
/// Wraps the system event editor (EKEventEditViewController).
struct EventEditView: UIViewControllerRepresentable {
let event: EKEvent
let store: EKEventStore
var onDone: () -> Void
func makeUIViewController(context: Context) -> EKEventEditViewController {
let vc = EKEventEditViewController()
vc.eventStore = store
vc.event = event
vc.editViewDelegate = context.coordinator
return vc
}
func updateUIViewController(_ vc: EKEventEditViewController, context: Context) {}
func makeCoordinator() -> Coordinator { Coordinator(onDone: onDone) }
final class Coordinator: NSObject, EKEventEditViewDelegate {
let onDone: () -> Void
init(onDone: @escaping () -> Void) { self.onDone = onDone }
func eventEditViewController(_ controller: EKEventEditViewController,
didCompleteWith action: EKEventEditViewAction) {
controller.dismiss(animated: true)
onDone()
}
}
}

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,8 +65,12 @@ 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) },
onTapHeader: { drillQuadrant = .urgent; showDrill = true }
)
@@ -73,7 +78,7 @@ struct MatrixView: View {
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,8 +86,12 @@ 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) },
onTapHeader: { drillQuadrant = .schedule; showDrill = true }
)
@@ -94,7 +103,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,8 +111,12 @@ 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) },
onTapHeader: { drillQuadrant = .delegate_; showDrill = true }
)
@@ -111,7 +124,7 @@ struct MatrixView: View {
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,8 +132,12 @@ 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) },
onTapHeader: { drillQuadrant = .eliminate; showDrill = true }
)
@@ -147,6 +164,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,8 +204,12 @@ 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 onTapHeader: (() -> Void)? = nil
@@ -260,6 +287,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 +314,20 @@ 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) } }
)
}
}
}
@@ -454,21 +442,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 +474,22 @@ 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) }
)
}
if task.id != overdueTasks.last?.id { Divider().padding(.leading, 46) }
}
}
@@ -495,6 +501,22 @@ 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) }
)
}
if task.id != laterTasks.last?.id { Divider().padding(.leading, 46) }
}
}
@@ -507,6 +529,22 @@ 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) }
)
}
if task.id != displayed.last?.id { Divider().padding(.leading, 46) }
}
if completedTasks.count > completedLimit && !showAllCompleted {
@@ -624,11 +662,22 @@ private struct QDTaskRow: View {
Button(action: onTap) {
HStack(alignment: .top) {
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) {
@@ -679,9 +728,11 @@ 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 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) {
@@ -691,9 +742,11 @@ struct TaskEditSheet: View {
_hasTime = State(initialValue: task.hasTime)
_isRecurring = State(initialValue: task.isRecurring)
_recurrenceLabel = State(initialValue: task.recurrenceLabel)
_recurrenceEnd = State(initialValue: task.recurrenceEnd)
_endDate = State(initialValue: task.endDate)
_isAllDay = State(initialValue: task.isAllDay)
_reminderDate = State(initialValue: task.reminderDate)
_constantReminder = State(initialValue: task.constantReminder)
}
private var dateSummary: String {
@@ -839,7 +892,9 @@ struct TaskEditSheet: View {
recurrenceLabel: $recurrenceLabel,
endDate: $endDate,
isAllDay: $isAllDay,
reminderDate: $reminderDate
reminderDate: $reminderDate,
constantReminder: $constantReminder,
recurrenceEnd: $recurrenceEnd
)
}
}
@@ -849,7 +904,8 @@ 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)
dismiss()
}
}

View File

@@ -26,11 +26,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 +39,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 +68,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 +113,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 +132,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 +280,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
//
@@ -506,29 +546,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()
}
}

View File

@@ -5,11 +5,11 @@ struct SettingsView: View {
@EnvironmentObject var workoutVM: WorkoutViewModel
@EnvironmentObject var taskVM: TaskViewModel
@ObservedObject private var auth = AuthManager.shared
@ObservedObject private var calStore = CalendarStore.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 +22,8 @@ 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
// Sheet presentation
@State private var showProfile = false
@@ -31,6 +33,7 @@ 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 showRestTimer = false
@@ -101,6 +104,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 +116,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 +125,7 @@ struct SettingsView: View {
SttSection(label: "Fitness") {
SttNavRow(icon: "dumbbell", bg: AppColors.accentSoft, fg: AppColors.accent, label: "Workout Settings", value: weightLabel) { showWorkoutSettings = true }
SttNavRow(icon: "figure.run", bg: AppColors.accentSoft, fg: AppColors.accent, label: "Rest Timer", value: restLabel) { showRestTimer = true }
HealthToggleRow(isOn: $healthOn, isLast: true)
HealthToggleRow(isLast: true)
}
// ACCOUNT
@@ -164,19 +173,20 @@ 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: $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 +203,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)
Toggle("", isOn: Binding(
get: { hk.authorized },
set: { on in
if on { Task { _ = await hk.requestAuthorization() } }
else { hk.disconnect() }
}
))
.labelsHidden()
.tint(AppColors.green)
.onChange(of: isOn) { enabled in
if enabled {
Task { await HealthKitManager.shared.requestAuthorization() }
}
}
}
.padding(.horizontal, 14).padding(.vertical, 11)
if !isLast { Divider().background(AppColors.border(cs)).padding(.leading, 53) }
@@ -452,7 +463,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 +534,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()
@@ -953,10 +964,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,7 +1042,6 @@ private struct ProfileStatsSheet: View {
@EnvironmentObject var taskVM: TaskViewModel
@EnvironmentObject var workoutVM: WorkoutViewModel
@AppStorage("streakGoal") private var streakGoal = 5
@AppStorage("healthOn") private var healthOn = false
@ObservedObject private var hk = HealthKitManager.shared
@ObservedObject private var auth = AuthManager.shared
@@ -1048,7 +1058,7 @@ private struct ProfileStatsSheet: View {
return taskVM.tasks.filter { $0.isComplete && ($0.completedAt ?? .distantPast) >= start }.count
}
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
@@ -1137,10 +1147,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 +1235,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")
@@ -1340,7 +1350,7 @@ 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("Wenza").font(AppFonts.sans(20, weight: .bold)).foregroundColor(AppColors.text(cs))
Text("Version 1.0.0").font(AppFonts.mono(11)).foregroundColor(AppColors.text3(cs))
}
Text("A calm, focused productivity app for tasks, calendar, and fitness.")

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,173 @@
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 }
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") }
}
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,10 @@ 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 isReordering = false
@State private var trainAnyway = false // override the rest-day state for today
@ObservedObject private var tm = TutorialManager.shared
private let restTick = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
@@ -70,6 +74,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 +93,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,13 +103,41 @@ struct WorkoutView: View {
.foregroundColor(AppColors.text3(cs))
}
Spacer()
IButton(icon: "ellipsis") { showManage = true }
IButton(icon: "chart.bar.xaxis") { showStats = true }
IButton(icon: "clock.arrow.circlepath") { showHistory = true }
Menu {
Button { withAnimation(KisaniSpring.snappy) { vm.setAllSetsDone(true) } } label: {
Label("Mark All Complete", systemImage: "checkmark.circle")
}
Button { withAnimation(KisaniSpring.snappy) { vm.setAllSetsDone(false) } } label: {
Label("Clear All Sets", systemImage: "circle")
}
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)
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
DotProgressCard(progress: vm.progress, done: vm.doneSets, total: vm.totalSets)
.listRowBackground(Color.clear)
@@ -142,6 +178,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 +247,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 +319,16 @@ struct WorkoutView: View {
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
.sheet(isPresented: $showHistory) {
WorkoutHistoryView().environmentObject(vm)
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
.sheet(isPresented: $showStats) {
WorkoutStatsView().environmentObject(vm)
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
.sheet(isPresented: $showAddSection) {
AddSectionSheet().environmentObject(vm)
.presentationDetents([.height(240)])
@@ -537,6 +595,318 @@ struct DotProgressCard: View {
}
}
// 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
struct StreakBannerView: View {
@Environment(\.colorScheme) private var cs
@@ -1039,11 +1409,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 +1443,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 +1480,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,376 @@
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
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) }
}
}
// 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) }
}
/// 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 tracking date, or first-seen, up to the due date.
private func resolveSpan(due: Date, isRecurring: Bool, label: String?,
anchorKey: String, explicitStart: Date?) -> (start: Date, target: Date) {
if isRecurring, let label, let span = Self.recurrenceSpan(label: label, due: due) {
return (span.prev, span.next)
}
if let s = explicitStart, s < due { return (s, 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, anchorKey: trackedId, explicitStart: since)
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,
anchorKey: ev.id, explicitStart: nil)
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,
anchorKey: ev.id, explicitStart: config.startDate)
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.
private static func recurrenceSpan(label: String, due: Date) -> (prev: Date, next: Date)? {
let cal = Calendar.current
let now = Date()
let comp: Calendar.Component
let step: Int
switch label {
case "Daily", "Every Weekday": comp = .day; step = 1
case "Weekly": comp = .day; step = 7
case "Monthly": comp = .month; step = 1
case "Yearly": comp = .year; step = 1
default: return nil
}
var next = due
var guardRail = 0
if next > now {
// Walk back until the previous occurrence is in the past.
while let prev = cal.date(byAdding: comp, value: -step, to: next),
prev > now, guardRail < 4000 { next = prev; guardRail += 1 }
} else {
while next <= now, let n = cal.date(byAdding: comp, value: step, to: next),
guardRail < 4000 { next = n; guardRail += 1 }
}
guard let prev = cal.date(byAdding: comp, value: -step, to: next) else { return nil }
return (prev, next)
}
}
// 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,318 @@
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 customEvent
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Progress Mode"
static var caseDisplayRepresentations: [ProgressDotMode: DisplayRepresentation] = [
.day: "This Day",
.week: "This Week",
.month: "This Month",
.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 .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
}
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 recurrenceSpan(label: "Yearly", due: due, now: now)
}
guard task.isRecurring, let label = task.recurrenceLabel else { return nil }
return recurrenceSpan(label: label, due: due, now: now)
}
private static func recurrenceSpan(label: String, due: Date, now: Date) -> (prev: Date, next: Date)? {
let cal = Calendar.current
let comp: Calendar.Component
let step: Int
switch label {
case "Daily", "Every Weekday": comp = .day; step = 1
case "Weekly": comp = .day; step = 7
case "Monthly": comp = .month; step = 1
case "Yearly": comp = .year; step = 1
default: return nil
}
var next = due
var guardRail = 0
if next > now {
while let prev = cal.date(byAdding: comp, value: -step, to: next),
prev > now, guardRail < 4000 {
next = prev
guardRail += 1
}
} else {
while next <= now, let candidate = cal.date(byAdding: comp, value: step, to: next),
guardRail < 4000 {
next = candidate
guardRail += 1
}
}
guard let prev = cal.date(byAdding: comp, value: -step, to: next) else { return nil }
return (prev, next)
}
private static func startOfISOWeek(containing date: Date) -> Date {
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)
}
}
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,89 @@
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" : "circle")
.font(.system(size: 22))
.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)
}
}
.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)
}
} 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,174 @@
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 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,
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 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 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

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.

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

View File

@@ -6,6 +6,12 @@ options:
xcodeVersion: "15.0"
createIntermediateGroups: true
settings:
base:
DEVELOPMENT_TEAM: K8BLMMR883
MARKETING_VERSION: "2.0"
CURRENT_PROJECT_VERSION: "9"
schemes:
KisaniCal:
build:
@@ -29,20 +35,82 @@ targets:
deploymentTarget: "16.0"
sources:
- path: KisaniCal
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."
KisaniCalWidgets:
type: app-extension
platform: iOS
deploymentTarget: "18.0"
sources:
- path: KisaniCalWidgets
- path: KisaniCal/Managers/AppGroup.swift
- path: KisaniCal/Managers/TaskActivityAttributes.swift
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"

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>