Compare commits

...

31 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
31 changed files with 3846 additions and 699 deletions

View File

@@ -30,6 +30,8 @@
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 */; };
@@ -80,13 +82,14 @@
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; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = KisaniCalWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; };
0D6D6740721F01A74E404EB9 /* KisaniCalWidgets.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = KisaniCalWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; };
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>"; };
@@ -98,15 +101,17 @@
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>"; };
9BD1F84B4F45B12CB9B9F1B2 /* KisaniCal.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = KisaniCal.app; sourceTree = BUILT_PRODUCTS_DIR; };
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>"; };
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>"; };
@@ -114,9 +119,11 @@
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>"; };
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 */
@@ -139,11 +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>";
@@ -153,6 +171,7 @@
children = (
9BD1F84B4F45B12CB9B9F1B2 /* KisaniCal.app */,
0D6D6740721F01A74E404EB9 /* KisaniCalWidgets.appex */,
7A9D47B284FD6A217AEF813B /* WenzaWatch.app */,
);
name = Products;
sourceTree = "<group>";
@@ -169,6 +188,7 @@
72308FEE0226F45414C04DDD /* SettingsView.swift */,
BE2D50B4B4AC227BD21F4B60 /* SplashView.swift */,
670A8C6F8243EC973A1BC431 /* TaskContextMenu.swift */,
ED5DB5340BEB3EF7A78CA153 /* TodayMenuFeatures.swift */,
C786EBC7DF879D64EB28165E /* TodayView.swift */,
FA3D5289C5F93484E22DEB63 /* TutorialView.swift */,
20DAD771EFCC8B3B1DBD4DD6 /* WorkoutView.swift */,
@@ -282,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 */
@@ -289,7 +326,7 @@
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 2620;
LastUpgradeCheck = 1500;
TargetAttributes = {
28B43516AD88946E21D9BFE0 = {
DevelopmentTeam = K8BLMMR883;
@@ -299,6 +336,10 @@
DevelopmentTeam = K8BLMMR883;
ProvisioningStyle = Automatic;
};
403D989BDC6268361CFFB479 = {
DevelopmentTeam = K8BLMMR883;
ProvisioningStyle = Automatic;
};
};
};
buildConfigurationList = EF1C4F3BDA4AA7E008FC2CDE /* Build configuration list for PBXProject "KisaniCal" */;
@@ -317,6 +358,7 @@
targets = (
297C0C2BFBA10AB52D5D49CE /* KisaniCal */,
28B43516AD88946E21D9BFE0 /* KisaniCalWidgets */,
403D989BDC6268361CFFB479 /* WenzaWatch */,
);
};
/* End PBXProject section */
@@ -336,6 +378,14 @@
/* 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;
@@ -364,6 +414,7 @@
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 */,
@@ -407,11 +458,11 @@
CODE_SIGN_STYLE = Automatic;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "Kisani Cal";
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 = "Kisani Cal reads your steps, energy, heart rate, and workouts to show health stats on your dashboard.";
INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Kisani Cal saves workouts you complete to the Health app.";
INFOPLIST_KEY_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_UILaunchStoryboardName = LaunchScreen;
@@ -423,7 +474,6 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2;
PRODUCT_BUNDLE_IDENTIFIER = com.kutesir.KisaniCal;
SDKROOT = iphoneos;
SWIFT_VERSION = 5.9;
@@ -465,12 +515,11 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 2;
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;
@@ -480,12 +529,10 @@
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MARKETING_VERSION = 1.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;
@@ -501,12 +548,7 @@
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",
);
MARKETING_VERSION = 2;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
PRODUCT_BUNDLE_IDENTIFIER = com.kutesir.KisaniCal.KisaniCalWidgets;
SDKROOT = iphoneos;
SWIFT_VERSION = 5.9;
@@ -548,12 +590,11 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 2;
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;
@@ -569,19 +610,35 @@
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MARKETING_VERSION = 1.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 = {
@@ -591,12 +648,7 @@
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",
);
MARKETING_VERSION = 2;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
PRODUCT_BUNDLE_IDENTIFIER = com.kutesir.KisaniCal.KisaniCalWidgets;
SDKROOT = iphoneos;
SWIFT_VERSION = 5.9;
@@ -604,6 +656,24 @@
};
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 = {
@@ -614,11 +684,11 @@
CODE_SIGN_STYLE = Automatic;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "Kisani Cal";
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 = "Kisani Cal reads your steps, energy, heart rate, and workouts to show health stats on your dashboard.";
INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Kisani Cal saves workouts you complete to the Health app.";
INFOPLIST_KEY_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_UILaunchStoryboardName = LaunchScreen;
@@ -630,7 +700,6 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2;
PRODUCT_BUNDLE_IDENTIFIER = com.kutesir.KisaniCal;
SDKROOT = iphoneos;
SWIFT_VERSION = 5.9;
@@ -650,6 +719,15 @@
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">

View File

@@ -94,6 +94,7 @@ struct ContentView: View {
.onAppear {
if !hasOnboarded { showOnboarding = true }
workoutVM.resetSetsForNewDayIfNeeded()
workoutVM.checkPendingMissed()
CloudSyncManager.shared.restoreSettings()
CloudSyncManager.shared.backupSettings()
HealthKitManager.shared.bootstrap()
@@ -111,6 +112,7 @@ struct ContentView: View {
consumeWidgetAddTask()
workoutVM.resetSetsForNewDayIfNeeded()
workoutVM.checkPendingHealthConfirm()
workoutVM.checkPendingMissed()
CloudSyncManager.shared.backupSettings()
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
Task {
@@ -123,6 +125,12 @@ struct ContentView: View {
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)
}
@@ -222,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 {

View File

@@ -339,3 +339,683 @@ leaked onto all of those dates.
finished. Future occurrences are never auto-completed.
Files: `ExerciseModels.swift`, `CalendarView.swift`, `TodayView.swift`.
---
## KC-11 — Calendar appeared capped at 2026 (no year shown)
**Status:** Resolved
**Reported by:** User
**Area:** Calendar
### Description
Users couldn't navigate/see dates beyond 2026.
### Root cause
Navigation was actually unbounded, but every header showed only the month name
(`"MMMM"`) — the Year view had no year label at all — so crossing a year boundary
was invisible and future months felt unreachable (compounded by empty future
months from the missing recurrence engine, KC-12).
### Fix
Show the year: main header `"Month Year"`, date-picker grid `"MMMM yyyy"`, and a
`◀ year ▶` navigator + label in the Year view (`navigateYear`).
Files: `CalendarView.swift`, `TodayView.swift`. Committed in `7b2b61a`.
---
## KC-12 — Recurring tasks don't appear on future dates
**Status:** In Progress (implemented & build-verified, pending device build)
**Reported by:** User
**Area:** Tasks / Calendar
### Description
A task set to repeat (Daily/Weekly/Monthly/Yearly/Every-Weekday) only ever showed
on its single original `dueDate`. No future occurrences, no per-occurrence
completion.
### Root cause
There was no recurrence engine — tasks carry `isRecurring` + `recurrenceLabel` but
a single `dueDate`, and every surface matched by `isDate(dueDate, inSameDayAs:)`.
### Fix (core)
- Model: `recurrenceEnd` + `completedOccurrences` (optional → safe decode of old data).
- `TaskViewModel` engine: `isOccurrence(_:on:)`, `occurrenceComplete`,
`toggleOccurrence`, `occurrenceCopy`, `nextOccurrence`, `occurrences(on:)`.
- `toggle(_:)` routes recurring rows to per-occurrence completion (one day never
affects another).
- Date filters (`today`/`next3`/`upcoming`/`completedToday`) exclude recurring
masters and re-inject per-date occurrence copies; recurring never goes "overdue".
- Calendar dots + day detail use `occurrences(on:)`.
- Works across year boundaries; respects `recurrenceEnd` when set.
Files: `TaskItem.swift`, `CalendarView.swift`.
### "Repeat until" UI (done)
- Added `recurrenceEnd` through `NLParsed`, `TaskDatePickerSheet`, both add/edit
sheets, and `addTask`/`updateTask`.
- Date picker shows a "Repeat until" row (graphical date picker, default "Forever",
with Clear) whenever a recurrence is selected.
Files: `TaskItem.swift`, `CalendarView.swift`, `TodayView.swift`, `MatrixView.swift`.
---
## KC-13 — Recurrence redesign: single occurrence, new sections, classic Matrix
**Status:** In Progress (implemented & build-verified, pending device build)
**Reported by:** User
**Area:** Tasks / Calendar / Matrix
### Description
The first recurrence pass used calendar-expansion in lists — a daily task showed
in Today, Tomorrow, Next 7 Days, and Upcoming simultaneously (planning noise).
Should behave like TickTick: one task, one next occurrence, one source of truth.
### Fix
- **Single active occurrence (P1/P5):** `nextActiveOccurrence` + `activeRows` — each
recurring task contributes ONE row (its next un-completed occurrence). Completing
rolls forward; no future duplicates in lists. Calendar grid still shows per-date.
- **Sections (P2):** replaced Today / Next 3 Days / Upcoming with
**Today / Tomorrow / Next 7 Days / Later** (`todayTasks`/`tomorrowTasks`/
`next7DaysTasks`/`laterTasks`; `UpcomingSection` gained a `title`).
- **Icons (P3):** ⏰ reminder + 🔁 recurring on the right of each row; fixed the
hardcoded "Annual" label to show the real frequency.
- **Matrix (P4):** classic Eisenhower — importance is user-assigned (row), urgency is
derived from the deadline (within `urgentWindowDays` = 7). `displayQuadrant` +
`matrixTasks` group by the computed quadrant; recurring rolls forward. Chosen over
the fully-auto "far = Q4" model so the user keeps control of importance.
Files: `TaskItem.swift`, `TodayView.swift`, `MatrixView.swift`.
Commits: `b4e36db` (lists + icons), `2f1bc5d` (Matrix).
---
## KC-14 — Workout Stats (daily / weekly / monthly performance)
**Status:** In Progress (implemented & build-verified, pending device build)
**Reported by:** User
**Area:** Workout
### Description
Per-day logs (KC-9) existed but weren't aggregated, so there was no way to compare
daily/weekly/monthly performance.
### Fix
- `WorkoutDayLog.volume` (Σ weight×reps over done sets); `StatPeriod`/`WorkoutStat`/
`StatBucket`.
- `WorkoutViewModel`: `stat(period, offset:)` (current vs previous), `statInterval`,
`statBuckets(period, count:)` for charting.
- New `WorkoutStatsView` (chart-bar button in the Workout header): Day/Week/Month
selector, summary cards (Workouts / Sets / Volume) with %-delta vs the previous
period, a metric chooser, and a Swift Charts bar trend (last 14 days / 8 weeks /
6 months). Empty state when no history.
Files: `ExerciseModels.swift`, `WorkoutView.swift`.
### Note
Stats are computed from the app's own per-day logs. Health (steps/kcal/resting HR +
workout dates) is already read on the Today dashboard and merged into the streak;
a Health overlay on this screen could be added later.
---
## KC-15 — Bars countdown widget + unified slate/orange widget theme
**Status:** In Progress (implemented & build-verified, pending device build)
**Reported by:** User
**Area:** Widgets
### Description
Add a bar-meter event-countdown widget (per reference screenshot) and re-theme
every widget to one look: dark slate background + brand orange, matching the app.
### Fix
- `WidgetTheme` (slate background `RGB(0.224,0.255,0.31)`, brand orange accent).
- `BarGrid` vertical-bar progress view; new `EventBarsView` + `EventBarsWidget`
("Event Countdown (Bars)", medium) reusing the existing event config/provider.
- `EventEntry.fineTimeLeft` (days → "X hr, Y min left") for the bars label.
- Re-themed all home-screen widgets to slate bg + orange: Day Progress, Tasks Done
Today, Event Countdown (dots), My Tasks. Lock-screen accessory widgets stay on
`.thinMaterial` (system-tinted on the Lock Screen).
Files: `WidgetViews.swift`, `EventCountdownWidget.swift`, `MyTasksWidget.swift`,
`KisaniCalWidgets.swift`.
---
## KC-16 — Workout check-in, missed state, and rest-day compensation
**Status:** In Progress (implemented & build-verified; notification actions need a real device)
**Reported by:** User
**Area:** Workout / Notifications
### Description
Workout reminders should ask "Have you done this workout?" with Yes / No / Snooze.
Saying No marks that day missed and offers to compensate on a rest day. Long-press
on workout/exercise cards gives quick Done / Not Done / Compensate actions. Each
scheduled day keeps its own completion state.
### Fix
- **VM:** `missedDates` + `compensations` (persisted + iCloud), `workoutStatus(on:)`
(pending/done/missed), `markWorkoutDone/markWorkoutMissed(on:)` (per-date only),
`upcomingRestDays`, `scheduleCompensation`, `promptCompensation`,
`checkPendingMissed`. `program(for:)` honors compensations; a compensation day
is not a rest day.
- **Notifications:** WORKOUT_CONFIRM category now has "Yes, I completed it ✓",
"No, I didn't" (→ marks missed + asks compensation on next open), and "Remind me
in 1 hour" (snooze re-delivers). Daily reminder + check-in both use it.
Compensation days get their own one-off reminder ("Missed workout recovery").
- **UI:** `CompensationSheet` (rest-day picker, "No, keep as missed"); long-press
menus on the calendar/Today workout row (Done / Not Done / Compensate) and on
exercise cards (Mark as Done / Not Done); workout row subtitle shows
Pending / Done / Missed and a "Compensation ·" prefix (missed shows accent color).
- Daily per-date rules unchanged (KC-1/KC-10): completing Tuesday never completes
Saturday.
Files: `ExerciseModels.swift`, `NotificationManager.swift`, `ContentView.swift`,
`WorkoutView.swift`, `CalendarView.swift`, `TodayView.swift`.
### How to test (device)
1. Enable workout reminder/check-in in Settings; when it fires, use the actions.
2. "No, I didn't" → open app → compensation sheet appears → pick a rest day →
that day now shows the program labeled Compensation (and gets a reminder).
3. Long-press the workout card in Today/Calendar → Done / Not Done / Compensate.
4. Long-press an exercise card in Workout → Mark as Done / Not Done.
---
## KC-17 — Remove Event Countdown (dots) + Tasks Done Today widgets
**Status:** Resolved (build-verified)
**Reported by:** User
**Area:** Widgets
### Description
The dot-grid "Event Countdown" and "Tasks Done Today" widgets were judged not up
to standard and removed entirely from the gallery.
### Removal
- `EventCountdownWidget` struct (kind `KisaniEventCountdown`) + `EventCountdownView`.
- `TasksCompleteWidget` (kind `KisaniTasksComplete`) + `TasksCompleteView` +
its only consumer `todayTaskCompletion()` in WidgetData.swift.
- Both deregistered from `KisaniWidgetBundle`.
- Kept (shared by the surviving bars widget): `EventConfigIntent`, `EventEntity`,
`EventQuery`, `EventProvider`, `EventEntry`, `CycleCountdownUnitIntent`,
`BarGrid`; `DotGrid` kept (used by Day Progress).
- Untouched: Event Countdown (Bars), My Tasks, Day Progress, Lock Screen rect +
circular, Task Live Activity.
Files modified: `KisaniCalWidgets.swift`, `EventCountdownWidget.swift`,
`WidgetViews.swift`, `WidgetData.swift`. No files deleted (shared code remains
in EventCountdownWidget.swift). No app-side settings referenced these widgets.
---
## KC-18 — Bars widget: progress bar never fills; date pickers felt broken
**Status:** In Progress (implemented & build-verified, pending device build)
**Reported by:** User
**Area:** Widgets
### Symptoms
The Event Countdown (Bars) widget showed all bars dim regardless of time left,
and the Event date / Counting from rows in the config appeared not to work.
### Root causes
1. Auto (upcoming-queue) mode anchored progress to `Date()` on **every** refresh,
so elapsed time was always ~0 → empty bar forever.
2. Custom mode defaulted both `Event date` and `Counting from` to the same
instant (`Date.now` at config time) → zero-length span → progress 0. The raw
second-precision defaults also made the picker rows look broken.
3. The timeline only refreshed at midnight, so even a correct bar wouldn't move
during an "X hr left" countdown.
### Fix
- Persistent per-event anchor (`kisani.widget.anchor.<id>` in the App Group):
progress fills from when an event was first tracked and resets naturally when
the tracked event changes. An explicit "Counting from" before the event wins.
- `targetDate`/`startDate` are now optional — rows read "Choose" until set, and a
missing custom date falls back to a 2-day stub window.
- Timeline pre-renders an entry every 30 minutes (12 h horizon) so the bar and
time-left label keep moving.
Files: `KisaniCalWidgets/EventCountdownWidget.swift`.
Note: existing widget instances re-read their config; dates must be re-picked
once (params changed to optional).
---
## KC-19 — Widgets: exact logo palette everywhere
**Status:** In Progress (implemented & build-verified, pending device build)
**Reported by:** User
**Area:** Widgets
### Description
All widgets should match the brand orange theme of the logo exactly.
### Fix
- `WidgetTheme` now uses the precise brand palette: accent **RGB(232,98,42)**
(app `AppColors.accent`) on the logo's dark **RGB(26,29,34)** background —
previously a near-miss orange on a slate background.
- Every themed widget (Event Countdown Bars, My Tasks, Day Progress) inherits it.
- Task Live Activity: hardcoded accent → `WidgetTheme.accent`; background tint →
logo dark.
- Lock-screen ring tints → brand accent (renders in color on StandBy/home).
Files: `WidgetViews.swift`, `TaskLiveActivity.swift`.
---
## KC-20 — "Track Countdown": zero-config bars widget driven from the app
**Status:** In Progress (implemented & build-verified, pending device build)
**Reported by:** User
**Area:** Widgets / Tasks
### Description
The bars widget should not need manual event entry: track any KisaniCal event
from the app and the widget displays it automatically (title, date, time left,
progress bars).
### Fix
- **App:** new `CountdownTracker` + a "Track Countdown" / "Stop Tracking
Countdown" item in the shared task long-press menu. Stores
`kisani.widget.trackedEvent` (+ `trackedSince`) in the App Group and reloads
widget timelines.
- **Widget:** the tracked event bypasses the widget configuration entirely.
- **Mode A** (one-off): progress = elapsed/(event tracking date).
- **Mode B** (recurring birthdays/anniversaries): progress across the
occurrence span containing now (previous → next occurrence), derived from
the task's recurrence rule (`recurrenceSpan`).
- `WidgetTask` now decodes `isRecurring`/`recurrenceLabel` from the shared JSON.
- No tracked event → existing configuration (upcoming queue / picked / custom)
remains the fallback, exactly as before.
- Bar rendering already maps progress → filled (accent) vs remaining (dimmed)
bars; the screenshot showing static dim bars was the pre-KC-18 build.
Files: `TaskContextMenu.swift`, `WidgetData.swift`, `EventCountdownWidget.swift`.
### How to test (device)
1. Long-press any task with a due date → "Track Countdown".
2. The bars widget switches to that event without touching its config.
3. A yearly birthday shows Mode B: bar spans last year's → this year's date.
4. Long-press the task again → "Stop Tracking Countdown" → widget falls back
to its configured mode.
---
## KC-21 — Eisenhower Matrix as a TickTick-style view (Priority × deadline)
**Status:** In Progress (implemented & build-verified, pending device QA)
**Reported by:** User
**Area:** Tasks / Matrix
### Problem
The Matrix treated importance as the task's *stored quadrant* — you placed a
task in a row and only a manual "Move" changed it. TickTick instead treats the
Matrix as a **view**: it auto-places every task from its **Priority** and **due
date**, while still allowing quick drag/menu moves.
### Fix (model — `TaskItem.swift`)
- Replaced whole-quadrant override with `urgencyOverride: Bool?`.
- `nil` = urgency is computed from due date.
- `true` = force urgent column.
- `false` = force not-urgent column.
- Importance is now `priority == .high` (top row), not the stored quadrant.
Medium/Low/None stay in the bottom row.
- Urgency is deadline-driven:
- default urgent window = 3 days
- birthday/domain/annual/renewal/subscription/exam/deadline = 14 days
Auto-placement: Q1 = High+urgent, Q2 = High+later, Q3 = not-High+urgent,
Q4 = the rest.
- `moveToQuadrant` updates real attributes:
- vertical movement sets Priority (top → High; moving down demotes High → Medium)
- horizontal movement sets `urgencyOverride` only if the target column disagrees
with date-derived urgency.
- Editing due date (`setDate`) or saving the editor (`updateTask`) clears
`urgencyOverride` so the task re-places live.
- Category changes, priority changes, and date changes refresh the legacy
`quadrant` color/widget fallback to match computed placement.
- `addTask` applies KisaniCal defaults when no priority is picked: birthday /
domain / annual (+ title keywords exam, subscription, renew, expir) → High;
workout / gym / exercise → Medium.
### Fix (create UI — `TodayView.swift`)
- `AddTaskSheet` no longer has a manual **quadrant** selector. It now exposes a
**Priority** menu (flag icon). User picks Priority + Date only; placement is
computed. Adding from a Matrix quadrant seeds High for the top row.
- Fixed no-date creation: a task without an explicit parsed/picked date stays
"No Date" instead of silently becoming today/urgent.
### Fix (Matrix UI — `MatrixView.swift`, `TaskContextMenu.swift`)
- Completed tasks are hidden from the 2×2 Matrix grid by default, but remain in
the per-quadrant detail Completed section.
- Rows with an urgency override show a small **Manual** badge.
- Context menu includes **Reset Matrix Urgency** when an urgency override exists.
- Move menu shows all four quadrants, with a checkmark on the current quadrant.
### How to test
1. New task, no priority, far date → lands in Q4; raise to High → jumps to Q2.
2. New task with no date → stays in right column until given a due date/reminder.
3. Drag Q2 → Q1 when due date is far out → Manual badge appears.
4. Reset Matrix Urgency → task returns to its date-derived column.
5. Drag Q1 → Q4 → High demotes to Medium and urgency is overridden if needed.
6. Open the task, change its due date → urgency override clears, re-places by rule.
7. Add a "Mum's birthday" with no priority → auto-High and uses 14-day urgency window.
---
## KC-22 — Quick postpone / snooze from task menus and lock screen
**Status:** In Progress (implemented & build-verified, pending device QA)
**Reported by:** User
**Area:** Tasks / Calendar / Matrix / Notifications
### Problem
Long-pressing a task/event in Today, Matrix, or Calendar required going into the
date editor or using only coarse "+1 day" actions. Lock-screen task reminders
also only supported "Mark Complete". The user wanted standard task-manager
snooze choices directly from the pressed item and notification actions.
### Fix (app task menus)
- Added a shared **Postpone** submenu to `TaskMenuItems`:
- Snooze 15 Minutes
- Snooze 30 Minutes
- Snooze 1 Hour
- Snooze 2 Hours
- Tomorrow
- Wired the submenu through:
- Today timeline + overdue/upcoming sections
- Matrix grid + quadrant detail sections
- Calendar day timeline
- `TaskViewModel.postpone(_:minutes:)` applies the nudge:
- existing reminder → reminder moves forward
- due date with no reminder → due date/time moves forward and becomes timed
- no date/reminder → creates a reminder
### Fix (lock screen notifications)
- Task reminder notifications now include actions:
- Snooze 15 min
- Snooze 30 min
- Snooze 1 hour
- Snooze 2 hours
- Notification response handler updates the stored task reminder and schedules
a one-off snoozed notification for the selected delay.
Files: `TaskContextMenu.swift`, `TaskItem.swift`, `TodayView.swift`,
`MatrixView.swift`, `CalendarView.swift`, `NotificationManager.swift`.
### How to test
1. Long-press a task in Today → Postpone → 15 Minutes; reminder/due time moves.
2. Repeat from Matrix grid, Matrix detail, and Calendar day timeline.
3. Open Move from the same menu → only the other three quadrants are listed (see KC-25).
4. Trigger a task reminder notification on device → verify 15/30/60/120 minute
lock-screen actions appear.
5. Tap a lock-screen snooze action → task reminder updates and notification
re-delivers after the selected interval.
---
## KC-23 — "Pick Date" everywhere + sensible time-based reminders
**Status:** In Progress (implemented & build-verified, pending device QA)
**Reported by:** User
**Area:** Tasks / Matrix / Calendar / Reminders
### Problem
The Date submenu offered "Pick Date" only in the Today view — the Matrix grid
cards and the Calendar reschedule/move menu were missing it. Separately, the
reminder lead-time options were day/week based ("2 days early", "1 week early"),
which is unrealistic for timed events; the user wanted the standard time-based
set.
### Fix
- "Pick Date" (and Edit) now appear in the Matrix grid cards and the Calendar day
timeline menu — both open the shared `TaskEditSheet` via a new `editingTask`
sheet (`QuadrantCard` gained an `onEdit`; Calendar passes `onEdit` into the
task menu).
- Reworked the reminder picker (`TaskDatePickerSheet`, shared by every entry
point) from day-offsets to **minutes before the event time**: None, On time,
5 min, 30 min, 1 hour, 1 day, plus a Minutes/Hours/Days custom wheel. Existing
absolute `reminderDate`s migrate to the nearest minute offset on open.
Files: `MatrixView.swift`, `CalendarView.swift`, `TodayView.swift`.
### How to test
1. Long-press a task in Matrix and in Calendar → Date → "Pick Date" is present.
2. Create/edit a task → reminder options read On time / 5 min / 30 min / 1 hour /
1 day / Custom, relative to the event time.
3. Custom → Minutes/Hours/Days toggle + count wheel commits the right offset.
---
## KC-24 — App rebrand to "Wenza"
**Status:** In Progress (display name + copy done; App Store name set in ASC)
**Reported by:** User
**Area:** Branding
### Problem
The app is being rebranded from "Kisani Cal" to **Wenza**.
### Fix
- `CFBundleDisplayName` → "Wenza" (app) and "Wenza Widgets" (widget) in
`project.yml`; permission prompts and in-app copy (sidebar, Auth, Onboarding,
Settings, tutorial, widget description) updated to Wenza.
- **Deliberately unchanged** to preserve App Store identity and user data: bundle
ID `com.kutesir.KisaniCal`, App Group `group.com.kutesir.KisaniCal`,
`kisani.tasks.v2.*` storage keys, team ID, and internal Xcode target/project
names.
- App Store listing name set separately in App Store Connect (ships with the
next version).
Files: `project.yml`, `ContentView.swift`, `TutorialManager.swift`, `AuthView.swift`,
`OnboardingView.swift`, `SettingsView.swift`, `KisaniCalWidgets.swift`.
### How to test
1. Build & run → home-screen label and all in-app titles read "Wenza".
2. Confirm tasks/widget data survive (App Group + keys unchanged).
---
## KC-25 — Matrix "Move" lists only the other quadrants
**Status:** In Progress (implemented & build-verified, pending device QA)
**Reported by:** User
**Area:** Tasks / Matrix
### Problem
The Move submenu listed all four quadrants with a checkmark on the current one,
so the quadrant a task already sat in appeared as a no-op option.
### Fix
- `TaskMenuItems` Move submenu now excludes the task's current quadrant and lists
only the other three, using the clear matrix labels (e.g. a task in Urgent &
Important shows Not Urgent & Important, Urgent & Unimportant, Not Urgent &
Unimportant). Shared across Today, Matrix, and Calendar.
Files: `TaskContextMenu.swift`.
### How to test
1. Long-press a task in any quadrant → Move shows only the three other quadrants.
2. Pick one → task moves (priority/urgency updated per the TickTick model).
---
## KC-26 — Bars widget: auto-select advances to the next event
**Status:** In Progress (implemented & build-verified, pending device QA)
**Reported by:** User
**Area:** Widgets
### Problem
With "Upcoming queue" on + "Nearest to finish", the Event Countdown (Bars) widget
stayed stuck on an event after it expired (e.g. a passed deadline) instead of
rolling to the next-nearest. The selection was frozen: the timeline computed the
chosen event once and reused it for all 24 pre-rendered entries, only refreshing
every 12 hours.
### Fix
- Each timeline entry now re-resolves the nearest-to-finish event *as of that
entry's own time* (`upcomingEvents(asOf:)` filters `dueDate > entryDate`), so
the moment the current event expires the next entry rolls to the next one.
- The timeline refreshes the instant the current selection expires
(`currentSelectionExpiry`), not 12 hours later, giving the next event a fresh
full pre-render window.
- Completing/closing a task already reloads widget timelines, and the recompute
drops it and picks the next.
### Follow-up — tracked events now advance too
The first pass left the **tracked** ("Track Countdown") path pinned: a completed
or expired tracked event stayed on screen. Fixed with `trackedTask(asOf:)`, which
returns the tracked event only while it's live (not complete, and recurring or
still future); once done/past (non-recurring) the widget falls through to the
next-nearest. Recurring tracked events still roll forward, and the refresh
boundary now includes the tracked event's expiry.
Files: `EventCountdownWidget.swift`.
### How to test
1. Queue on + Nearest to finish; let the nearest event's due time pass → widget
rolls to the next-nearest without manual action.
2. Complete the nearest event → widget advances to the next.
3. Track an event, then complete it → widget advances to the next-nearest.
---
## KC-27 — Today ⋯ menu: Background, Group & Sort, Select
**Status:** In Progress (implemented & build-verified, pending device QA)
**Reported by:** User
**Area:** Tasks / Today
### Problem
Three items in the Today ⋯ menu were empty placeholders: Background, Group &
Sort, and Select.
### Fix
- **Background:** sheet with 5 themes (Default/Warm/Cool/Mono/Paper) applied as a
subtle gradient to the Today background; persisted via `@AppStorage`.
- **Group & Sort:** group by Date (default)/Priority/Category/Quadrant and sort by
Smart/Due/Priority/Title. Non-date groupings render as titled sections reusing
`UpcomingSection`.
- **Select:** multi-select sheet with Select All / Deselect All and a bulk action
bar (Complete / Priority / Move / Delete).
- New file `TodayMenuFeatures.swift`; added `TaskViewModel.allActiveRows` so
grouping/selection use the same single-occurrence task set (incl. undated).
Files: `TodayMenuFeatures.swift`, `TodayView.swift`, `TaskItem.swift`.
### How to test
1. ⋯ → Background → pick a theme; Today background updates and persists.
2. ⋯ → Group & Sort → Priority/Category/Quadrant regroups the list.
3. ⋯ → Select → Select All → Complete marks all done.
---
## KC-28 — Clearer Snooze labels on notifications and task menus
**Status:** In Progress (implemented & build-verified, pending device QA)
**Reported by:** User
**Area:** Notifications / Tasks
### Problem
The lock-screen reminder actions and the in-app Postpone submenu showed bare
durations ("15 min", "30 min", …) that didn't say what they do. iOS notification
buttons are a flat list and can't nest, so a single expanding "Snooze" isn't
possible — the labels must carry the meaning.
### Fix
- Lock-screen actions: "Snooze 15 min / 30 min / 1 hour / 2 hours" (+ Mark Complete).
- In-app Postpone submenu: "Snooze 15 Minutes / 30 Minutes / 1 Hour / 2 Hours".
Files: `NotificationManager.swift`, `TaskContextMenu.swift`.
---
## KC-29 — Lock-screen Progress Dots widget legibility
**Status:** In Progress (implemented & build-verified, pending device QA)
**Reported by:** User
**Area:** Widgets
### Problem
The Progress Dots widget in the lock-screen accessoryRectangular family packed
100 tiny dots into a small pill over a custom dark background, which the lock
screen's vibrant rendering washed out — it looked faint / not loaded.
### Fix
- accessoryRectangular: 28 larger dots (vs 100), `.primary` color for the vibrant
tint, and `AccessoryWidgetBackground()` for the standard frosted pill. Home
Screen small/medium/large keep the 100-dot orange-on-dark grid.
Files: `WidgetViews.swift`.
---
## KC-30 — Period milestone notifications (day / week / month / year)
**Status:** In Progress (implemented & build-verified, pending device QA)
**Reported by:** User
**Area:** Notifications
### Problem
The user wanted recurring "one more period passed" nudges (Pretty Progress
style): day, week, month, and year, all at midnight.
### Fix
- `schedulePeriodMilestones()` schedules recurring `UNCalendarNotificationTrigger`s,
all firing at **midnight (00:00)**:
- Today — "One more day passed." — every day
- This Week — "One more week passed." — Monday 00:00 (weekly)
- This Month — "One more month passed." — 1st at 00:00 (monthly)
- This Year — "One more year passed." — Jan 1 at 00:00 (yearly)
- Fixed identifiers + remove-then-add keep them de-duplicated; runs via the
existing `reschedule(...)` path when authorized; gated by the
`kisani.periodMilestones` flag (default on).
- **Pending:** no in-app Settings toggle yet (see KC-31 #2).
Files: `NotificationManager.swift`.
### How to test
1. Authorize notifications; at midnight a "One more day passed." notification
fires (and the week/month/year equivalents on their boundaries).
---
## KC-31 — Audit fixes: lock-screen sync, live urgency, batch postpone
**Status:** Fixed items build-verified; pending items remain open (see below)
**Reported by:** User (code review)
**Area:** Notifications / Tasks / Matrix
### Reviewed items & outcomes
**Fixed**
- **Lock-screen Mark Complete / Snooze now sync + reload.** `markTaskComplete`
and `snoozeTask` previously wrote only to `UserDefaults.kisani`. They now also
`CloudSyncManager.shared.push(...)` (→ iCloud / Apple Watch) and
`WidgetCenter.shared.reloadAllTimelines()`.
- **Notification copy uses live Matrix urgency, not stored quadrant.**
`reschedule(...)` snapshots urgent task IDs from the computed `displayQuadrant`
and threads them into `scheduleTasks` / `notifBody`, so "Urgent — tap to mark
complete" and the evening check-in reflect current urgency rather than a stale
stored fallback.
- **Batch `postponeAllOverdue` matches single postpone.** It now clears
`urgencyOverride` and re-syncs the stored quadrant per task.
Files: `NotificationManager.swift`, `TaskItem.swift`.
### Pending (not closed)
- **#2 — Period-milestone Settings toggle.** Currently only the
`kisani.periodMilestones` UserDefaults flag (default on); no in-app UI to turn
the day/week/month/year notifications on/off. **Pending.**
- **#6 — Configurable Matrix thresholds & snooze durations.** `urgentWindowDays`
(3), `categoryUrgentWindowDays` (14), and the 15/30/60/120-min snooze actions
are hardcoded constants — fine for now, but no settings for TickTick-style
customization. **Pending.**
- **Note (#1) — Matrix "Move" lists only the other 3 quadrants.** This is the
intended KC-25 behavior, not a defect; left as-is unless reversed.

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])
@@ -49,9 +64,36 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
tasks[idx].completedAt = Date()
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()
}
}
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() {
@@ -79,7 +121,11 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
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 }
@@ -88,12 +134,124 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
guard ok else { return }
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
@@ -191,6 +349,7 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
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
@@ -204,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
@@ -272,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
@@ -309,7 +469,7 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
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"]
@@ -328,7 +488,7 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
// 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."
@@ -343,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"
}
}
}
@@ -379,11 +539,32 @@ 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.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) }
Task { @MainActor in

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

@@ -67,6 +67,34 @@ struct WorkoutDayLog: Identifiable, Codable {
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 {
@@ -205,7 +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
@@ -239,6 +276,8 @@ class WorkoutViewModel: ObservableObject {
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"
@@ -248,6 +287,8 @@ class WorkoutViewModel: ObservableObject {
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)")
@@ -317,6 +358,8 @@ class WorkoutViewModel: ObservableObject {
activeSections = blank.sections
}
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
@@ -333,6 +376,10 @@ class WorkoutViewModel: ObservableObject {
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)
@@ -382,13 +429,53 @@ class WorkoutViewModel: ObservableObject {
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 {
let weekday = Calendar.current.component(.weekday, from: Date())
return !schedule.isEmpty && schedule[weekday] == nil
!schedule.isEmpty && program(for: Date()) == nil
}
/// On a new day, make the active program match the day's schedule so the
@@ -492,6 +579,79 @@ class WorkoutViewModel: ObservableObject {
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()
@@ -529,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 }
@@ -587,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 })

View File

@@ -20,6 +20,12 @@ struct TaskItem: Identifiable, Codable, Equatable {
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 {
@@ -146,6 +152,22 @@ class TaskViewModel: ObservableObject {
} 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() {
@@ -163,43 +185,76 @@ class TaskViewModel: ObservableObject {
}
}
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 next3DaysTasks: [(date: Date, tasks: [TaskItem])] {
let cal = Calendar.current
let today = cal.startOfDay(for: Date())
return (1...3).compactMap { offset -> (Date, [TaskItem])? in
guard let day = cal.date(byAdding: .day, value: offset, to: today),
let nextDay = cal.date(byAdding: .day, value: 1, to: day) else { return nil }
let dayTasks = tasks.filter {
!$0.isComplete &&
($0.dueDate ?? .distantFuture) >= day &&
($0.dueDate ?? .distantFuture) < nextDay
}.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
return dayTasks.isEmpty ? nil : (day, dayTasks)
var cur = max(cal.startOfDay(for: start), cal.startOfDay(for: from))
for _ in 0..<4000 {
if let end = t.recurrenceEnd, cur > cal.startOfDay(for: end) { return nil }
if isOccurrence(t, on: cur) && !occurrenceComplete(t, on: cur) { return cur }
cur = cal.date(byAdding: .day, value: 1, to: cur) ?? cur
}
return nil
}
/// One representative active row per task: non-recurring incomplete tasks, plus a
/// single next-active occurrence copy for each recurring task.
private func activeRows() -> [TaskItem] {
let today = Calendar.current.startOfDay(for: Date())
var out: [TaskItem] = []
for t in tasks {
if recurs(t) {
if let occ = nextActiveOccurrence(t, from: today) { out.append(occurrenceCopy(t, on: occ)) }
} else if !t.isComplete {
out.append(t)
}
}
return out
}
/// All incomplete single-occurrence rows (dated and undated) for Group & Sort
/// and multi-select, which regroup the same task set the date sections draw from.
var allActiveRows: [TaskItem] { activeRows() }
/// Active rows whose due date falls in [today+lo, today+hi) days (hi nil = open).
private func bucket(from lo: Int, to hi: Int?) -> [TaskItem] {
let cal = Calendar.current; let base = cal.startOfDay(for: Date())
let start = cal.date(byAdding: .day, value: lo, to: base)!
let end = hi.flatMap { cal.date(byAdding: .day, value: $0, to: base) }
return activeRows().filter {
let d = $0.dueDate ?? .distantFuture
return d >= start && (end == nil || d < end!)
}.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
}
var overdueTasks: [TaskItem] {
let 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 }
.sorted { ($0.completedAt ?? .distantPast) > ($1.completedAt ?? .distantPast) }
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] {
@@ -209,6 +264,11 @@ class TaskViewModel: ObservableObject {
}
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
@@ -220,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()
@@ -229,47 +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,
constantReminder: Bool = false) {
tasks.append(TaskItem(title: title, dueDate: dueDate, hasTime: hasTime,
quadrant: quadrant, category: category,
taskColor: taskColor, isRecurring: isRecurring,
recurrenceLabel: recurrenceLabel,
endDate: endDate, isAllDay: isAllDay,
priority: priority, reminderDate: reminderDate,
constantReminder: constantReminder))
constantReminder: Bool = false, recurrenceEnd: Date? = nil) {
let resolvedPriority = priority == .none
? defaultPriority(category: category, title: title) : priority
var item = TaskItem(title: title, dueDate: dueDate, hasTime: hasTime,
quadrant: quadrant, category: category,
taskColor: taskColor, isRecurring: isRecurring,
recurrenceLabel: recurrenceLabel,
endDate: endDate, isAllDay: isAllDay,
priority: resolvedPriority, reminderDate: reminderDate,
constantReminder: constantReminder)
item.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?,
constantReminder: Bool = false) {
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
@@ -280,6 +562,9 @@ class TaskViewModel: ObservableObject {
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()
}
@@ -287,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()
}
@@ -296,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()
}
@@ -305,4 +610,3 @@ class TaskViewModel: ObservableObject {
save()
}
}

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

@@ -298,6 +298,7 @@ struct CalendarView: View {
@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"]
@@ -326,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()
@@ -411,6 +412,12 @@ struct CalendarView: View {
}
.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) }
@@ -463,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)
@@ -475,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 }
@@ -581,13 +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) }
@@ -597,11 +606,14 @@ struct CalendarView: View {
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) }
)
@@ -618,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
@@ -673,13 +682,38 @@ 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) {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 24) {
ForEach(months, id: \.self) { m in
cvMiniMonth(m)
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(16)
.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)
}
}
.padding(16)
}
}
}
private func navigateYear(_ offset: Int) {
withAnimation(KisaniSpring.snappy) {
displayMonth = cal.date(byAdding: .year, value: offset, to: displayMonth) ?? displayMonth
}
}
@@ -765,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))
@@ -775,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))
}
}
}
@@ -850,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) {
@@ -956,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) : [] {
@@ -1540,6 +1642,11 @@ struct DayTimelineView: View {
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
@@ -1547,9 +1654,11 @@ struct DayTimelineView: View {
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
@@ -1589,11 +1698,13 @@ struct DayTimelineView: View {
// date (workoutDates), never the program's shared live set state.
if let w = workout {
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
))
}
@@ -1685,6 +1796,9 @@ struct DayTimelineView: View {
)
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,
@@ -1704,15 +1818,38 @@ struct DayTimelineView: View {
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 {

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,9 +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 }
)
@@ -74,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 } },
@@ -82,9 +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 }
)
@@ -96,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 } },
@@ -104,9 +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 }
)
@@ -114,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 } },
@@ -122,9 +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 }
)
@@ -151,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)
@@ -185,9 +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
@@ -265,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))
@@ -288,10 +319,13 @@ struct QuadrantCard: View {
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) } }
)
}
@@ -408,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 }
@@ -444,9 +480,11 @@ struct QuadrantDetailView: View {
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) }
@@ -469,9 +507,11 @@ struct QuadrantDetailView: View {
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) }
@@ -495,9 +535,11 @@ struct QuadrantDetailView: View {
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) }
@@ -620,12 +662,23 @@ private struct QDTaskRow: View {
Button(action: onTap) {
HStack(alignment: .top) {
Text(task.title)
.font(AppFonts.sans(14, weight: task.isComplete ? .regular : .medium))
.foregroundColor(task.isComplete ? AppColors.text3(cs) : AppColors.text(cs))
.strikethrough(task.isComplete, color: AppColors.text3(cs))
.lineLimit(2)
.frame(maxWidth: .infinity, alignment: .leading)
VStack(alignment: .leading, spacing: 4) {
if task.urgencyOverride != nil {
Text("Manual")
.font(AppFonts.mono(8, weight: .bold))
.foregroundColor(quadrant.color)
.padding(.horizontal, 5)
.padding(.vertical, 2)
.background(quadrant.softColor)
.clipShape(Capsule())
}
Text(task.title)
.font(AppFonts.sans(14, weight: task.isComplete ? .regular : .medium))
.foregroundColor(task.isComplete ? AppColors.text3(cs) : AppColors.text(cs))
.strikethrough(task.isComplete, color: AppColors.text3(cs))
.lineLimit(2)
}
.frame(maxWidth: .infinity, alignment: .leading)
VStack(alignment: .trailing, spacing: 3) {
if let d = task.dueDate {
@@ -675,6 +728,7 @@ 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?
@@ -688,6 +742,7 @@ 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)
@@ -838,7 +893,8 @@ struct TaskEditSheet: View {
endDate: $endDate,
isAllDay: $isAllDay,
reminderDate: $reminderDate,
constantReminder: $constantReminder
constantReminder: $constantReminder,
recurrenceEnd: $recurrenceEnd
)
}
}
@@ -849,7 +905,7 @@ struct TaskEditSheet: View {
taskVM.updateTask(task, title: t, dueDate: dueDate, hasTime: hasTime,
isRecurring: isRecurring, recurrenceLabel: recurrenceLabel,
endDate: endDate, isAllDay: isAllDay, reminderDate: reminderDate,
constantReminder: constantReminder)
constantReminder: constantReminder, recurrenceEnd: recurrenceEnd)
dismiss()
}
}

View File

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

View File

@@ -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
@@ -102,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 }
@@ -456,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)
}
@@ -527,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()
@@ -957,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()
@@ -1051,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
@@ -1140,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))
@@ -1343,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

@@ -1,4 +1,27 @@
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.
@@ -9,9 +32,11 @@ struct TaskMenuItems: View {
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 }
@@ -24,29 +49,60 @@ struct TaskMenuItems: View {
}
Menu {
Button { onSetDate(task, cal.startOfDay(for: Date())) } label: {
Label("Today", systemImage: "sun.max")
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")
}
Button {
let d = cal.date(byAdding: .day, value: 1, to: Date())!
onSetDate(task, cal.startOfDay(for: d))
} label: { Label("Tomorrow", systemImage: "sunrise") }
Button {
let d = cal.date(byAdding: .weekOfYear, value: 1, to: Date())!
onSetDate(task, cal.startOfDay(for: d))
} label: { Label("Next Week", systemImage: "calendar") }
Divider()
if let onEdit {
Button { onEdit(task) } label: {
Label("Pick Date", systemImage: "calendar.badge.plus")
}
}
Button { onSetDate(task, nil) } label: {
Label("Remove Date", systemImage: "xmark.circle")
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: { Text(q.rawValue) }
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: {
@@ -64,7 +120,12 @@ struct TaskMenuItems: View {
} label: { Label("Tags", systemImage: "tag") }
Button { onLiveActivity(task) } label: {
Label("Add to Live Activity", systemImage: "pin.circle")
Label(liveActivityTitle, systemImage: liveActivityIcon)
}
Button { CountdownTracker.toggle(task) } label: {
Label(CountdownTracker.isTracked(task) ? "Stop Tracking Countdown" : "Track Countdown",
systemImage: "timer")
}
if let onEdit {
@@ -77,4 +138,36 @@ struct TaskMenuItems: View {
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 }) }
}
}

View File

@@ -11,6 +11,12 @@ struct TodayView: View {
@State private var showWorkoutSession = false
@State private var editingTask: TaskItem? = nil
@AppStorage("todayHideCompleted") private var hideCompleted = false
@AppStorage("todayBackground") private var bgStyle = "default"
@AppStorage("todayGroupMode") private var groupMode = "date"
@AppStorage("todaySortMode") private var sortMode = "smart"
@State private var showBackgroundSheet = false
@State private var showGroupSortSheet = false
@State private var showSelectSheet = false
@ObservedObject private var tm = TutorialManager.shared
@ObservedObject private var hk = HealthKitManager.shared
@@ -27,6 +33,29 @@ struct TodayView: View {
}
}
// Group & Sort: non-date groupings rendered as titled sections (reuses UpcomingSection).
@ViewBuilder private var groupedTaskSections: some View {
let sections = TaskOrganizer.sections(taskVM.allActiveRows, grouping: groupMode,
sort: sortMode, vm: taskVM)
ForEach(Array(sections.enumerated()), id: \.offset) { _, section in
UpcomingSection(
title: section.title,
tasks: section.tasks,
onToggle: { taskVM.toggle($0) },
onPostpone: { taskVM.postpone($0) },
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) }
)
}
}
var body: some View {
ZStack(alignment: .bottomTrailing) {
ScrollView(showsIndicators: false) {
@@ -36,7 +65,7 @@ struct TodayView: View {
// Header
HStack {
Menu {
Button { } label: { Label("Background", systemImage: "paintbrush") }
Button { showBackgroundSheet = true } label: { Label("Background", systemImage: "paintbrush") }
Button {
withAnimation(KisaniSpring.snappy) { hideCompleted.toggle() }
} label: {
@@ -44,8 +73,8 @@ struct TodayView: View {
systemImage: hideCompleted ? "checkmark.circle" : "checkmark.circle.badge.xmark")
}
Divider()
Button { } label: { Label("Group & Sort", systemImage: "arrow.up.arrow.down") }
Button { } label: { Label("Select", systemImage: "checkmark.circle") }
Button { showGroupSortSheet = true } label: { Label("Group & Sort", systemImage: "arrow.up.arrow.down") }
Button { showSelectSheet = true } label: { Label("Select", systemImage: "checkmark.circle") }
} label: {
Image(systemName: "line.3.horizontal")
.font(.system(size: 13, weight: .medium))
@@ -81,6 +110,7 @@ struct TodayView: View {
.padding(.bottom, 10)
}
if groupMode == "date" {
// Today timeline: active completed (green)
let activeTasks = taskVM.todayTasks
let completedTasks = hideCompleted ? [] : taskVM.completedTodayTasks
@@ -91,6 +121,14 @@ struct TodayView: View {
calEvents: todayCalEvents,
workout: workoutVM.program(for: Date()),
workoutDone: workoutVM.isWorkoutComplete(on: Date()),
workoutMissed: workoutVM.isWorkoutMissed(on: Date()),
workoutCompensation: workoutVM.isCompensation(on: Date()),
onWorkoutDone: { workoutVM.markWorkoutDone(on: Date()) },
onWorkoutMissed: { workoutVM.markWorkoutMissed(on: Date()) },
onWorkoutCompensate: {
workoutVM.markWorkoutMissed(on: Date())
workoutVM.promptCompensation(forMissed: Date())
},
onWorkoutTap: {
if let w = workoutVM.program(for: Date()), workoutVM.activeProgramId != w.id {
workoutVM.switchProgram(to: w.id)
@@ -103,9 +141,11 @@ struct TodayView: View {
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) }
onSetCategory: { taskVM.setCategory($0, category: $1) },
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) }
)
.padding(.horizontal, 18)
@@ -118,28 +158,79 @@ struct TodayView: View {
onPostponeTask: { taskVM.postpone($0) },
onPin: { taskVM.pin($0) },
onEdit: { editingTask = $0 },
onDelete: { taskVM.delete($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) }
)
.padding(.horizontal, 18)
.padding(.top, 10)
}
// Next 3 Days
let next3 = taskVM.next3DaysTasks
if !next3.isEmpty {
Next3DaysSection(tasksByDay: next3, onToggle: { taskVM.toggle($0) })
}
// Upcoming (4+ days)
if !taskVM.upcomingTasks.isEmpty {
// Tomorrow
if !taskVM.tomorrowTasks.isEmpty {
UpcomingSection(
tasks: taskVM.upcomingTasks,
title: "Tomorrow",
tasks: taskVM.tomorrowTasks,
onToggle: { taskVM.toggle($0) },
onPostpone: { taskVM.postpone($0) },
onDelete: { taskVM.delete($0) }
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) }
)
}
// Next 7 Days (27 days out)
if !taskVM.next7DaysTasks.isEmpty {
UpcomingSection(
title: "Next 7 Days",
tasks: taskVM.next7DaysTasks,
onToggle: { taskVM.toggle($0) },
onPostpone: { taskVM.postpone($0) },
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) }
)
}
// Later (beyond 7 days)
if !taskVM.laterTasks.isEmpty {
UpcomingSection(
title: "Later",
tasks: taskVM.laterTasks,
onToggle: { taskVM.toggle($0) },
onPostpone: { taskVM.postpone($0) },
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) }
)
}
} else {
groupedTaskSections
}
// Completed (all, collapsible)
if !taskVM.completedTasks.isEmpty {
CompletedSection(
@@ -150,9 +241,8 @@ struct TodayView: View {
.padding(.top, 10)
}
let hasAnything = !taskVM.overdueTasks.isEmpty || !taskVM.todayTasks.isEmpty
|| !taskVM.upcomingTasks.isEmpty || !taskVM.completedTodayTasks.isEmpty
|| !taskVM.next3DaysTasks.isEmpty || !todayCalEvents.isEmpty
let hasAnything = !taskVM.allActiveRows.isEmpty || !taskVM.completedTodayTasks.isEmpty
|| !todayCalEvents.isEmpty
|| workoutVM.program(for: Date()) != nil
if !hasAnything {
VStack(spacing: 6) {
@@ -192,13 +282,28 @@ struct TodayView: View {
.zIndex(10)
}
}
.background(AppColors.background(cs).ignoresSafeArea())
.background(TodayBackground.gradient(bgStyle, cs).ignoresSafeArea())
.sheet(isPresented: $showAddTask) {
AddTaskSheet()
.environmentObject(taskVM)
.presentationDetents([.height(150)])
.presentationDragIndicator(.visible)
}
.sheet(isPresented: $showBackgroundSheet) {
BackgroundPickerSheet(selected: $bgStyle)
.presentationDetents([.medium])
.presentationDragIndicator(.visible)
}
.sheet(isPresented: $showGroupSortSheet) {
GroupSortSheet(grouping: $groupMode, sort: $sortMode)
.presentationDetents([.medium])
.presentationDragIndicator(.visible)
}
.sheet(isPresented: $showSelectSheet) {
SelectableTaskListSheet()
.environmentObject(taskVM)
.presentationDragIndicator(.visible)
}
.sheet(isPresented: $showWorkoutSession) {
WorkoutSessionSheet()
.environmentObject(workoutVM)
@@ -486,6 +591,12 @@ struct OverdueCard: View {
var onPin: (TaskItem) -> Void = { _ in }
var onEdit: (TaskItem) -> Void = { _ in }
var onDelete: (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 = { _ in }
@State private var expanded = true
@@ -573,25 +684,26 @@ struct OverdueCard: View {
@ViewBuilder
private func taskMenu(_ task: TaskItem) -> some View {
Button { withAnimation { onToggle(task) } } label: {
Button { withAnimation(KisaniSpring.bounce) { onToggle(task) } } label: {
Label("Mark Complete", systemImage: "checkmark.circle")
}
Button { onPostponeTask(task) } label: {
Button { withAnimation(KisaniSpring.snappy) { onPostponeTask(task) } } label: {
Label("Postpone 1 Day", systemImage: "clock.arrow.circlepath")
}
Button { onPin(task) } label: {
Label(task.isPinned ? "Unpin" : "Pin", systemImage: task.isPinned ? "pin.slash" : "pin")
}
Button { onEdit(task) } label: {
Label("Edit", systemImage: "pencil")
}
ShareLink(item: task.title) {
Label("Share", systemImage: "square.and.arrow.up")
}
Divider()
Button(role: .destructive) { onDelete(task) } label: {
Label("Delete", systemImage: "trash")
}
TaskMenuItems(
task: task,
onPin: onPin,
onSetDate: onSetDate,
onPostponeMinutes: onPostponeMinutes,
onMove: onMove,
onSetPriority: onSetPriority,
onSetCategory: onSetCategory,
onResetMatrixUrgency: onResetMatrixUrgency,
onLiveActivity: { LiveActivityManager.toggle(for: $0) },
onEdit: onEdit,
onDelete: onDelete
)
}
}
@@ -889,12 +1001,21 @@ private struct SessionSectionHeader: View {
struct UpcomingSection: View {
@Environment(\.colorScheme) private var cs
var title: String = "Upcoming"
let tasks: [TaskItem]
let onToggle: (TaskItem) -> Void
let onPostpone: (TaskItem) -> Void
var onPin: (TaskItem) -> Void = { _ in }
var onEdit: (TaskItem) -> Void = { _ in }
let onDelete: (TaskItem) -> Void
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 = { _ in }
@AppStorage("upcomingCollapsed") private var collapsed = false
@State private var collapsed = false
@State private var showAll = false
private let pageSize = 5
@@ -908,7 +1029,7 @@ struct UpcomingSection: View {
// Header (tappable to collapse)
HStack(spacing: 6) {
Text("Upcoming")
Text(title)
.font(AppFonts.sans(10.5, weight: .semibold))
.foregroundColor(AppColors.text3(cs))
@@ -945,13 +1066,23 @@ struct UpcomingSection: View {
withAnimation(KisaniSpring.snappy) { onToggle(task) }
}
.contextMenu {
Button { withAnimation { onPostpone(task) } } label: {
Button { withAnimation(KisaniSpring.snappy) { onPostpone(task) } } label: {
Label("Postpone 1 Day", systemImage: "clock.arrow.circlepath")
}
Divider()
Button(role: .destructive) { withAnimation { onDelete(task) } } label: {
Label("Delete", systemImage: "trash")
}
TaskMenuItems(
task: task,
onPin: onPin,
onSetDate: onSetDate,
onPostponeMinutes: onPostponeMinutes,
onMove: onMove,
onSetPriority: onSetPriority,
onSetCategory: onSetCategory,
onResetMatrixUrgency: onResetMatrixUrgency,
onLiveActivity: { LiveActivityManager.toggle(for: $0) },
onEdit: onEdit,
onDelete: { task in withAnimation(KisaniSpring.snappy) { onDelete(task) } }
)
}
.padding(.horizontal, 18)
.padding(.bottom, 5)
@@ -1146,24 +1277,35 @@ struct TaskRowView: View {
let task: TaskItem
let onToggle: () -> Void
@State private var showCompleteConfirm = false
private let dueFmt: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "MMM d"; return f
}()
/// A task scheduled for a day after today completing one of these is easy to do
/// by accident from the Next 7 Days / Later sections, so we confirm first.
private var isFutureTask: Bool {
guard let d = task.dueDate else { return false }
let cal = Calendar.current
return cal.startOfDay(for: d) > cal.startOfDay(for: Date())
}
var body: some View {
HStack(spacing: 10) {
Button(action: onToggle) {
ZStack {
Circle()
.fill(task.quadrant.softColor)
.frame(width: 22, height: 22)
Circle()
.stroke(task.quadrant.color, lineWidth: 2)
.frame(width: 22, height: 22)
Button {
if !task.isComplete && isFutureTask {
showCompleteConfirm = true
} else {
onToggle()
}
.frame(width: 36, height: 44)
.padding(.leading, 4)
.contentShape(Circle())
} label: {
Circle()
.strokeBorder(task.quadrant.color, lineWidth: 1.5)
.frame(width: 18, height: 18)
.frame(width: 36, height: 44)
.padding(.leading, 4)
.contentShape(Circle())
}
.buttonStyle(PressButtonStyle(scale: 0.82))
@@ -1173,7 +1315,7 @@ struct TaskRowView: View {
.foregroundColor(AppColors.text(cs))
if let d = task.dueDate, showDetails {
HStack(spacing: 4) {
Text("\(dueFmt.string(from: d))\(task.isRecurring ? " · Annual" : "")")
Text("\(dueFmt.string(from: d))\(task.isRecurring ? " · \(task.recurrenceLabel ?? "Repeat")" : "")")
.font(AppFonts.mono(9.5))
.foregroundColor(AppColors.text3(cs))
Text("·")
@@ -1188,6 +1330,20 @@ struct TaskRowView: View {
Spacer()
// reminder · 🔁 recurring indicators
HStack(spacing: 5) {
if task.reminderDate != nil || task.constantReminder {
Image(systemName: "alarm")
.font(.system(size: 11))
.foregroundColor(AppColors.text3(cs))
}
if task.isRecurring {
Image(systemName: "repeat")
.font(.system(size: 11))
.foregroundColor(AppColors.text3(cs))
}
}
TagChip(
text: task.category.rawValue,
color: task.quadrant.color,
@@ -1205,6 +1361,18 @@ struct TaskRowView: View {
.clipShape(RoundedRectangle(cornerRadius: AppRadius.small))
.overlay(RoundedRectangle(cornerRadius: AppRadius.small)
.stroke(AppColors.border(cs), lineWidth: 1))
.confirmationDialog(
"Complete this task?",
isPresented: $showCompleteConfirm,
titleVisibility: .visible
) {
Button("Mark Complete") { onToggle() }
Button("Cancel", role: .cancel) {}
} message: {
if let d = task.dueDate {
Text("\(task.title)” is scheduled for \(dueFmt.string(from: d)).")
}
}
}
private func countdownLabel(to date: Date) -> String {
@@ -1304,6 +1472,7 @@ struct NLParsed {
var tokenRanges: [NSRange] = []
var isRecurring: Bool = false
var recurrenceLabel: String? = nil
var recurrenceEnd: Date? = nil
var endDate: Date? = nil
var isAllDay: Bool = false
var isBirthday: Bool = false
@@ -1640,7 +1809,7 @@ struct AddTaskSheet: View {
@State private var rawText = ""
@State private var parsed = NLParsed()
@State private var selectedQuadrant: Quadrant
@State private var selectedPriority: Priority = .none
@State private var selectedCategory: TaskCategory = .reminder
@State private var reminderDate: Date? = nil
@State private var constantReminder = false
@@ -1652,9 +1821,11 @@ struct AddTaskSheet: View {
init(prefilledDate: Date? = nil, prefilledQuadrant: Quadrant = .urgent) {
self.prefilledDate = prefilledDate
self.prefilledQuadrant = prefilledQuadrant
_selectedQuadrant = State(initialValue: prefilledQuadrant)
// Adding from a Matrix quadrant: seed the Priority that lands the task there.
let importantRow = (prefilledQuadrant == .urgent || prefilledQuadrant == .schedule)
_selectedPriority = State(initialValue: importantRow ? .high : .none)
var p = NLParsed()
p.date = prefilledDate ?? Calendar.current.startOfDay(for: Date())
p.date = prefilledDate
_parsed = State(initialValue: p)
}
@@ -1672,7 +1843,7 @@ struct AddTaskSheet: View {
.frame(minHeight: 36, maxHeight: 72)
.onChange(of: rawText) { _ in
var p = NLTaskParser.parse(rawText)
if p.date == nil { p.date = Calendar.current.startOfDay(for: Date()) }
if p.date == nil { p.date = prefilledDate }
withAnimation(KisaniSpring.micro) {
parsed = p
if p.isBirthday { selectedCategory = .birthday }
@@ -1720,27 +1891,18 @@ struct AddTaskSheet: View {
Spacer(minLength: 4)
// Quadrant
// Priority drives Matrix placement (importance axis), TickTick-style
Menu {
ForEach(Quadrant.allCases) { q in
Button { withAnimation(KisaniSpring.micro) { selectedQuadrant = q } } label: {
Label {
Text(q.matrixLabel)
} icon: {
Image(uiImage: quadrantBadgeImage(q))
}
ForEach(Priority.allCases) { p in
Button { withAnimation(KisaniSpring.micro) { selectedPriority = p } } label: {
Label(p.label, systemImage: selectedPriority == p ? "checkmark" : "flag")
}
}
} label: {
HStack(spacing: 3) {
Text(selectedQuadrant.roman)
.font(AppFonts.mono(8, weight: .heavy))
.foregroundColor(.white)
.frame(width: 14, height: 14)
.background(selectedQuadrant.color)
.clipShape(Circle())
}
.frame(width: 42, height: 44)
Image(systemName: selectedPriority == .none ? "flag" : "flag.fill")
.font(.system(size: 15))
.foregroundColor(selectedPriority == .none ? AppColors.text3(cs) : selectedPriority.color)
.frame(width: 42, height: 44)
}
.buttonStyle(.plain)
@@ -1807,7 +1969,8 @@ struct AddTaskSheet: View {
endDate: $parsed.endDate,
isAllDay: $parsed.isAllDay,
reminderDate: $reminderDate,
constantReminder: $constantReminder
constantReminder: $constantReminder,
recurrenceEnd: $parsed.recurrenceEnd
)
}
}
@@ -1815,34 +1978,14 @@ struct AddTaskSheet: View {
private func submit() {
guard canAdd else { return }
taskVM.addTask(title: cleanTitle, dueDate: parsed.date, hasTime: parsed.hasTime,
quadrant: selectedQuadrant, category: selectedCategory,
quadrant: prefilledQuadrant, category: selectedCategory,
isRecurring: parsed.isRecurring, recurrenceLabel: parsed.recurrenceLabel,
endDate: parsed.endDate, isAllDay: parsed.isAllDay,
reminderDate: reminderDate, constantReminder: constantReminder)
priority: selectedPriority,
reminderDate: reminderDate, constantReminder: constantReminder,
recurrenceEnd: parsed.recurrenceEnd)
dismiss()
}
private func quadrantBadgeImage(_ q: Quadrant) -> UIImage {
let uiColor: UIColor
switch q {
case .urgent: uiColor = UIColor(AppColors.accent)
case .schedule: uiColor = UIColor(AppColors.yellow)
case .delegate_: uiColor = UIColor(AppColors.blue)
case .eliminate: uiColor = UIColor(AppColors.green)
}
let size = CGSize(width: 20, height: 20)
let renderer = UIGraphicsImageRenderer(size: size)
let img = renderer.image { ctx in
uiColor.setFill()
UIBezierPath(ovalIn: CGRect(origin: .zero, size: size)).fill()
let label = q.roman
let font = UIFont.monospacedSystemFont(ofSize: 8, weight: .heavy)
let attrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: UIColor.white]
let ts = label.size(withAttributes: attrs)
label.draw(at: CGPoint(x: (size.width - ts.width) / 2, y: (size.height - ts.height) / 2), withAttributes: attrs)
}
return img.withRenderingMode(.alwaysOriginal)
}
}
// MARK: - Task Date Picker Sheet
@@ -1852,6 +1995,7 @@ struct TaskDatePickerSheet: View {
@Binding var hasTime: Bool
@Binding var isRecurring: Bool
@Binding var recurrenceLabel: String?
@Binding var recurrenceEnd: Date?
@Binding var endDate: Date?
@Binding var isAllDay: Bool
@Binding var reminderDate: Date?
@@ -1865,17 +2009,16 @@ struct TaskDatePickerSheet: View {
@State private var localTime: Date?
@State private var showTimePicker = false
@State private var showReminderPicker = false
// Reminder lead-time, expressed as days before the task date (nil = no reminder).
// Reminder lead-time, expressed as MINUTES before the task time (nil = no reminder).
@State private var localReminderOffset: Int?
@State private var localReminderTime: Date
@State private var localConstant: Bool
// Custom wheel state (draft committed to the reminder only on "Add")
@State private var showCustomReminder = false
@State private var customNumber: Int
@State private var customUnitWeeks: Bool
@State private var customHour: Int
@State private var customMinute: Int
@State private var customUnit: Int // 0 = minutes, 1 = hours, 2 = days
@State private var localRepeat: String
@State private var localRecurrenceEnd: Date?
@State private var showRecurrenceEndPicker = false
@State private var localIsAllDay: Bool
@State private var editingEnd = false
@@ -1883,11 +2026,13 @@ struct TaskDatePickerSheet: View {
isRecurring: Binding<Bool>, recurrenceLabel: Binding<String?>,
endDate: Binding<Date?>, isAllDay: Binding<Bool>,
reminderDate: Binding<Date?> = .constant(nil),
constantReminder: Binding<Bool> = .constant(false)) {
constantReminder: Binding<Bool> = .constant(false),
recurrenceEnd: Binding<Date?> = .constant(nil)) {
_selectedDate = selectedDate
_hasTime = hasTime
_isRecurring = isRecurring
_recurrenceLabel = recurrenceLabel
_recurrenceEnd = recurrenceEnd
_endDate = endDate
_isAllDay = isAllDay
_reminderDate = reminderDate
@@ -1898,34 +2043,29 @@ struct TaskDatePickerSheet: View {
_localEndDate = State(initialValue: endDate.wrappedValue ?? start)
_localTime = State(initialValue: hasTime.wrappedValue ? selectedDate.wrappedValue : nil)
_localRepeat = State(initialValue: recurrenceLabel.wrappedValue ?? "None")
_localRecurrenceEnd = State(initialValue: recurrenceEnd.wrappedValue)
_localIsAllDay = State(initialValue: isAllDay.wrappedValue)
_pickerMode = State(initialValue: endDate.wrappedValue != nil ? 1 : 0)
_localConstant = State(initialValue: constantReminder.wrappedValue)
// Derive lead-time + time-of-day from any existing absolute reminder date.
// Derive the lead-time (minutes before the event time) from any existing reminder.
if let r = reminderDate.wrappedValue {
let days = cal.dateComponents([.day],
from: cal.startOfDay(for: r),
to: cal.startOfDay(for: start)).day ?? 0
let offset = max(0, days)
_localReminderOffset = State(initialValue: offset)
_localReminderTime = State(initialValue: r)
if offset > 0 && offset % 7 == 0 {
_customUnitWeeks = State(initialValue: true)
_customNumber = State(initialValue: offset / 7)
let base: Date = hasTime.wrappedValue
? (selectedDate.wrappedValue ?? start)
: (cal.date(bySettingHour: 9, minute: 0, second: 0, of: start) ?? start)
let mins = max(0, cal.dateComponents([.minute], from: r, to: base).minute ?? 0)
_localReminderOffset = State(initialValue: mins)
if mins > 0 && mins % 1440 == 0 {
_customUnit = State(initialValue: 2); _customNumber = State(initialValue: mins / 1440)
} else if mins > 0 && mins % 60 == 0 {
_customUnit = State(initialValue: 1); _customNumber = State(initialValue: mins / 60)
} else {
_customUnitWeeks = State(initialValue: false)
_customNumber = State(initialValue: offset)
_customUnit = State(initialValue: 0); _customNumber = State(initialValue: max(1, mins))
}
_customHour = State(initialValue: cal.component(.hour, from: r))
_customMinute = State(initialValue: cal.component(.minute, from: r))
} else {
_localReminderOffset = State(initialValue: nil)
_localReminderTime = State(initialValue: cal.date(bySettingHour: 9, minute: 0, second: 0, of: start) ?? start)
_customUnitWeeks = State(initialValue: false)
_customNumber = State(initialValue: 0)
_customHour = State(initialValue: 9)
_customMinute = State(initialValue: 0)
_customUnit = State(initialValue: 1) // default Custom draft: 1 hour
_customNumber = State(initialValue: 1)
}
}
@@ -2048,6 +2188,32 @@ struct TaskDatePickerSheet: View {
value: repeatDisplayValue, active: localRepeat != "None")
}
.buttonStyle(.plain)
// Repeat until (only when a recurrence is set)
if localRepeat != "None" {
Divider().padding(.leading, 54)
Button { withAnimation(KisaniSpring.micro) { showRecurrenceEndPicker.toggle() } } label: {
pickerRow(icon: "calendar.badge.exclamationmark", label: "Repeat until",
value: localRecurrenceEnd.map { repeatEndFmt.string(from: $0) } ?? "Forever",
active: localRecurrenceEnd != nil)
}
.buttonStyle(.plain)
if showRecurrenceEndPicker {
DatePicker("", selection: Binding(
get: { localRecurrenceEnd ?? localDate },
set: { localRecurrenceEnd = $0 }
), in: localDate..., displayedComponents: .date)
.datePickerStyle(.graphical).labelsHidden()
.padding(.horizontal, 8)
if localRecurrenceEnd != nil {
Button { withAnimation(KisaniSpring.micro) { localRecurrenceEnd = nil; showRecurrenceEndPicker = false } } label: {
Text("Clear end date").font(AppFonts.sans(13)).foregroundColor(AppColors.accent)
}
.frame(maxWidth: .infinity).padding(.bottom, 8)
}
}
}
}
.background(Color(uiColor: cs == .dark ? .secondarySystemBackground : .systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 14))
@@ -2062,14 +2228,15 @@ struct TaskDatePickerSheet: View {
let label: String
}
// Offsets are MINUTES before the event time.
private var reminderPresets: [ReminderPreset] {
[
ReminderPreset(offset: nil, label: "None"),
ReminderPreset(offset: 0, label: "On the day"),
ReminderPreset(offset: 1, label: "1 day early"),
ReminderPreset(offset: 2, label: "2 days early"),
ReminderPreset(offset: 3, label: "3 days early"),
ReminderPreset(offset: 7, label: "1 week early"),
ReminderPreset(offset: nil, label: "None"),
ReminderPreset(offset: 0, label: "On time"),
ReminderPreset(offset: 5, label: "5 minutes early"),
ReminderPreset(offset: 30, label: "30 minutes early"),
ReminderPreset(offset: 60, label: "1 hour early"),
ReminderPreset(offset: 1440, label: "1 day early"),
]
}
@@ -2080,12 +2247,7 @@ struct TaskDatePickerSheet: View {
Button {
withAnimation(KisaniSpring.micro) {
localReminderOffset = preset.offset
if let off = preset.offset {
if off > 0 && off % 7 == 0 { customUnitWeeks = true; customNumber = off / 7 }
else { customUnitWeeks = false; customNumber = off }
} else {
localConstant = false
}
if preset.offset == nil { localConstant = false }
showCustomReminder = false
}
} label: {
@@ -2093,11 +2255,6 @@ struct TaskDatePickerSheet: View {
Text(preset.label)
.font(AppFonts.sans(15))
.foregroundColor(preset.offset == nil ? AppColors.accent : .primary)
if preset.offset != nil {
Text("(\(timeFmt.string(from: localReminderTime)))")
.font(AppFonts.sans(13))
.foregroundColor(.secondary)
}
Spacer()
if !showCustomReminder && localReminderOffset == preset.offset {
Image(systemName: "checkmark")
@@ -2146,38 +2303,34 @@ struct TaskDatePickerSheet: View {
// MARK: - Custom reminder wheel (Day / Week)
// Minutes-per-unit for the Custom wheel: minutes / hours / days.
private let customUnitMinutes = [1, 60, 1440]
private let customUnitNames = ["minute", "hour", "day"]
private func seedCustomDraft() {
let cal = Calendar.current
if let off = localReminderOffset {
if off > 0 && off % 7 == 0 { customUnitWeeks = true; customNumber = off / 7 }
else { customUnitWeeks = false; customNumber = off }
}
customHour = cal.component(.hour, from: localReminderTime)
customMinute = cal.component(.minute, from: localReminderTime)
guard let off = localReminderOffset, off > 0 else { return }
if off % 1440 == 0 { customUnit = 2; customNumber = off / 1440 }
else if off % 60 == 0 { customUnit = 1; customNumber = off / 60 }
else { customUnit = 0; customNumber = off }
}
private var customOffsetMinutes: Int { max(1, customNumber) * customUnitMinutes[customUnit] }
private func commitCustomReminder() {
let cal = Calendar.current
localReminderOffset = customUnitWeeks ? customNumber * 7 : customNumber
localReminderTime = cal.date(bySettingHour: customHour, minute: customMinute,
second: 0, of: localDate) ?? localReminderTime
localReminderOffset = customOffsetMinutes
}
// Label for a given count under the current Day/Week mode.
// Label for a given count under the current unit.
private func customLeadLabel(_ n: Int) -> String {
if n == 0 { return "On the day" }
let unit = customUnitWeeks ? "week" : "day"
let unit = customUnitNames[customUnit]
return "\(n) \(unit)\(n == 1 ? "" : "s") early"
}
private var customMaxCount: Int { customUnitWeeks ? 8 : 60 }
private var customMaxCount: Int { [59, 23, 30][customUnit] }
// Reminder date implied by the current *draft* wheel selection.
private var draftReminderDate: Date? {
let cal = Calendar.current
let off = customUnitWeeks ? customNumber * 7 : customNumber
let base = cal.date(byAdding: .day, value: -off, to: localDate) ?? localDate
return cal.date(bySettingHour: customHour, minute: customMinute, second: 0, of: base)
Calendar.current.date(byAdding: .minute, value: -customOffsetMinutes, to: reminderBaseDate())
}
private var draftReminderInPast: Bool {
@@ -2193,42 +2346,30 @@ struct TaskDatePickerSheet: View {
.onTapGesture { withAnimation(KisaniSpring.snappy) { showCustomReminder = false } }
VStack(spacing: 18) {
// Day / Week segmented toggle
// Minutes / Hours / Days segmented toggle
HStack(spacing: 2) {
customModeButton("Day", weeks: false)
customModeButton("Week", weeks: true)
customModeButton("Minutes", unit: 0)
customModeButton("Hours", unit: 1)
customModeButton("Days", unit: 2)
}
.padding(3)
.background(Color(uiColor: .tertiarySystemFill))
.clipShape(Capsule())
.padding(.top, 20)
// Three-column wheel: lead-time · hour · minute
HStack(spacing: 0) {
Picker("", selection: $customNumber) {
ForEach(0...customMaxCount, id: \.self) { n in
Text(customLeadLabel(n)).tag(n)
}
// Single lead-time wheel (relative to the event time)
Picker("", selection: $customNumber) {
ForEach(1...customMaxCount, id: \.self) { n in
Text(customLeadLabel(n)).tag(n)
}
.pickerStyle(.wheel).frame(maxWidth: .infinity).clipped()
Picker("", selection: $customHour) {
ForEach(0...23, id: \.self) { h in
Text(String(format: "%02d", h)).tag(h)
}
}
.pickerStyle(.wheel).frame(width: 64).clipped()
Picker("", selection: $customMinute) {
ForEach(0...59, id: \.self) { m in
Text(String(format: "%02d", m)).tag(m)
}
}
.pickerStyle(.wheel).frame(width: 64).clipped()
}
.pickerStyle(.wheel)
.frame(maxWidth: .infinity)
.frame(height: 180)
.onChange(of: customUnitWeeks) { _ in
.clipped()
.onChange(of: customUnit) { _ in
if customNumber > customMaxCount { customNumber = customMaxCount }
if customNumber < 1 { customNumber = 1 }
}
// Subtle (non-red) note never alarming.
@@ -2275,15 +2416,19 @@ struct TaskDatePickerSheet: View {
}
@ViewBuilder
private func customModeButton(_ label: String, weeks: Bool) -> some View {
private func customModeButton(_ label: String, unit: Int) -> some View {
Button {
withAnimation(KisaniSpring.micro) { customUnitWeeks = weeks }
withAnimation(KisaniSpring.micro) {
customUnit = unit
if customNumber > customMaxCount { customNumber = customMaxCount }
if customNumber < 1 { customNumber = 1 }
}
} label: {
Text(label)
.font(AppFonts.sans(15, weight: customUnitWeeks == weeks ? .semibold : .regular))
.foregroundColor(customUnitWeeks == weeks ? .primary : .secondary)
.frame(width: 96, height: 36)
.background(customUnitWeeks == weeks ? Color(uiColor: .systemGray4) : Color.clear)
.font(AppFonts.sans(15, weight: customUnit == unit ? .semibold : .regular))
.foregroundColor(customUnit == unit ? .primary : .secondary)
.frame(maxWidth: .infinity).frame(height: 36)
.background(customUnit == unit ? Color(uiColor: .systemGray4) : Color.clear)
.clipShape(Capsule())
}
.buttonStyle(.plain)
@@ -2294,22 +2439,26 @@ struct TaskDatePickerSheet: View {
return computedReminderDate(offset: off) ?? Date() < Date()
}
private func computedReminderDate(offset: Int) -> Date? {
// The event datetime the reminder counts back from: the task's time, or 9 AM if undated.
private func reminderBaseDate() -> Date {
let cal = Calendar.current
let base = cal.date(byAdding: .day, value: -offset, to: localDate) ?? localDate
let tc = cal.dateComponents([.hour, .minute], from: localReminderTime)
return cal.date(bySettingHour: tc.hour ?? 9, minute: tc.minute ?? 0, second: 0, of: base)
if let t = localTime {
let tc = cal.dateComponents([.hour, .minute], from: t)
return cal.date(bySettingHour: tc.hour ?? 9, minute: tc.minute ?? 0, second: 0, of: localDate) ?? localDate
}
return cal.date(bySettingHour: 9, minute: 0, second: 0, of: localDate) ?? localDate
}
private func computedReminderDate(offset: Int) -> Date? {
Calendar.current.date(byAdding: .minute, value: -offset, to: reminderBaseDate())
}
private var reminderSummary: String {
guard let off = localReminderOffset else { return "None" }
let timeStr = timeFmt.string(from: localReminderTime)
if off == 0 { return "On the day · \(timeStr)" }
if off % 7 == 0 {
let w = off / 7
return "\(w) week\(w == 1 ? "" : "s") early · \(timeStr)"
}
return "\(off) day\(off == 1 ? "" : "s") early · \(timeStr)"
if off == 0 { return "On time" }
if off % 1440 == 0 { let d = off / 1440; return "\(d) day\(d == 1 ? "" : "s") early" }
if off % 60 == 0 { let h = off / 60; return "\(h) hour\(h == 1 ? "" : "s") early" }
return "\(off) minute\(off == 1 ? "" : "s") early"
}
@ViewBuilder
@@ -2453,6 +2602,10 @@ struct TaskDatePickerSheet: View {
return "Duration: \(n) day\(n == 1 ? "" : "s")"
}
private let repeatEndFmt: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "MMM d, yyyy"; return f
}()
private func commit() {
if pickerMode == 0 {
var final = Calendar.current.startOfDay(for: localDate)
@@ -2469,6 +2622,7 @@ struct TaskDatePickerSheet: View {
isAllDay = false
isRecurring = localRepeat != "None"
recurrenceLabel = localRepeat != "None" ? localRepeat : nil
recurrenceEnd = localRepeat != "None" ? localRecurrenceEnd : nil
// Reminder is a lead-time offset from the task date at the chosen time-of-day.
if let off = localReminderOffset {
reminderDate = computedReminderDate(offset: off)
@@ -2560,7 +2714,7 @@ struct CalendarGridView: View {
}
private var monthLabel: String {
let f = DateFormatter(); f.dateFormat = "MMMM"; return f.string(from: displayMonth)
let f = DateFormatter(); f.dateFormat = "MMMM yyyy"; return f.string(from: displayMonth)
}
private func shift(_ d: Int) {

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 {
@@ -62,6 +63,7 @@ struct WorkoutView: View {
@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
@@ -101,8 +103,26 @@ struct WorkoutView: View {
.foregroundColor(AppColors.text3(cs))
}
Spacer()
IButton(icon: "chart.bar.xaxis") { showStats = true }
IButton(icon: "clock.arrow.circlepath") { showHistory = true }
IButton(icon: "ellipsis") { showManage = 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)
@@ -158,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: {
@@ -293,6 +324,11 @@ struct WorkoutView: View {
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
.sheet(isPresented: $showStats) {
WorkoutStatsView().environmentObject(vm)
.presentationDetents([.large])
.presentationDragIndicator(.visible)
}
.sheet(isPresented: $showAddSection) {
AddSectionSheet().environmentObject(vm)
.presentationDetents([.height(240)])
@@ -559,6 +595,130 @@ 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
@@ -630,6 +790,88 @@ struct WorkoutHistoryView: View {
}
}
// 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

View File

@@ -42,11 +42,13 @@ struct EventConfigIntent: WidgetConfigurationIntent {
@Parameter(title: "Or event name", default: "My Event")
var eventName: String
@Parameter(title: "Event date", default: Date.now)
var targetDate: Date
// 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", default: Date.now)
var startDate: 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.
@@ -74,6 +76,8 @@ 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()
@@ -100,7 +104,8 @@ struct EventQuery: EntityQuery {
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) }
.map { EventEntity(id: $0.id.uuidString, title: $0.title, dueDate: $0.dueDate,
isRecurring: $0.isRecurring, recurrenceLabel: $0.recurrenceLabel) }
}
}
@@ -151,6 +156,18 @@ struct EventEntry: TimelineEntry {
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 {
@@ -192,65 +209,168 @@ struct EventProvider: AppIntentTimelineProvider {
}
func timeline(for configuration: EventConfigIntent, in context: Context) async -> Timeline<EventEntry> {
let entry = entry(for: configuration)
// Recompute at the next midnight so the grid advances one dot per day.
let nextMidnight = Calendar.current.startOfDay(for: Date().addingTimeInterval(86400))
return Timeline(entries: [entry], policy: .after(nextMidnight))
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 future due date), nearest first.
private func upcomingEvents() -> [EventEntity] {
/// 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) > Date() }
.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) }
.map { EventEntity(id: $0.id.uuidString, title: $0.title, dueDate: $0.dueDate,
isRecurring: $0.isRecurring, recurrenceLabel: $0.recurrenceLabel) }
}
private func entry(for config: EventConfigIntent) -> EventEntry {
var name: String
var target: Date
/// 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
}
if config.upcomingQueue {
// Auto-track the Nth nearest upcoming event.
let upcoming = upcomingEvents()
let idx = max(0, config.autoSelect.rawValue - 1)
if idx < upcoming.count, let due = upcoming[idx].dueDate {
name = upcoming[idx].title
target = due
} else {
name = "No upcoming event"
target = Date().addingTimeInterval(86400)
}
} else if let ev = config.event, let due = ev.dueDate {
// A specifically picked event supplies its own name + date.
name = ev.title
target = due
} else {
// Fully custom event.
name = config.eventName.isEmpty ? "My Event" : config.eventName
target = config.targetDate
/// 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)
}
// Counting anchor: custom mode honors the picker; auto mode counts from
// "now" so the grid fills toward the event. Never start after the target.
let anchor = config.upcomingQueue ? Date() : config.startDate
let start = min(anchor, target)
let mode = UserDefaults.kisani.integer(forKey: countdownUnitKey)
return EventEntry(date: .now, eventName: name, start: start, target: 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: - Widget
// MARK: - Event Countdown (Bars)
struct EventCountdownWidget: Widget {
let kind = "KisaniEventCountdown"
struct EventBarsWidget: Widget {
let kind = "KisaniEventBars"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind, intent: EventConfigIntent.self, provider: EventProvider()) { entry in
EventCountdownView(entry: entry)
EventBarsView(entry: entry)
}
.configurationDisplayName("Event Countdown")
.description("Track the days until any upcoming event with a dot grid.")
.supportedFamilies([.systemMedium, .systemSmall])
.configurationDisplayName("Event Countdown (Bars)")
.description("Count down to an event with a bar meter.")
.supportedFamilies([.systemMedium])
}
}

View File

@@ -5,7 +5,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Kisani Cal Widgets</string>
<string>Wenza Widgets</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>

View File

@@ -36,29 +36,232 @@ struct KisaniProvider: TimelineProvider {
// MARK: - Day Progress Widget ("Day done %")
struct DayProgressWidget: Widget {
let kind = "KisaniDayProgress"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: KisaniProvider()) { _ in
ProgressDotView(period: .day, accent: Color(red: 0.48, green: 0.87, blue: 0.80))
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)")
}
.configurationDisplayName("Day Progress")
.description("How much of today has elapsed, as a dot grid.")
.supportedFamilies([.systemSmall, .systemMedium])
}
}
// MARK: - Tasks Done Today Widget ("Tasks complete %")
struct ProgressDotEntry: TimelineEntry {
let date: Date
let title: String
let progress: Double
let totalDots: Int
}
struct TasksCompleteWidget: Widget {
let kind = "KisaniTasksComplete"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: KisaniProvider()) { _ in
TasksCompleteView()
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))
}
.configurationDisplayName("Tasks Done Today")
.description("Percentage of today's tasks you've completed.")
.supportedFamilies([.systemSmall, .systemMedium])
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])
}
}
@@ -105,10 +308,9 @@ struct LockScreenCircularWidget: Widget {
@main
struct KisaniWidgetBundle: WidgetBundle {
var body: some Widget {
EventCountdownWidget()
EventBarsWidget()
MyTasksWidget()
DayProgressWidget()
TasksCompleteWidget()
LockScreenWidget()
LockScreenCircularWidget()
TaskLiveActivity()

View File

@@ -95,16 +95,16 @@ struct MyTasksView: View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 6) {
Text("Today")
.font(.system(size: 18, weight: .bold))
.foregroundColor(Color(red: 0.42, green: 0.5, blue: 0.96))
.font(.system(.body, design: .monospaced))
.foregroundColor(WidgetTheme.accent)
Text("\(entry.openCount)")
.font(.system(size: 15, weight: .semibold))
.font(.system(.body, design: .monospaced))
.foregroundColor(Color(white: 0.5))
Spacer()
Button(intent: AddTaskFromWidgetIntent()) {
Image(systemName: "plus")
.font(.system(size: 16, weight: .semibold))
.foregroundColor(Color(red: 0.42, green: 0.5, blue: 0.96))
.font(.system(size: 16))
.foregroundColor(WidgetTheme.accent)
}
.buttonStyle(.plain)
}
@@ -112,7 +112,7 @@ struct MyTasksView: View {
if entry.tasks.isEmpty {
Spacer()
Text("All clear for today 🎉")
.font(.system(size: 13))
.font(.system(.caption, design: .monospaced))
.foregroundColor(Color(white: 0.5))
.frame(maxWidth: .infinity, alignment: .center)
Spacer()
@@ -127,7 +127,7 @@ struct MyTasksView: View {
.buttonStyle(.plain)
Text(task.title)
.font(.system(size: 14))
.font(.system(.caption, design: .monospaced))
.foregroundColor(task.isComplete ? Color(white: 0.45) : .white)
.strikethrough(task.isComplete, color: Color(white: 0.45))
.lineLimit(1)
@@ -136,13 +136,13 @@ struct MyTasksView: View {
}
if entry.tasks.count > maxRows {
Text("+\(entry.tasks.count - maxRows) more")
.font(.system(size: 11, weight: .medium))
.font(.system(.caption2, design: .monospaced))
.foregroundColor(Color(white: 0.4))
.padding(.leading, 28)
}
Spacer(minLength: 0)
}
}
.containerBackground(.black, for: .widget)
.containerBackground(WidgetTheme.background, for: .widget)
}
}

View File

@@ -4,7 +4,7 @@ import SwiftUI
@available(iOS 16.1, *)
struct TaskLiveActivity: Widget {
private let accent = Color(red: 0.93, green: 0.42, blue: 0.20) // brand orange
private let accent = WidgetTheme.accent // brand orange
var body: some WidgetConfiguration {
ActivityConfiguration(for: TaskActivityAttributes.self) { context in
@@ -13,29 +13,38 @@ struct TaskLiveActivity: Widget {
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(size: 15, weight: .semibold))
.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(size: 12, design: .monospaced))
.font(.system(.caption, design: .monospaced))
.foregroundColor(.white.opacity(0.6))
.lineLimit(1)
}
}
Spacer()
.layoutPriority(1)
if let due = context.state.dueDate, !context.state.isComplete {
Text(due, style: .timer)
.font(.system(size: 16, weight: .bold, design: .monospaced))
.font(.system(.callout, design: .monospaced))
.monospacedDigit()
.foregroundColor(accent)
.frame(maxWidth: 70)
.lineLimit(1)
.minimumScaleFactor(0.72)
.frame(width: 92, alignment: .trailing)
.fixedSize(horizontal: false, vertical: true)
}
}
.padding(.horizontal, 16)
.padding(.leading, 14)
.padding(.trailing, 10)
.padding(.vertical, 12)
.activityBackgroundTint(Color.black.opacity(0.55))
.activityBackgroundTint(WidgetTheme.background.opacity(0.9))
.activitySystemActionForegroundColor(.white)
} dynamicIsland: { context in
@@ -47,14 +56,17 @@ struct TaskLiveActivity: Widget {
DynamicIslandExpandedRegion(.trailing) {
if let due = context.state.dueDate, !context.state.isComplete {
Text(due, style: .timer)
.font(.system(.body, design: .monospaced))
.font(.system(.callout, design: .monospaced))
.monospacedDigit()
.foregroundColor(accent)
.frame(maxWidth: 64)
.lineLimit(1)
.minimumScaleFactor(0.75)
.frame(width: 86, alignment: .trailing)
}
}
DynamicIslandExpandedRegion(.center) {
Text(context.attributes.title)
.font(.system(size: 14, weight: .semibold))
.font(.system(.callout, design: .monospaced))
.lineLimit(1)
}
} compactLeading: {
@@ -63,8 +75,11 @@ struct TaskLiveActivity: Widget {
if let due = context.state.dueDate, !context.state.isComplete {
Text(due, style: .timer)
.font(.system(.caption2, design: .monospaced))
.monospacedDigit()
.foregroundColor(accent)
.frame(maxWidth: 44)
.lineLimit(1)
.minimumScaleFactor(0.7)
.frame(width: 48, alignment: .trailing)
}
} minimal: {
Image(systemName: "checklist").foregroundColor(accent)

View File

@@ -10,6 +10,11 @@ struct WidgetTask: Codable, Identifiable {
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? {
@@ -60,7 +65,10 @@ func loadWidgetTasks() -> [WidgetTask] {
else { return [] }
return raw.map {
WidgetTask(id: $0.id, title: $0.title, dueDate: $0.dueDate,
isComplete: $0.isComplete, quadrant: $0.quadrant, category: $0.category)
isComplete: $0.isComplete, quadrant: $0.quadrant, category: $0.category,
isRecurring: $0.isRecurring ?? false, recurrenceLabel: $0.recurrenceLabel,
createdAt: $0.createdAt, reminderDate: $0.reminderDate,
progress: $0.countdownProgress ?? $0.progress)
}
}
@@ -72,8 +80,15 @@ private struct RawTask: Codable {
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
case id, title, dueDate, isComplete, quadrant, category, isRecurring, recurrenceLabel
case createdAt, reminderDate, progress, countdownProgress
}
}
@@ -138,21 +153,6 @@ private func CloudSyncManager_widgetPush(_ data: Data, _ key: String) {
kv.synchronize()
}
// Today's task completion: (done, total) for tasks due today.
func todayTaskCompletion() -> (done: Int, total: Int) {
let cal = Calendar.current
let start = cal.startOfDay(for: Date())
let end = cal.date(byAdding: .day, value: 1, to: start)!
let tasks = loadWidgetTasks()
func dueToday(_ t: WidgetTask) -> Bool {
guard let due = t.dueDate else { return false }
return due >= start && due < end
}
let active = tasks.filter { !$0.isComplete && dueToday($0) }
let completed = tasks.filter { $0.isComplete && dueToday($0) }
return (completed.count, active.count + completed.count)
}
// Workout streak count
func loadWorkoutStreak() -> Int {
let uid = UserDefaults.kisani.string(forKey: "kisani.activeUserId") ?? "shared"

View File

@@ -1,145 +1,136 @@
import SwiftUI
import WidgetKit
// MARK: - Dot-grid progress (day / week / month / year)
// MARK: - Shared widget theme (slate background + brand orange)
enum TimePeriod { case day, week, month, year }
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)
}
struct ProgressDotView: View {
let period: TimePeriod
let accent: Color
@Environment(\.widgetFamily) private var family
private var label: String {
switch period {
case .day: let f = DateFormatter(); f.dateFormat = "EEEE d"; return f.string(from: Date())
case .week: return "This week"
case .month: let f = DateFormatter(); f.dateFormat = "MMMM"; return f.string(from: Date())
case .year: let f = DateFormatter(); f.dateFormat = "yyyy"; return f.string(from: Date())
private 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()
}
}
private var progress: Double {
let cal = Calendar.current; let now = Date()
switch period {
case .day:
let sod = cal.startOfDay(for: now)
return (now.timeIntervalSince(sod)) / 86400
case .week:
let comps = cal.dateComponents([.weekday], from: now)
let wd = (comps.weekday ?? 1) - 1
let secIntoDay = now.timeIntervalSince(cal.startOfDay(for: now))
return (Double(wd) * 86400 + secIntoDay) / (7 * 86400)
case .month:
let range = cal.range(of: .day, in: .month, for: now)!
let day = cal.component(.day, from: now) - 1
let secIntoDay = now.timeIntervalSince(cal.startOfDay(for: now))
return (Double(day) * 86400 + secIntoDay) / (Double(range.count) * 86400)
case .year:
let start = cal.date(from: cal.dateComponents([.year], from: now))!
let end = cal.date(byAdding: .year, value: 1, to: start)!
return now.timeIntervalSince(start) / end.timeIntervalSince(start)
}
}
private var subLabel: String {
let pct = Int(progress * 100)
return "\(pct)%"
}
var body: some View {
let isMedium = family == .systemMedium
VStack(alignment: .leading, spacing: isMedium ? 10 : 6) {
HStack(alignment: .firstTextBaseline) {
Text(label)
.font(.system(size: isMedium ? 22 : 14, weight: .bold))
.foregroundColor(.white)
.lineLimit(1)
Spacer()
Text(subLabel)
.font(.system(size: isMedium ? 22 : 13, weight: .bold))
.foregroundColor(Color(white: 0.5))
}
DotGrid(total: 100, filled: Int(progress * 100), color: accent)
}
.padding(.horizontal, isMedium ? 4 : 0)
.containerBackground(.black, for: .widget)
.joined(separator: " ")
}
}
// MARK: - Tasks completed today
// MARK: - Vertical-bar progress (comb / equalizer style)
struct TasksCompleteView: View {
@Environment(\.widgetFamily) private var family
private let teal = Color(red: 0.48, green: 0.87, blue: 0.80)
private var stats: (done: Int, total: Int) { todayTaskCompletion() }
private var percent: Int {
let s = stats
guard s.total > 0 else { return 0 }
return Int((Double(s.done) / Double(s.total) * 100).rounded())
}
var body: some View {
let s = stats
let isMedium = family == .systemMedium
VStack(alignment: .leading, spacing: isMedium ? 10 : 6) {
HStack(alignment: .firstTextBaseline) {
Text("Today's Tasks")
.font(.system(size: isMedium ? 22 : 14, weight: .bold))
.foregroundColor(.white)
.lineLimit(1)
Spacer()
Text("\(percent)%")
.font(.system(size: isMedium ? 22 : 13, weight: .bold))
.foregroundColor(Color(white: 0.5))
}
if s.total == 0 {
Spacer(minLength: 0)
Text("No tasks due today")
.font(.system(size: 12))
.foregroundColor(Color(white: 0.5))
Spacer(minLength: 0)
} else {
Text("\(s.done) of \(s.total) done")
.font(.system(size: 12))
.foregroundColor(teal)
Spacer(minLength: 2)
DotGrid(total: s.total, filled: s.done, color: teal)
}
}
.padding(.horizontal, isMedium ? 4 : 0)
.containerBackground(.black, for: .widget)
}
}
// MARK: - Circular dot grid (event countdown motif)
struct DotGrid: View {
let total: Int
let filled: Int
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 l = layout(for: total, in: geo.size)
VStack(spacing: gap) {
ForEach(0..<l.rows, id: \.self) { row in
HStack(spacing: gap) {
ForEach(0..<l.cols, id: \.self) { col in
let idx = row * l.cols + col
if idx < total {
dot(filled: idx < filled, size: l.size)
} else {
Color.clear.frame(width: l.size, height: l.size)
}
}
Spacer(minLength: 0)
}
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)
}
Spacer(minLength: 0)
}
.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)
}
}
@@ -154,88 +145,56 @@ struct DotGrid: View {
}
}
/// Pick the column count that yields the largest uniform circle that still fits
/// every dot inside the available area an even grid that fills the widget.
private func layout(for count: Int, in size: CGSize) -> (cols: Int, rows: Int, size: CGFloat) {
guard count > 0, size.width > 1, size.height > 1 else { return (1, 1, 6) }
var best = (cols: 1, rows: count, size: CGFloat(0))
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)
if s > best.size { best = (cols, rows, s) }
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 Widget (small + medium)
// MARK: - Event Countdown Bars style (medium)
struct EventCountdownView: View {
struct EventBarsView: View {
let entry: EventEntry
@Environment(\.widgetFamily) var family
private let teal = Color(red: 0.48, green: 0.87, blue: 0.80)
private var percent: Int { Int((entry.progress * 100).rounded()) }
var body: some View {
Group {
if family == .systemSmall {
small
} else {
medium
}
}
.containerBackground(.black, for: .widget)
}
private var filled: Int { Int(entry.progress * Double(entry.totalDays)) }
// Wide layout: event name + days-left countdown, then the dot-grid progress.
private var medium: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(LocalizedStringKey(entry.eventName))
.font(.system(size: 20, weight: .bold))
.foregroundColor(teal)
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .firstTextBaseline, spacing: 10) {
Text(entry.eventName.widgetTitleCased())
.font(.system(.body, design: .monospaced))
.foregroundColor(WidgetTheme.accent)
.lineLimit(1)
Spacer(minLength: 4)
VStack(alignment: .trailing, spacing: 1) {
Button(intent: CycleCountdownUnitIntent()) {
Text(entry.timeLeftLabel)
.font(.system(size: 14, weight: .semibold))
.foregroundColor(Color(white: 0.62))
.lineLimit(1)
}
.buttonStyle(.plain)
Text("\(percent)%")
.font(.system(size: 11, weight: .bold))
.foregroundColor(Color(white: 0.4))
.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)
}
DotGrid(total: entry.totalDays, filled: filled, color: teal)
}
.padding(.horizontal, 4)
}
private var small: some View {
VStack(alignment: .leading, spacing: 6) {
Text(LocalizedStringKey(entry.eventName))
.font(.system(size: 14, weight: .bold))
.foregroundColor(teal)
.lineLimit(2)
Button(intent: CycleCountdownUnitIntent()) {
Text(entry.timeLeftLabel)
.font(.system(size: 12, weight: .semibold))
.foregroundColor(Color(white: 0.62))
.lineLimit(1)
}
.buttonStyle(.plain)
Spacer(minLength: 2)
DotGrid(total: entry.totalDays, filled: filled, color: teal)
Spacer(minLength: 8)
BarGrid(progress: entry.progress, color: WidgetTheme.accent)
.frame(maxHeight: .infinity)
}
.padding(.horizontal, 4).padding(.vertical, 2)
.containerBackground(WidgetTheme.background, for: .widget)
}
}
@@ -248,10 +207,10 @@ struct LockScreenRectView: View {
HStack(spacing: 10) {
VStack(alignment: .leading, spacing: 2) {
Text(task.title)
.font(.system(size: 13, weight: .semibold))
.font(.system(.caption, design: .monospaced))
.lineLimit(1)
Text(task.timeLeftLabel)
.font(.system(size: 11))
.font(.system(.caption2, design: .monospaced))
.foregroundColor(.secondary)
}
Spacer()
@@ -259,7 +218,7 @@ struct LockScreenRectView: View {
ProgressView(value: Double(365 - days), total: 365)
.progressViewStyle(.circular)
.frame(width: 22, height: 22)
.tint(.white)
.tint(WidgetTheme.accent)
} else if task.isComplete {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
@@ -279,14 +238,14 @@ struct LockScreenCircularView: View {
if !task.isComplete, let days = task.daysLeft, days <= 365 {
ProgressView(value: Double(365 - days), total: 365)
.progressViewStyle(.circular)
.tint(.white)
.tint(WidgetTheme.accent)
}
VStack(spacing: 0) {
if let days = task.daysLeft {
Text("\(days)").font(.system(size: 14, weight: .bold))
Text("days").font(.system(size: 7))
Text("\(days)").font(.system(.caption, design: .monospaced))
Text("days").font(.system(.caption2, design: .monospaced))
} else {
Image(systemName: "checkmark").font(.system(size: 14, weight: .bold))
Image(systemName: "checkmark").font(.system(size: 14))
}
}
}

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

@@ -9,8 +9,8 @@ options:
settings:
base:
DEVELOPMENT_TEAM: K8BLMMR883
MARKETING_VERSION: "1.0"
CURRENT_PROJECT_VERSION: "2"
MARKETING_VERSION: "2.0"
CURRENT_PROJECT_VERSION: "9"
schemes:
KisaniCal:
@@ -38,6 +38,11 @@ targets:
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
@@ -45,7 +50,7 @@ targets:
GENERATE_INFOPLIST_FILE: YES
INFOPLIST_KEY_UIApplicationSceneManifest_Generation: YES
INFOPLIST_KEY_UILaunchStoryboardName: LaunchScreen
INFOPLIST_KEY_CFBundleDisplayName: "Kisani Cal"
INFOPLIST_KEY_CFBundleDisplayName: "Wenza"
INFOPLIST_KEY_UIStatusBarStyle: UIStatusBarStyleDefault
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"
@@ -55,10 +60,10 @@ targets:
CODE_SIGN_STYLE: Automatic
CODE_SIGN_ENTITLEMENTS: KisaniCal/KisaniCal.entitlements
ENABLE_PREVIEWS: YES
INFOPLIST_KEY_NSCalendarsUsageDescription: "Kisani Cal shows your calendar events alongside tasks."
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription: "Kisani Cal shows your calendar events alongside tasks."
INFOPLIST_KEY_NSHealthShareUsageDescription: "Kisani Cal reads your steps, energy, heart rate, and workouts to show health stats on your dashboard."
INFOPLIST_KEY_NSHealthUpdateUsageDescription: "Kisani Cal saves workouts you complete to the Health app."
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
@@ -71,7 +76,7 @@ targets:
info:
path: KisaniCalWidgets/Info.plist
properties:
CFBundleDisplayName: "Kisani Cal Widgets"
CFBundleDisplayName: "Wenza Widgets"
CFBundleShortVersionString: "$(MARKETING_VERSION)"
CFBundleVersion: "$(CURRENT_PROJECT_VERSION)"
NSExtension:
@@ -85,3 +90,27 @@ targets:
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>