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>
2027 lines
89 KiB
Swift
2027 lines
89 KiB
Swift
import SwiftUI
|
|
import EventKit
|
|
import EventKitUI
|
|
|
|
// Plain snapshot of a calendar event for scheduling notifications off the main actor.
|
|
struct CalEventNotif {
|
|
let id: String
|
|
let title: String
|
|
let start: Date
|
|
let isAllDay: Bool
|
|
let alarmOffsets: [TimeInterval] // seconds relative to start (negative = before)
|
|
}
|
|
|
|
// MARK: - Calendar Store
|
|
@MainActor
|
|
final class CalendarStore: ObservableObject {
|
|
/// Single shared source of truth so every screen (Today, Calendar, Onboarding,
|
|
/// Settings) sees the same permission state and event cache.
|
|
static let shared = CalendarStore()
|
|
|
|
@Published var authStatus: EKAuthorizationStatus = .notDetermined
|
|
@Published private(set) var isRequesting = false
|
|
@Published var monthEvents: [EKEvent] = []
|
|
|
|
private let ekStore = EKEventStore()
|
|
private var changeToken: NSObjectProtocol?
|
|
private var fetchedMonth: Date?
|
|
|
|
@Published var localCalendars: [EKCalendar] = []
|
|
@Published var hiddenCalendarIDs: Set<String> = []
|
|
@Published var localCalendarsEnabled: Bool = true
|
|
@Published var doNotDisturb: Bool = false
|
|
|
|
private let hiddenIDsKey = "kisani.cal.hiddenIDs"
|
|
private let calEnabledKey = "kisani.cal.localEnabled"
|
|
private let dndKey = "kisani.cal.dnd"
|
|
|
|
init() {
|
|
authStatus = EKEventStore.authorizationStatus(for: .event)
|
|
// Migrate legacy prefs from UserDefaults.standard into the App Group (one-time).
|
|
Self.migrateCalPrefIfNeeded(hiddenIDsKey)
|
|
Self.migrateCalPrefIfNeeded(calEnabledKey)
|
|
Self.migrateCalPrefIfNeeded(dndKey)
|
|
hiddenCalendarIDs = Set(UserDefaults.kisani.stringArray(forKey: hiddenIDsKey) ?? [])
|
|
localCalendarsEnabled = UserDefaults.kisani.object(forKey: calEnabledKey) as? Bool ?? true
|
|
doNotDisturb = UserDefaults.kisani.bool(forKey: dndKey)
|
|
changeToken = NotificationCenter.default.addObserver(
|
|
forName: .EKEventStoreChanged,
|
|
object: ekStore,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
Task { @MainActor [weak self] in
|
|
guard let self else { return }
|
|
// Re-read auth status — may have changed since init (e.g. granted from onboarding)
|
|
self.authStatus = EKEventStore.authorizationStatus(for: .event)
|
|
guard self.isAuthorized else { return }
|
|
self.fetchMonth(containing: self.fetchedMonth ?? Date())
|
|
}
|
|
}
|
|
}
|
|
|
|
// Call when app becomes active so permission granted via Settings is picked up
|
|
func refreshStatus() {
|
|
let current = EKEventStore.authorizationStatus(for: .event)
|
|
guard current != authStatus else { return }
|
|
authStatus = current
|
|
if isAuthorized {
|
|
fetchMonth(containing: fetchedMonth ?? Date())
|
|
loadLocalCalendars()
|
|
}
|
|
}
|
|
|
|
deinit {
|
|
if let t = changeToken { NotificationCenter.default.removeObserver(t) }
|
|
}
|
|
|
|
// MARK: - Permission
|
|
|
|
func requestAccess() {
|
|
// Debounce rapid taps; only the system prompt can move us off .notDetermined.
|
|
guard !isRequesting else { return }
|
|
guard authStatus == .notDetermined else {
|
|
// Already decided (granted/denied/write-only) — re-sync from the system.
|
|
refreshStatus()
|
|
return
|
|
}
|
|
isRequesting = true
|
|
|
|
// Trust the system's authoritative status, not just the `granted` bool.
|
|
let finish: () -> Void = { [weak self] in
|
|
Task { @MainActor [weak self] in
|
|
guard let self else { return }
|
|
self.authStatus = EKEventStore.authorizationStatus(for: .event)
|
|
self.isRequesting = false
|
|
if self.isAuthorized {
|
|
self.fetchMonth(containing: self.fetchedMonth ?? Date())
|
|
self.loadLocalCalendars()
|
|
}
|
|
}
|
|
}
|
|
|
|
if #available(iOS 17.0, *) {
|
|
ekStore.requestFullAccessToEvents { _, _ in finish() }
|
|
} else {
|
|
ekStore.requestAccess(to: .event) { _, _ in finish() }
|
|
}
|
|
}
|
|
|
|
// MARK: - Fetching
|
|
|
|
func fetchMonth(containing date: Date) {
|
|
guard isAuthorized else { return }
|
|
let cal = Calendar.current
|
|
guard let interval = cal.dateInterval(of: .month, for: date) else { return }
|
|
let start = cal.date(byAdding: .weekOfYear, value: -1, to: interval.start) ?? interval.start
|
|
let end = cal.date(byAdding: .weekOfYear, value: 1, to: interval.end) ?? interval.end
|
|
fetchedMonth = date
|
|
guard localCalendarsEnabled else { monthEvents = []; return }
|
|
let allCals = ekStore.calendars(for: .event)
|
|
let visibleCals = allCals.filter { !hiddenCalendarIDs.contains($0.calendarIdentifier) }
|
|
guard !visibleCals.isEmpty else { monthEvents = []; return }
|
|
let pred = ekStore.predicateForEvents(withStart: start, end: end, calendars: visibleCals)
|
|
monthEvents = ekStore.events(matching: pred)
|
|
.sorted { ($0.startDate ?? .distantFuture) < ($1.startDate ?? .distantFuture) }
|
|
}
|
|
|
|
// Events for a specific day, derived from the cached month set
|
|
func events(for date: Date) -> [EKEvent] {
|
|
let cal = Calendar.current
|
|
let start = cal.startOfDay(for: date)
|
|
let end = cal.date(byAdding: .day, value: 1, to: start)!
|
|
return monthEvents.filter { event in
|
|
guard let s = event.startDate, let e = event.endDate else { return false }
|
|
return s < end && e > start
|
|
}
|
|
}
|
|
|
|
var isAuthorized: Bool {
|
|
if #available(iOS 17.0, *) {
|
|
return authStatus == .authorized || authStatus == .fullAccess
|
|
}
|
|
return authStatus == .authorized
|
|
}
|
|
|
|
/// Upcoming visible events over the next `days`, as plain snapshots for the
|
|
/// notification scheduler. Honors the calendar-enabled flag and hidden calendars.
|
|
func upcomingEventNotifs(days: Int) -> [CalEventNotif] {
|
|
guard isAuthorized, localCalendarsEnabled else { return [] }
|
|
let now = Date()
|
|
guard let end = Calendar.current.date(byAdding: .day, value: days, to: now) else { return [] }
|
|
let cals = ekStore.calendars(for: .event).filter { !hiddenCalendarIDs.contains($0.calendarIdentifier) }
|
|
guard !cals.isEmpty else { return [] }
|
|
let pred = ekStore.predicateForEvents(withStart: now, end: end, calendars: cals)
|
|
return ekStore.events(matching: pred)
|
|
.sorted { ($0.startDate ?? .distantFuture) < ($1.startDate ?? .distantFuture) }
|
|
.compactMap { ev in
|
|
guard let start = ev.startDate else { return nil }
|
|
let offsets: [TimeInterval] = (ev.alarms ?? []).map { alarm in
|
|
if let abs = alarm.absoluteDate { return abs.timeIntervalSince(start) }
|
|
return alarm.relativeOffset
|
|
}
|
|
return CalEventNotif(
|
|
id: ev.eventIdentifier ?? UUID().uuidString,
|
|
title: ev.title ?? "Event",
|
|
start: start,
|
|
isAllDay: ev.isAllDay,
|
|
alarmOffsets: offsets
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Exposed so the edit sheet can hand it to EKEventEditViewController.
|
|
var eventStore: EKEventStore { ekStore }
|
|
|
|
/// Delete a real (writable) calendar event, then refresh the month cache.
|
|
func deleteEvent(_ event: EKEvent) {
|
|
guard event.calendar?.allowsContentModifications == true else { return }
|
|
do {
|
|
try ekStore.remove(event, span: .thisEvent)
|
|
fetchMonth(containing: event.startDate ?? Date())
|
|
} catch {
|
|
print("Delete event failed: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
/// Only `.notDetermined` can show the system prompt; otherwise we must deep-link to Settings.
|
|
var canRequest: Bool { authStatus == .notDetermined }
|
|
var isDenied: Bool { authStatus == .denied || authStatus == .restricted }
|
|
|
|
// MARK: - Local Calendar Management
|
|
|
|
func loadLocalCalendars() {
|
|
guard isAuthorized else { return }
|
|
localCalendars = ekStore.calendars(for: .event)
|
|
}
|
|
|
|
func isCalendarVisible(_ cal: EKCalendar) -> Bool {
|
|
!hiddenCalendarIDs.contains(cal.calendarIdentifier)
|
|
}
|
|
|
|
func toggleCalendarVisibility(_ cal: EKCalendar) {
|
|
if hiddenCalendarIDs.contains(cal.calendarIdentifier) {
|
|
hiddenCalendarIDs.remove(cal.calendarIdentifier)
|
|
} else {
|
|
hiddenCalendarIDs.insert(cal.calendarIdentifier)
|
|
}
|
|
saveCalPrefs()
|
|
if let m = fetchedMonth { fetchMonth(containing: m) }
|
|
}
|
|
|
|
func setLocalCalendarsEnabled(_ enabled: Bool) {
|
|
localCalendarsEnabled = enabled
|
|
saveCalPrefs()
|
|
// Turning it on with no access yet should prompt, not silently no-op.
|
|
if enabled && !isAuthorized { requestAccess() }
|
|
if let m = fetchedMonth { fetchMonth(containing: m) }
|
|
}
|
|
|
|
func setDoNotDisturb(_ on: Bool) {
|
|
doNotDisturb = on
|
|
UserDefaults.kisani.set(on, forKey: dndKey)
|
|
CloudSyncManager.shared.push(value: on, forKey: dndKey)
|
|
}
|
|
|
|
private func saveCalPrefs() {
|
|
UserDefaults.kisani.set(Array(hiddenCalendarIDs), forKey: hiddenIDsKey)
|
|
UserDefaults.kisani.set(localCalendarsEnabled, forKey: calEnabledKey)
|
|
CloudSyncManager.shared.push(value: Array(hiddenCalendarIDs), forKey: hiddenIDsKey)
|
|
CloudSyncManager.shared.push(value: localCalendarsEnabled, forKey: calEnabledKey)
|
|
}
|
|
|
|
private static func migrateCalPrefIfNeeded(_ key: String) {
|
|
if UserDefaults.kisani.object(forKey: key) == nil,
|
|
let legacy = UserDefaults.standard.object(forKey: key) {
|
|
UserDefaults.kisani.set(legacy, forKey: key)
|
|
UserDefaults.standard.removeObject(forKey: key)
|
|
}
|
|
}
|
|
|
|
func groupedCalendars() -> [CalendarGroup] {
|
|
guard isAuthorized else { return [] }
|
|
let all = ekStore.calendars(for: .event)
|
|
let defaultID = ekStore.defaultCalendarForNewEvents?.calendarIdentifier
|
|
let defaultCals = all.filter { $0.calendarIdentifier == defaultID }
|
|
let subscribed = all.filter { $0.type == .subscription }
|
|
let other = all.filter { $0.calendarIdentifier != defaultID && $0.type != .subscription }
|
|
var groups: [CalendarGroup] = []
|
|
if !defaultCals.isEmpty { groups.append(.init(id: "default", title: "Local Calendar (Default)", calendars: defaultCals)) }
|
|
if !other.isEmpty { groups.append(.init(id: "other", title: "Local Calendar (Other)", calendars: other)) }
|
|
if !subscribed.isEmpty { groups.append(.init(id: "subscribed", title: "Local Calendar (Subscribed Calendars)", calendars: subscribed)) }
|
|
return groups
|
|
}
|
|
}
|
|
|
|
// MARK: - Calendar Group
|
|
struct CalendarGroup: Identifiable {
|
|
let id: String
|
|
let title: String
|
|
let calendars: [EKCalendar]
|
|
}
|
|
|
|
// MARK: - Calendar View Mode
|
|
enum CalendarViewMode: String, CaseIterable {
|
|
case list = "List"
|
|
case year = "Year"
|
|
case month = "Month"
|
|
case week = "Week"
|
|
case threeDay = "3 Day"
|
|
case day = "Day"
|
|
|
|
var sfSymbol: String {
|
|
switch self {
|
|
case .list: return "list.bullet.below.rectangle"
|
|
case .year: return "square.grid.4x3.fill"
|
|
case .month: return "square.grid.3x2.fill"
|
|
case .week: return "rectangle.split.3x1.fill"
|
|
case .threeDay: return "square.split.2x1.fill"
|
|
case .day: return "rectangle.portrait.fill"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Calendar View
|
|
struct CalendarView: View {
|
|
@Environment(\.colorScheme) var cs
|
|
@Environment(\.scenePhase) private var scenePhase
|
|
@EnvironmentObject var taskVM: TaskViewModel
|
|
@EnvironmentObject var workoutVM: WorkoutViewModel
|
|
@StateObject private var calStore = CalendarStore.shared
|
|
@State private var selectedDate: Date = Date()
|
|
@State private var displayMonth: Date = {
|
|
let c = Calendar.current
|
|
return c.date(from: c.dateComponents([.year, .month], from: Date())) ?? Date()
|
|
}()
|
|
@State private var viewMode: CalendarViewMode = .month
|
|
@State private var calTaskListMode: Bool = false
|
|
@State private var showAddTask = false
|
|
@State private var showCalendarConnect = false
|
|
@State private var showWorkoutSession = false
|
|
@State private var editingEvent: EditableEvent? = nil
|
|
|
|
private let cal = Calendar.current
|
|
private let weekdays = ["S","M","T","W","T","F","S"]
|
|
|
|
var body: some View {
|
|
ZStack(alignment: .bottomTrailing) {
|
|
VStack(spacing: 0) {
|
|
// ── Header ──
|
|
HStack(spacing: 0) {
|
|
// Left circle: view mode picker
|
|
Menu {
|
|
Picker(selection: $viewMode, label: EmptyView()) {
|
|
ForEach(CalendarViewMode.allCases, id: \.self) { mode in
|
|
Label(mode.rawValue, systemImage: mode.sfSymbol).tag(mode)
|
|
}
|
|
}
|
|
} label: {
|
|
Image(systemName: viewMode.sfSymbol)
|
|
.font(.system(size: 15, weight: .medium))
|
|
.foregroundColor(AppColors.text(cs))
|
|
.frame(width: 46, height: 46)
|
|
.background(AppColors.surface(cs))
|
|
.clipShape(Circle())
|
|
.contentShape(Circle())
|
|
.shadow(color: .black.opacity(0.08), radius: 10, x: 0, y: 3)
|
|
}
|
|
.menuStyle(.automatic)
|
|
|
|
// Center: month + year
|
|
Spacer()
|
|
Text(monthTitle)
|
|
.font(AppFonts.sans(17, weight: .semibold))
|
|
.foregroundColor(AppColors.text(cs))
|
|
Spacer()
|
|
|
|
// Right: controls pill (view mode picker + more)
|
|
HStack(spacing: 0) {
|
|
// Right pill left button: task list toggle
|
|
Button {
|
|
withAnimation(KisaniSpring.snappy) { calTaskListMode.toggle() }
|
|
} label: {
|
|
Image(systemName: calTaskListMode ? "calendar.day.timeline.left" : "list.bullet.below.rectangle")
|
|
.font(.system(size: 15, weight: .medium))
|
|
.foregroundColor(calTaskListMode ? AppColors.accent : AppColors.text(cs))
|
|
.frame(width: 46, height: 36)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
Rectangle()
|
|
.fill(AppColors.border(cs))
|
|
.frame(width: 1, height: 18)
|
|
Menu {
|
|
Button { } label: { Label("Filter View Range", systemImage: "line.3.horizontal.decrease") }
|
|
Button { } label: { Label("View Options", systemImage: "gearshape.2") }
|
|
Button { } label: { Label("Arrange Tasks", systemImage: "list.bullet.rectangle") }
|
|
Divider()
|
|
Button { showCalendarConnect = true } label: {
|
|
Label("Calendar Subscription", systemImage: calStore.isAuthorized ? "calendar.badge.checkmark" : "calendar.badge.plus")
|
|
}
|
|
Divider()
|
|
Button { } label: { Label("Share", systemImage: "square.and.arrow.up") }.disabled(true)
|
|
Button { } label: { Label("Print", systemImage: "printer") }.disabled(true)
|
|
Button { } label: { Label("Select", systemImage: "checkmark.circle") }.disabled(true)
|
|
} label: {
|
|
Image(systemName: "ellipsis")
|
|
.font(.system(size: 14, weight: .medium))
|
|
.foregroundColor(AppColors.text(cs))
|
|
.frame(width: 46, height: 36)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.menuStyle(.automatic)
|
|
}
|
|
.background(AppColors.surface2(cs))
|
|
.clipShape(Capsule())
|
|
.overlay(Capsule().stroke(AppColors.border(cs), lineWidth: 1))
|
|
}
|
|
.padding(.horizontal, 18).padding(.top, 8)
|
|
|
|
calendarContent
|
|
|
|
if calTaskListMode {
|
|
Divider().background(AppColors.border(cs)).padding(.horizontal, 16)
|
|
calDayTaskListView
|
|
}
|
|
}
|
|
|
|
FAB { showAddTask = true }
|
|
.padding(.trailing, 20).padding(.bottom, 10)
|
|
}
|
|
.background(AppColors.background(cs).ignoresSafeArea())
|
|
.sheet(isPresented: $showAddTask) {
|
|
AddTaskSheet(prefilledDate: selectedDate)
|
|
.environmentObject(taskVM)
|
|
.presentationDetents([.height(150)])
|
|
.presentationDragIndicator(.visible)
|
|
}
|
|
.sheet(isPresented: $showCalendarConnect) {
|
|
CalendarConnectSheet(calStore: calStore)
|
|
.presentationDetents([.large])
|
|
.presentationDragIndicator(.visible)
|
|
}
|
|
.sheet(isPresented: $showWorkoutSession) {
|
|
WorkoutSessionSheet()
|
|
.environmentObject(workoutVM)
|
|
.presentationDetents([.large])
|
|
.presentationDragIndicator(.visible)
|
|
}
|
|
.sheet(item: $editingEvent) { wrapper in
|
|
EventEditView(event: wrapper.event, store: calStore.eventStore) {
|
|
calStore.fetchMonth(containing: selectedDate)
|
|
}
|
|
.ignoresSafeArea()
|
|
}
|
|
.onAppear {
|
|
calStore.refreshStatus()
|
|
if calStore.isAuthorized { calStore.fetchMonth(containing: displayMonth) }
|
|
}
|
|
.onChange(of: displayMonth) { newMonth in
|
|
if calStore.isAuthorized { calStore.fetchMonth(containing: newMonth) }
|
|
}
|
|
.onChange(of: scenePhase) { phase in
|
|
if phase == .active { calStore.refreshStatus() }
|
|
}
|
|
}
|
|
|
|
private var monthTitle: String {
|
|
let f = DateFormatter(); f.dateFormat = "MMMM yyyy"
|
|
return f.string(from: displayMonth)
|
|
}
|
|
|
|
private var monthShortTitle: String {
|
|
let f = DateFormatter(); f.dateFormat = "MMMM"
|
|
return f.string(from: displayMonth)
|
|
}
|
|
|
|
private func navigateMonth(_ offset: Int) {
|
|
withAnimation(KisaniSpring.snappy) {
|
|
displayMonth = cal.date(byAdding: .month, value: offset, to: displayMonth) ?? displayMonth
|
|
}
|
|
}
|
|
|
|
private func gridDays() -> [Date] {
|
|
guard
|
|
let interval = cal.dateInterval(of: .month, for: displayMonth),
|
|
let firstWeek = cal.dateInterval(of: .weekOfMonth, for: interval.start),
|
|
let lastWeek = cal.dateInterval(of: .weekOfMonth, for: interval.end - 1)
|
|
else { return [] }
|
|
var days: [Date] = []; var cur = firstWeek.start
|
|
while cur < lastWeek.end {
|
|
days.append(cur)
|
|
cur = cal.date(byAdding: .day, value: 1, to: cur)!
|
|
}
|
|
return days
|
|
}
|
|
|
|
private func eventDots(for date: Date) -> [Color] {
|
|
var dots: [Color] = []
|
|
if workoutVM.program(for: date) != nil { dots.append(AppColors.blue) }
|
|
if calStore.isAuthorized && dots.count < 3 {
|
|
let calDay = calStore.events(for: date)
|
|
if !calDay.isEmpty {
|
|
let calColor = calDay.first.flatMap { Color(cgColor: $0.calendar.cgColor) } ?? AppColors.green
|
|
dots.append(calColor)
|
|
}
|
|
}
|
|
// Includes recurring occurrences that land on this date.
|
|
let taskDots = taskVM.occurrences(on: date)
|
|
.filter { !$0.isComplete }
|
|
.prefix(max(0, 3 - dots.count))
|
|
.map { $0.quadrant.color }
|
|
dots.append(contentsOf: taskDots)
|
|
return dots
|
|
}
|
|
|
|
// 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 incomplete = all.filter { !$0.isComplete }
|
|
let complete = all.filter { $0.isComplete }
|
|
|
|
return ScrollView(showsIndicators: false) {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
if !incomplete.isEmpty {
|
|
Text("Today")
|
|
.font(AppFonts.sans(12, weight: .semibold))
|
|
.foregroundColor(AppColors.text2(cs))
|
|
.padding(.horizontal, 18).padding(.top, 14).padding(.bottom, 8)
|
|
|
|
VStack(spacing: 0) {
|
|
ForEach(Array(incomplete.enumerated()), id: \.element.id) { i, task in
|
|
CalTaskRow(task: task) { taskVM.toggle(task) }
|
|
if i < incomplete.count - 1 {
|
|
Divider().background(AppColors.border(cs)).padding(.leading, 52)
|
|
}
|
|
}
|
|
}
|
|
.background(AppColors.surface(cs))
|
|
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
|
|
.overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.border(cs), lineWidth: 1))
|
|
.padding(.horizontal, 16)
|
|
}
|
|
|
|
if !complete.isEmpty {
|
|
Text("Completed")
|
|
.font(AppFonts.sans(12, weight: .semibold))
|
|
.foregroundColor(AppColors.text3(cs))
|
|
.padding(.horizontal, 18).padding(.top, 16).padding(.bottom, 8)
|
|
|
|
VStack(spacing: 0) {
|
|
ForEach(Array(complete.enumerated()), id: \.element.id) { i, task in
|
|
CalTaskRow(task: task) { taskVM.toggle(task) }
|
|
if i < complete.count - 1 {
|
|
Divider().background(AppColors.border(cs)).padding(.leading, 52)
|
|
}
|
|
}
|
|
}
|
|
.background(AppColors.surface(cs))
|
|
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
|
|
.overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.border(cs), lineWidth: 1))
|
|
.padding(.horizontal, 16)
|
|
}
|
|
|
|
if incomplete.isEmpty && complete.isEmpty {
|
|
cvEmptyDay.padding(.top, 48)
|
|
}
|
|
|
|
Spacer().frame(height: 90)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Content switcher
|
|
|
|
@ViewBuilder private var calendarContent: some View {
|
|
switch viewMode {
|
|
case .month: monthView
|
|
case .list: listView
|
|
case .year: yearView
|
|
case .week: weekView
|
|
case .threeDay: threeDayView
|
|
case .day: dayView
|
|
}
|
|
}
|
|
|
|
// MARK: - Month
|
|
|
|
private var monthView: some View {
|
|
Group {
|
|
HStack(spacing: 0) {
|
|
ForEach(Array(weekdays.enumerated()), id: \.offset) { _, d in
|
|
Text(d).font(AppFonts.mono(9, weight: .semibold))
|
|
.foregroundColor(AppColors.text3(cs)).frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
.padding(.horizontal, 16).padding(.top, 14).padding(.bottom, 3)
|
|
|
|
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 1), count: 7), spacing: 1) {
|
|
ForEach(gridDays(), id: \.self) { date in
|
|
DayCell(
|
|
date: date,
|
|
isCurrentMonth: cal.isDate(date, equalTo: displayMonth, toGranularity: .month),
|
|
isToday: cal.isDateInToday(date),
|
|
isSelected: cal.isDate(date, inSameDayAs: selectedDate),
|
|
dots: eventDots(for: date)
|
|
)
|
|
.onTapGesture { withAnimation(KisaniSpring.micro) { selectedDate = date } }
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.gesture(DragGesture(minimumDistance: 40).onEnded { v in
|
|
if v.translation.width < -40 { navigateMonth(1) }
|
|
else if v.translation.width > 40 { navigateMonth(-1) }
|
|
})
|
|
|
|
Divider().background(AppColors.border(cs)).padding(.horizontal, 16).padding(.top, 6)
|
|
|
|
ScrollView(showsIndicators: false) {
|
|
DayTimelineView(
|
|
date: 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) }
|
|
showWorkoutSession = true
|
|
},
|
|
onToggle: { task in
|
|
withAnimation(KisaniSpring.snappy) { taskVM.toggle(task) }
|
|
},
|
|
onPin: { taskVM.pin($0) },
|
|
onDelete: { taskVM.delete($0) },
|
|
onSetDate: { taskVM.setDate($0, date: $1) },
|
|
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
|
|
onSetPriority: { taskVM.setPriority($0, priority: $1) },
|
|
onSetCategory: { taskVM.setCategory($0, category: $1) },
|
|
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) },
|
|
onEditEvent: { editingEvent = EditableEvent(event: $0) },
|
|
onDeleteEvent: { calStore.deleteEvent($0) }
|
|
)
|
|
.padding(.horizontal, 16).padding(.top, 12).padding(.bottom, 20)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - List
|
|
|
|
private var listView: some View {
|
|
VStack(spacing: 0) {
|
|
cvWeekStrip.padding(.top, 12).padding(.bottom, 6)
|
|
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 events = calStore.isAuthorized ? calStore.events(for: selectedDate) : []
|
|
if dayTasks.isEmpty && events.isEmpty {
|
|
cvEmptyDay
|
|
} else {
|
|
ForEach(events, id: \.eventIdentifier) { ev in
|
|
HStack(spacing: 12) {
|
|
RoundedRectangle(cornerRadius: 2)
|
|
.fill(Color(cgColor: ev.calendar.cgColor))
|
|
.frame(width: 3, height: 38)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(ev.title ?? "Event")
|
|
.font(AppFonts.sans(13, weight: .medium)).foregroundColor(AppColors.text(cs))
|
|
if let s = ev.startDate {
|
|
Text(cvTimeFmt.string(from: s)).font(AppFonts.mono(10)).foregroundColor(AppColors.text3(cs))
|
|
}
|
|
}
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 16).padding(.vertical, 10)
|
|
Divider().background(AppColors.border(cs)).padding(.leading, 44)
|
|
}
|
|
ForEach(dayTasks) { task in
|
|
HStack(spacing: 12) {
|
|
RoundedRectangle(cornerRadius: 3)
|
|
.strokeBorder(task.quadrant.color, lineWidth: 1.5)
|
|
.frame(width: 18, height: 18)
|
|
Text(task.title).font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs))
|
|
Spacer()
|
|
if let d = task.dueDate {
|
|
let h = cal.component(.hour, from: d)
|
|
let m = cal.component(.minute, from: d)
|
|
if h != 0 || m != 0 {
|
|
Text(cvTimeFmt.string(from: d)).font(AppFonts.mono(10)).foregroundColor(AppColors.text3(cs))
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 16).padding(.vertical, 10)
|
|
Divider().background(AppColors.border(cs)).padding(.leading, 44)
|
|
}
|
|
}
|
|
}
|
|
.padding(.top, 4)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Year
|
|
|
|
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 VStack(spacing: 0) {
|
|
// Year header with navigation (unbounded — go as far forward/back as you like).
|
|
HStack {
|
|
Button { navigateYear(-1) } label: {
|
|
Image(systemName: "chevron.left").font(.system(size: 14, weight: .semibold))
|
|
.foregroundColor(AppColors.text(cs)).frame(width: 40, height: 40)
|
|
}
|
|
Spacer()
|
|
Text(String(year))
|
|
.font(AppFonts.sans(20, weight: .bold)).foregroundColor(AppColors.text(cs))
|
|
Spacer()
|
|
Button { navigateYear(1) } label: {
|
|
Image(systemName: "chevron.right").font(.system(size: 14, weight: .semibold))
|
|
.foregroundColor(AppColors.text(cs)).frame(width: 40, height: 40)
|
|
}
|
|
}
|
|
.padding(.horizontal, 16).padding(.top, 4)
|
|
|
|
ScrollView(showsIndicators: false) {
|
|
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 24) {
|
|
ForEach(months, id: \.self) { m in
|
|
cvMiniMonth(m)
|
|
}
|
|
}
|
|
.padding(16)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func navigateYear(_ offset: Int) {
|
|
withAnimation(KisaniSpring.snappy) {
|
|
displayMonth = cal.date(byAdding: .year, value: offset, to: displayMonth) ?? displayMonth
|
|
}
|
|
}
|
|
|
|
private func cvMiniMonth(_ month: Date) -> some View {
|
|
let mDays = cvMiniGridDays(month)
|
|
return VStack(spacing: 3) {
|
|
Text(cvMonthFmt.string(from: month))
|
|
.font(AppFonts.sans(10, weight: .semibold)).foregroundColor(AppColors.text(cs))
|
|
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 0), count: 7), spacing: 1) {
|
|
ForEach(Array(mDays.enumerated()), id: \.offset) { _, date in
|
|
let isToday = cal.isDateInToday(date)
|
|
let inMon = cal.isDate(date, equalTo: month, toGranularity: .month)
|
|
ZStack {
|
|
if isToday { Circle().fill(AppColors.accent).frame(width: 14, height: 14) }
|
|
Text("\(cal.component(.day, from: date))")
|
|
.font(AppFonts.sans(7, weight: isToday ? .bold : .regular))
|
|
.foregroundColor(isToday ? .white : inMon ? AppColors.text(cs) : AppColors.text3(cs))
|
|
}
|
|
.frame(height: 14)
|
|
.onTapGesture {
|
|
withAnimation(KisaniSpring.micro) {
|
|
selectedDate = date
|
|
displayMonth = cal.date(from: cal.dateComponents([.year, .month], from: date)) ?? date
|
|
viewMode = .month
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func cvMiniGridDays(_ month: Date) -> [Date] {
|
|
guard let interval = cal.dateInterval(of: .month, for: month),
|
|
let firstWeek = cal.dateInterval(of: .weekOfMonth, for: interval.start),
|
|
let lastWeek = cal.dateInterval(of: .weekOfMonth, for: interval.end - 1)
|
|
else { return [] }
|
|
var days: [Date] = []; var cur = firstWeek.start
|
|
while cur < lastWeek.end { days.append(cur); cur = cal.date(byAdding: .day, value: 1, to: cur)! }
|
|
return days
|
|
}
|
|
|
|
// MARK: - Week
|
|
|
|
private var weekView: some View {
|
|
HStack(alignment: .top, spacing: 0) {
|
|
VStack(spacing: 0) {
|
|
HStack(spacing: 0) {
|
|
ForEach(Array(weekdays.enumerated()), id: \.offset) { _, d in
|
|
Text(d).font(AppFonts.mono(7, weight: .semibold))
|
|
.foregroundColor(AppColors.text3(cs)).frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
.padding(.horizontal, 6).padding(.top, 10)
|
|
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 0), count: 7), spacing: 2) {
|
|
ForEach(Array(gridDays().enumerated()), id: \.offset) { _, date in
|
|
let isTod = cal.isDateInToday(date)
|
|
let isSel = cal.isDate(date, inSameDayAs: selectedDate)
|
|
let inMon = cal.isDate(date, equalTo: displayMonth, toGranularity: .month)
|
|
ZStack {
|
|
if isTod { Circle().fill(AppColors.accent).frame(width: 20, height: 20) }
|
|
else if isSel { Circle().fill(AppColors.accentSoft).frame(width: 20, height: 20) }
|
|
Text("\(cal.component(.day, from: date))")
|
|
.font(AppFonts.sans(9, weight: isTod ? .bold : .regular))
|
|
.foregroundColor(isTod ? .white : inMon ? AppColors.text(cs) : AppColors.text3(cs))
|
|
}
|
|
.frame(height: 22)
|
|
.onTapGesture { withAnimation(KisaniSpring.micro) { selectedDate = date } }
|
|
}
|
|
}
|
|
.padding(.horizontal, 4)
|
|
.gesture(DragGesture(minimumDistance: 40).onEnded { v in
|
|
if v.translation.width < -40 { navigateMonth(1) }
|
|
else if v.translation.width > 40 { navigateMonth(-1) }
|
|
})
|
|
Spacer()
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
|
|
Rectangle().fill(AppColors.border(cs)).frame(width: 1)
|
|
|
|
ScrollView(showsIndicators: false) {
|
|
VStack(spacing: 0) {
|
|
ForEach(cvWeekDays(), id: \.self) { date in
|
|
let isTod = cal.isDateInToday(date)
|
|
let isSel = cal.isDate(date, inSameDayAs: selectedDate)
|
|
let dots = eventDots(for: date)
|
|
VStack(alignment: .leading, spacing: 5) {
|
|
HStack(spacing: 4) {
|
|
Text(cvDayFmt.string(from: date))
|
|
.font(AppFonts.mono(9, weight: .semibold))
|
|
.foregroundColor(isTod ? AppColors.accent : AppColors.text2(cs))
|
|
Text("\(cal.component(.day, from: date))")
|
|
.font(AppFonts.sans(12, weight: isTod ? .bold : .medium))
|
|
.foregroundColor(isTod ? AppColors.accent : AppColors.text(cs))
|
|
}
|
|
if dots.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)
|
|
}
|
|
}
|
|
}
|
|
.padding(10)
|
|
.background(isSel ? AppColors.accentSoft : Color.clear)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture { withAnimation(KisaniSpring.micro) { selectedDate = date } }
|
|
Divider().background(AppColors.border(cs))
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
|
|
// MARK: - 3 Day
|
|
|
|
private var threeDayView: some View {
|
|
let days = cvAdjacentDays(3)
|
|
return VStack(spacing: 0) {
|
|
cvDayColumnHeader(days)
|
|
Divider().background(AppColors.border(cs))
|
|
ScrollView(showsIndicators: false) {
|
|
cvTimeGrid(days: days).padding(.bottom, 40)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Day
|
|
|
|
private var dayView: some View {
|
|
VStack(spacing: 0) {
|
|
cvWeekStrip.padding(.top, 12).padding(.bottom, 6)
|
|
Divider().background(AppColors.border(cs))
|
|
ScrollView(showsIndicators: false) {
|
|
cvTimeGrid(days: [selectedDate]).padding(.bottom, 40)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Time grid
|
|
|
|
private let cvStartH = 6
|
|
private let cvEndH = 22
|
|
private let cvHourH: CGFloat = 52
|
|
|
|
private func cvDayColumnHeader(_ days: [Date]) -> some View {
|
|
HStack(spacing: 0) {
|
|
Color.clear.frame(width: 36)
|
|
ForEach(days, id: \.self) { date in
|
|
let isTod = cal.isDateInToday(date)
|
|
VStack(spacing: 2) {
|
|
Text(cvDayFmt.string(from: date).uppercased())
|
|
.font(AppFonts.mono(8, weight: .semibold)).foregroundColor(AppColors.text3(cs))
|
|
ZStack {
|
|
if isTod { Circle().fill(AppColors.accent).frame(width: 28, height: 28) }
|
|
Text("\(cal.component(.day, from: date))")
|
|
.font(AppFonts.sans(14, weight: isTod ? .bold : .medium))
|
|
.foregroundColor(isTod ? .white : AppColors.text(cs))
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
.padding(.vertical, 10).padding(.horizontal, 8)
|
|
}
|
|
|
|
private func cvTimeGrid(days: [Date]) -> some View {
|
|
let totalH = CGFloat(cvEndH - cvStartH) * cvHourH
|
|
return ZStack(alignment: .topLeading) {
|
|
VStack(spacing: 0) {
|
|
ForEach(cvStartH...cvEndH, id: \.self) { h in
|
|
HStack(spacing: 0) {
|
|
Text(h == 12 ? "12" : h > 12 ? "\(h - 12)" : "\(h)")
|
|
.font(AppFonts.mono(8.5)).foregroundColor(AppColors.text3(cs))
|
|
.frame(width: 28, alignment: .trailing)
|
|
Rectangle().fill(AppColors.border(cs)).frame(height: 0.5).padding(.leading, 8)
|
|
}
|
|
.frame(height: cvHourH, alignment: .top)
|
|
}
|
|
}
|
|
.padding(.horizontal, 8)
|
|
|
|
HStack(alignment: .top, spacing: 2) {
|
|
Color.clear.frame(width: 36)
|
|
ForEach(days, id: \.self) { date in
|
|
cvTimeColumn(date: date, totalH: totalH)
|
|
}
|
|
}
|
|
.padding(.horizontal, 8)
|
|
.frame(height: totalH)
|
|
}
|
|
}
|
|
|
|
private func cvTimeColumn(date: Date, totalH: CGFloat) -> some View {
|
|
ZStack(alignment: .topLeading) {
|
|
Color.clear.frame(maxWidth: .infinity).frame(height: totalH)
|
|
ForEach(cvTimeItems(date)) { item in
|
|
let top = max(0, CGFloat(item.startMin - cvStartH * 60) / 60.0 * cvHourH)
|
|
let dur = max(CGFloat(item.endMin - item.startMin) / 60.0 * cvHourH, 28)
|
|
RoundedRectangle(cornerRadius: 4)
|
|
.fill(item.color.opacity(0.28))
|
|
.overlay(alignment: .leading) {
|
|
Rectangle().fill(item.color).frame(width: 3)
|
|
.clipShape(RoundedRectangle(cornerRadius: 2))
|
|
}
|
|
.overlay(alignment: .topLeading) {
|
|
Text(item.title)
|
|
.font(AppFonts.sans(9, weight: .medium))
|
|
.foregroundColor(item.color)
|
|
.padding(.horizontal, 6).padding(.top, 4)
|
|
.lineLimit(2)
|
|
}
|
|
.frame(maxWidth: .infinity).frame(height: dur)
|
|
.padding(.trailing, 2)
|
|
.offset(y: top)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
|
|
// MARK: - Shared helpers
|
|
|
|
private var cvWeekStrip: some View {
|
|
HStack(spacing: 0) {
|
|
ForEach(cvWeekDays(), id: \.self) { date in
|
|
let isTod = cal.isDateInToday(date)
|
|
let isSel = cal.isDate(date, inSameDayAs: selectedDate)
|
|
VStack(spacing: 3) {
|
|
Text(cvDayFmt.string(from: date))
|
|
.font(AppFonts.mono(9, weight: .semibold)).foregroundColor(AppColors.text3(cs))
|
|
ZStack {
|
|
if isTod { Circle().fill(AppColors.accent).frame(width: 28, height: 28) }
|
|
else if isSel { Circle().fill(AppColors.accentSoft).frame(width: 28, height: 28) }
|
|
Text("\(cal.component(.day, from: date))")
|
|
.font(AppFonts.sans(13, weight: isTod ? .bold : .regular))
|
|
.foregroundColor(isTod ? .white : AppColors.text(cs))
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.onTapGesture { withAnimation(KisaniSpring.micro) { selectedDate = date } }
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.gesture(DragGesture(minimumDistance: 40).onEnded { v in
|
|
let delta = v.translation.width < -40 ? 7 : v.translation.width > 40 ? -7 : 0
|
|
guard delta != 0 else { return }
|
|
withAnimation(KisaniSpring.snappy) {
|
|
selectedDate = cal.date(byAdding: .day, value: delta, to: selectedDate) ?? selectedDate
|
|
displayMonth = cal.date(from: cal.dateComponents([.year, .month], from: selectedDate)) ?? displayMonth
|
|
}
|
|
})
|
|
}
|
|
|
|
private var cvEmptyDay: some View {
|
|
VStack(spacing: 4) {
|
|
Text("Nothing scheduled").font(AppFonts.sans(13, weight: .medium)).foregroundColor(AppColors.text2(cs))
|
|
Text("Tap + to add a task").font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
|
|
}
|
|
.frame(maxWidth: .infinity).padding(.vertical, 48)
|
|
}
|
|
|
|
private func cvWeekDays() -> [Date] {
|
|
let wd = cal.component(.weekday, from: selectedDate)
|
|
let start = cal.date(byAdding: .day, value: -(wd - 1), to: selectedDate) ?? selectedDate
|
|
return (0..<7).compactMap { cal.date(byAdding: .day, value: $0, to: start) }
|
|
}
|
|
|
|
private func cvAdjacentDays(_ count: Int) -> [Date] {
|
|
let half = count / 2
|
|
return (-half...(count - half - 1)).compactMap { cal.date(byAdding: .day, value: $0, to: selectedDate) }
|
|
}
|
|
|
|
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 }
|
|
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) : [] {
|
|
guard let s = ev.startDate, let e = ev.endDate, !ev.isAllDay else { continue }
|
|
let sh = cal.component(.hour, from: s), sm = cal.component(.minute, from: s)
|
|
let eh = cal.component(.hour, from: e), em = cal.component(.minute, from: e)
|
|
items.append(CVTimeItem(title: ev.title ?? "Event", color: Color(cgColor: ev.calendar.cgColor),
|
|
startMin: sh * 60 + sm, endMin: max(eh * 60 + em, sh * 60 + sm + 30)))
|
|
}
|
|
return items
|
|
}
|
|
|
|
private let cvTimeFmt: DateFormatter = { let f = DateFormatter(); f.dateFormat = "h:mm a"; return f }()
|
|
private let cvDayFmt: DateFormatter = { let f = DateFormatter(); f.dateFormat = "EEE"; return f }()
|
|
private let cvMonthFmt: DateFormatter = { let f = DateFormatter(); f.dateFormat = "MMM"; return f }()
|
|
}
|
|
|
|
struct CVTimeItem: Identifiable {
|
|
let id: String
|
|
let title: String
|
|
let color: Color
|
|
let startMin: Int
|
|
let endMin: Int
|
|
|
|
init(title: String, color: Color, startMin: Int, endMin: Int) {
|
|
self.id = "\(title)-\(startMin)"
|
|
self.title = title
|
|
self.color = color
|
|
self.startMin = startMin
|
|
self.endMin = endMin
|
|
}
|
|
}
|
|
|
|
// MARK: - Calendar Connect Sheet
|
|
struct CalendarConnectSheet: View {
|
|
@ObservedObject var calStore: CalendarStore
|
|
@Environment(\.colorScheme) var cs
|
|
@Environment(\.dismiss) var dismiss
|
|
@Environment(\.openURL) private var openURL
|
|
@Environment(\.scenePhase) private var scenePhase
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ScrollView {
|
|
VStack(spacing: 20) {
|
|
// Connect prompt if not yet authorized
|
|
if !calStore.isAuthorized {
|
|
HStack(spacing: 14) {
|
|
Image(systemName: "calendar")
|
|
.font(.system(size: 16))
|
|
.foregroundColor(.white)
|
|
.frame(width: 36, height: 36)
|
|
.background(AppColors.accent)
|
|
.clipShape(RoundedRectangle(cornerRadius: 9))
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("iPhone Calendar").font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.text(cs))
|
|
Text(calStore.isDenied
|
|
? "Access denied — enable in Settings"
|
|
: "Tap to connect and show events")
|
|
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
|
|
}
|
|
Spacer()
|
|
Button(calStore.isDenied ? "Settings" : "Connect") {
|
|
if calStore.canRequest {
|
|
calStore.requestAccess()
|
|
} else if let url = URL(string: UIApplication.openSettingsURLString) {
|
|
openURL(url)
|
|
}
|
|
}
|
|
.font(AppFonts.mono(9.5, weight: .bold))
|
|
.foregroundColor(AppColors.accent)
|
|
.padding(.horizontal, 10).padding(.vertical, 5)
|
|
.background(AppColors.accentSoft)
|
|
.clipShape(Capsule())
|
|
.buttonStyle(.plain)
|
|
}
|
|
.padding(.horizontal, 16).padding(.vertical, 14)
|
|
.background(AppColors.surface(cs))
|
|
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
|
|
.overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.border(cs), lineWidth: 1))
|
|
.padding(.horizontal, 20)
|
|
}
|
|
|
|
// Main settings card
|
|
VStack(spacing: 0) {
|
|
// Local Calendars row
|
|
NavigationLink(destination: LocalCalendarsView(calStore: calStore)) {
|
|
HStack {
|
|
Text("Local Calendars")
|
|
.font(AppFonts.sans(14))
|
|
.foregroundColor(AppColors.text(cs))
|
|
Spacer()
|
|
Image(systemName: "chevron.right")
|
|
.font(.system(size: 12, weight: .medium))
|
|
.foregroundColor(AppColors.text3(cs))
|
|
}
|
|
.padding(.horizontal, 16).padding(.vertical, 15)
|
|
}
|
|
|
|
Divider().background(AppColors.border(cs)).padding(.leading, 16)
|
|
|
|
// Add Calendar row
|
|
NavigationLink(destination: AddCalendarView()) {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "plus")
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.foregroundColor(AppColors.blue)
|
|
Text("Add Calendar")
|
|
.font(AppFonts.sans(14))
|
|
.foregroundColor(AppColors.blue)
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 16).padding(.vertical, 15)
|
|
}
|
|
|
|
Divider().background(AppColors.border(cs)).padding(.leading, 16)
|
|
|
|
// Do Not Disturb toggle
|
|
HStack {
|
|
Text("Do Not Disturb")
|
|
.font(AppFonts.sans(14))
|
|
.foregroundColor(AppColors.text(cs))
|
|
Spacer()
|
|
Toggle("", isOn: Binding(
|
|
get: { calStore.doNotDisturb },
|
|
set: { calStore.setDoNotDisturb($0) }
|
|
))
|
|
.tint(AppColors.accent)
|
|
.labelsHidden()
|
|
}
|
|
.padding(.horizontal, 16).padding(.vertical, 10)
|
|
}
|
|
.background(AppColors.surface(cs))
|
|
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
|
|
.overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.border(cs), lineWidth: 1))
|
|
.padding(.horizontal, 20)
|
|
|
|
Spacer(minLength: 32)
|
|
}
|
|
.padding(.top, 16)
|
|
}
|
|
.background(AppColors.background(cs).ignoresSafeArea())
|
|
.navigationTitle("Calendar")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button("Done") { dismiss() }
|
|
.font(AppFonts.sans(13, weight: .semibold))
|
|
.foregroundColor(AppColors.accent)
|
|
}
|
|
}
|
|
.onAppear { calStore.refreshStatus() }
|
|
.onChange(of: scenePhase) { phase in
|
|
if phase == .active { calStore.refreshStatus() }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Local Calendars View
|
|
struct LocalCalendarsView: View {
|
|
@ObservedObject var calStore: CalendarStore
|
|
@Environment(\.colorScheme) var cs
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(spacing: 20) {
|
|
// Master enable toggle
|
|
HStack {
|
|
Text("Show Calendar Events")
|
|
.font(AppFonts.sans(14))
|
|
.foregroundColor(AppColors.text(cs))
|
|
Spacer()
|
|
Toggle("", isOn: Binding(
|
|
get: { calStore.localCalendarsEnabled },
|
|
set: { calStore.setLocalCalendarsEnabled($0) }
|
|
))
|
|
.tint(AppColors.accent)
|
|
.labelsHidden()
|
|
}
|
|
.padding(.horizontal, 16).padding(.vertical, 12)
|
|
.background(AppColors.surface(cs))
|
|
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
|
|
.overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.border(cs), lineWidth: 1))
|
|
.padding(.horizontal, 20)
|
|
|
|
if calStore.localCalendarsEnabled {
|
|
let groups = calStore.groupedCalendars()
|
|
if groups.isEmpty {
|
|
Text("No calendars found")
|
|
.font(AppFonts.sans(13))
|
|
.foregroundColor(AppColors.text3(cs))
|
|
.frame(maxWidth: .infinity, alignment: .center)
|
|
.padding(.top, 8)
|
|
} else {
|
|
ForEach(groups) { group in
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text(group.title)
|
|
.font(AppFonts.sans(11, weight: .semibold))
|
|
.foregroundColor(AppColors.text3(cs))
|
|
.padding(.horizontal, 20)
|
|
|
|
VStack(spacing: 0) {
|
|
ForEach(Array(group.calendars.enumerated()), id: \.element.calendarIdentifier) { idx, cal in
|
|
HStack(spacing: 12) {
|
|
Circle()
|
|
.fill(Color(cgColor: cal.cgColor))
|
|
.frame(width: 12, height: 12)
|
|
Text(cal.title)
|
|
.font(AppFonts.sans(13))
|
|
.foregroundColor(AppColors.text(cs))
|
|
Spacer()
|
|
Button(calStore.isCalendarVisible(cal) ? "Hide" : "Show") {
|
|
calStore.toggleCalendarVisibility(cal)
|
|
}
|
|
.font(AppFonts.sans(12, weight: .semibold))
|
|
.foregroundColor(calStore.isCalendarVisible(cal) ? AppColors.text3(cs) : AppColors.accent)
|
|
.buttonStyle(.plain)
|
|
}
|
|
.padding(.horizontal, 16).padding(.vertical, 13)
|
|
|
|
if idx < group.calendars.count - 1 {
|
|
Divider().background(AppColors.border(cs)).padding(.leading, 40)
|
|
}
|
|
}
|
|
}
|
|
.background(AppColors.surface(cs))
|
|
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
|
|
.overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.border(cs), lineWidth: 1))
|
|
.padding(.horizontal, 20)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Spacer(minLength: 32)
|
|
}
|
|
.padding(.top, 16)
|
|
}
|
|
.background(AppColors.background(cs).ignoresSafeArea())
|
|
.navigationTitle("Local Calendars")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.onAppear { calStore.loadLocalCalendars() }
|
|
}
|
|
}
|
|
|
|
// MARK: - Add Calendar
|
|
|
|
struct CalAddItem: Identifiable {
|
|
let id: String
|
|
let title: String
|
|
let description: String?
|
|
let sfIcon: String
|
|
let iconBG: Color
|
|
}
|
|
|
|
struct AddCalendarView: View {
|
|
@Environment(\.colorScheme) var cs
|
|
|
|
private let items: [CalAddItem] = [
|
|
CalAddItem(id: "google", title: "Google", description: nil,
|
|
sfIcon: "globe", iconBG: Color(red: 0.26, green: 0.52, blue: 0.96)),
|
|
CalAddItem(id: "outlook", title: "Outlook", description: nil,
|
|
sfIcon: "envelope.fill", iconBG: Color(red: 0.0, green: 0.47, blue: 0.85)),
|
|
CalAddItem(id: "icloud", title: "iCloud", description: nil,
|
|
sfIcon: "icloud.fill", iconBG: Color(red: 0.0, green: 0.48, blue: 1.0)),
|
|
CalAddItem(id: "caldav", title: "CalDAV", description: nil,
|
|
sfIcon: "server.rack", iconBG: Color(red: 0.95, green: 0.55, blue: 0.1)),
|
|
CalAddItem(id: "exchange", title: "Exchange", description: nil,
|
|
sfIcon: "envelope.badge.fill", iconBG: Color(red: 0.0, green: 0.47, blue: 0.72)),
|
|
CalAddItem(id: "holidays", title: "Holidays", description: nil,
|
|
sfIcon: "calendar.badge.plus", iconBG: AppColors.green),
|
|
CalAddItem(id: "url", title: "URL",
|
|
description: "Support adding calendars with URL subscriptions in iCal(.ics) format.",
|
|
sfIcon: "link.circle.fill", iconBG: AppColors.blue),
|
|
]
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(spacing: 0) {
|
|
ForEach(Array(items.enumerated()), id: \.element.id) { idx, item in
|
|
itemRow(item)
|
|
if idx < items.count - 1 {
|
|
Divider().background(AppColors.border(cs)).padding(.leading, 66)
|
|
}
|
|
}
|
|
}
|
|
.background(AppColors.surface(cs))
|
|
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
|
|
.overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.border(cs), lineWidth: 1))
|
|
.padding(.horizontal, 20).padding(.top, 16)
|
|
Spacer(minLength: 32)
|
|
}
|
|
.background(AppColors.background(cs).ignoresSafeArea())
|
|
.navigationTitle("Add Calendar")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func itemRow(_ item: CalAddItem) -> some View {
|
|
if item.id == "url" {
|
|
NavigationLink(destination: AddCalendarURLView()) { rowContent(item) }
|
|
} else if item.id == "holidays" {
|
|
NavigationLink(destination: AddCalendarHolidaysView()) { rowContent(item) }
|
|
} else {
|
|
NavigationLink(destination: AddCalendarAccountView(item: item)) { rowContent(item) }
|
|
}
|
|
}
|
|
|
|
private func rowContent(_ item: CalAddItem) -> some View {
|
|
HStack(spacing: 14) {
|
|
ZStack {
|
|
RoundedRectangle(cornerRadius: 10)
|
|
.fill(item.iconBG)
|
|
.frame(width: 36, height: 36)
|
|
Image(systemName: item.sfIcon)
|
|
.font(.system(size: 15, weight: .medium))
|
|
.foregroundColor(.white)
|
|
}
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(item.title).font(AppFonts.sans(14)).foregroundColor(AppColors.text(cs))
|
|
if let desc = item.description {
|
|
Text(desc).font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs)).lineLimit(2)
|
|
}
|
|
}
|
|
Spacer()
|
|
Image(systemName: "chevron.right")
|
|
.font(.system(size: 12, weight: .medium))
|
|
.foregroundColor(AppColors.text3(cs))
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, item.description != nil ? 10 : 13)
|
|
}
|
|
}
|
|
|
|
// MARK: - Add Calendar Account View
|
|
struct AddCalendarAccountView: View {
|
|
let item: CalAddItem
|
|
@Environment(\.colorScheme) var cs
|
|
@Environment(\.openURL) var openURL
|
|
|
|
private var instructions: String {
|
|
switch item.id {
|
|
case "google":
|
|
return "In iOS Settings, go to Apps → Calendar → Accounts → Add Account → Google, then sign in with your Google account."
|
|
case "outlook":
|
|
return "In iOS Settings, go to Apps → Calendar → Accounts → Add Account → Outlook, then sign in with your Microsoft account."
|
|
case "icloud":
|
|
return "Tap your name at the top of iOS Settings and sign in to iCloud, then enable Calendars."
|
|
case "caldav":
|
|
return "In iOS Settings, go to Apps → Calendar → Accounts → Add Account → Other → Add CalDAV Account, then enter your server details."
|
|
case "exchange":
|
|
return "In iOS Settings, go to Apps → Calendar → Accounts → Add Account → Microsoft Exchange, then enter your work email and sign in."
|
|
default:
|
|
return "Open iOS Settings and navigate to Apps → Calendar → Accounts to add a new account."
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(spacing: 24) {
|
|
VStack(spacing: 12) {
|
|
ZStack {
|
|
RoundedRectangle(cornerRadius: 20)
|
|
.fill(item.iconBG)
|
|
.frame(width: 72, height: 72)
|
|
Image(systemName: item.sfIcon)
|
|
.font(.system(size: 30))
|
|
.foregroundColor(.white)
|
|
}
|
|
Text(item.title)
|
|
.font(AppFonts.sans(20, weight: .bold))
|
|
.foregroundColor(AppColors.text(cs))
|
|
}
|
|
.padding(.top, 32)
|
|
|
|
Text(instructions)
|
|
.font(AppFonts.sans(14))
|
|
.foregroundColor(AppColors.text3(cs))
|
|
.multilineTextAlignment(.center)
|
|
.padding(.horizontal, 32)
|
|
|
|
Button {
|
|
if let url = URL(string: "app-settings:") { openURL(url) }
|
|
} label: {
|
|
Text("Open Settings")
|
|
.font(AppFonts.sans(14, weight: .semibold))
|
|
.foregroundColor(.white)
|
|
.frame(maxWidth: .infinity).frame(height: 48)
|
|
.background(item.iconBG)
|
|
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
|
|
}
|
|
.buttonStyle(.plain)
|
|
.padding(.horizontal, 32)
|
|
|
|
Spacer(minLength: 32)
|
|
}
|
|
}
|
|
.background(AppColors.background(cs).ignoresSafeArea())
|
|
.navigationTitle(item.title)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
}
|
|
}
|
|
|
|
// MARK: - Add Calendar URL View
|
|
struct AddCalendarURLView: View {
|
|
@Environment(\.colorScheme) var cs
|
|
@Environment(\.openURL) var openURL
|
|
@State private var urlText = ""
|
|
@FocusState private var focused: Bool
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(spacing: 20) {
|
|
Text("Subscribe to an iCal or .ics calendar feed by entering its webcal or https URL.")
|
|
.font(AppFonts.sans(13))
|
|
.foregroundColor(AppColors.text3(cs))
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.horizontal, 20).padding(.top, 4)
|
|
|
|
HStack {
|
|
Text("URL")
|
|
.font(AppFonts.sans(13))
|
|
.foregroundColor(AppColors.text3(cs))
|
|
.frame(width: 52, alignment: .leading)
|
|
TextField("webcal:// or https://…", text: $urlText)
|
|
.font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs))
|
|
.focused($focused)
|
|
.autocorrectionDisabled().textInputAutocapitalization(.never)
|
|
}
|
|
.padding(.horizontal, 16).padding(.vertical, 14)
|
|
.background(AppColors.surface(cs))
|
|
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
|
|
.overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(focused ? AppColors.accent : AppColors.border(cs), lineWidth: 1))
|
|
.padding(.horizontal, 20)
|
|
|
|
Button {
|
|
var s = urlText.trimmingCharacters(in: .whitespaces)
|
|
if s.hasPrefix("https://") { s = s.replacingOccurrences(of: "https://", with: "webcal://") }
|
|
if let url = URL(string: s) { openURL(url) }
|
|
} label: {
|
|
Text("Subscribe")
|
|
.font(AppFonts.sans(14, weight: .semibold)).foregroundColor(.white)
|
|
.frame(maxWidth: .infinity).frame(height: 48)
|
|
.background(!urlText.trimmingCharacters(in: .whitespaces).isEmpty ? AppColors.blue : AppColors.borderHi(cs))
|
|
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
|
|
}
|
|
.buttonStyle(.plain)
|
|
.disabled(urlText.trimmingCharacters(in: .whitespaces).isEmpty)
|
|
.padding(.horizontal, 20)
|
|
}
|
|
.padding(.top, 16)
|
|
}
|
|
.background(AppColors.background(cs).ignoresSafeArea())
|
|
.navigationTitle("URL Subscription")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
}
|
|
}
|
|
|
|
// MARK: - Add Calendar Holidays View
|
|
struct AddCalendarHolidaysView: View {
|
|
@Environment(\.colorScheme) var cs
|
|
@Environment(\.openURL) var openURL
|
|
|
|
private struct HolidayRegion: Identifiable {
|
|
let id = UUID()
|
|
let name: String
|
|
let webcalURL: String
|
|
}
|
|
|
|
private let regions: [HolidayRegion] = [
|
|
.init(name: "United States", webcalURL: "webcal://calendars.apple.com/subscriptions/holidays/us_en.ics"),
|
|
.init(name: "United Kingdom", webcalURL: "webcal://calendars.apple.com/subscriptions/holidays/gb_en.ics"),
|
|
.init(name: "Canada", webcalURL: "webcal://calendars.apple.com/subscriptions/holidays/ca_en.ics"),
|
|
.init(name: "Australia", webcalURL: "webcal://calendars.apple.com/subscriptions/holidays/au_en.ics"),
|
|
.init(name: "Kenya", webcalURL: "webcal://calendars.apple.com/subscriptions/holidays/ke_en.ics"),
|
|
.init(name: "Nigeria", webcalURL: "webcal://calendars.apple.com/subscriptions/holidays/ng_en.ics"),
|
|
.init(name: "South Africa", webcalURL: "webcal://calendars.apple.com/subscriptions/holidays/za_en.ics"),
|
|
.init(name: "India", webcalURL: "webcal://calendars.apple.com/subscriptions/holidays/in_en.ics"),
|
|
.init(name: "Germany", webcalURL: "webcal://calendars.apple.com/subscriptions/holidays/de_de.ics"),
|
|
.init(name: "France", webcalURL: "webcal://calendars.apple.com/subscriptions/holidays/fr_fr.ics"),
|
|
.init(name: "Japan", webcalURL: "webcal://calendars.apple.com/subscriptions/holidays/jp_ja.ics"),
|
|
]
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(spacing: 0) {
|
|
ForEach(Array(regions.enumerated()), id: \.element.id) { idx, region in
|
|
Button {
|
|
if let url = URL(string: region.webcalURL) { openURL(url) }
|
|
} label: {
|
|
HStack {
|
|
Text(region.name).font(AppFonts.sans(14)).foregroundColor(AppColors.text(cs))
|
|
Spacer()
|
|
Image(systemName: "plus.circle")
|
|
.font(.system(size: 18))
|
|
.foregroundColor(AppColors.accent)
|
|
}
|
|
.padding(.horizontal, 16).padding(.vertical, 13)
|
|
}
|
|
.buttonStyle(.plain)
|
|
if idx < regions.count - 1 {
|
|
Divider().background(AppColors.border(cs)).padding(.leading, 16)
|
|
}
|
|
}
|
|
}
|
|
.background(AppColors.surface(cs))
|
|
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
|
|
.overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.border(cs), lineWidth: 1))
|
|
.padding(.horizontal, 20).padding(.top, 16)
|
|
Spacer(minLength: 32)
|
|
}
|
|
.background(AppColors.background(cs).ignoresSafeArea())
|
|
.navigationTitle("Holidays")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
}
|
|
}
|
|
|
|
// MARK: - Day Cell
|
|
struct DayCell: View {
|
|
@Environment(\.colorScheme) private var cs
|
|
let date: Date; let isCurrentMonth: Bool; let isToday: Bool; let isSelected: Bool; let dots: [Color]
|
|
private let cal = Calendar.current
|
|
var dayNum: String { "\(cal.component(.day, from: date))" }
|
|
var weekNum: Int { cal.component(.weekOfYear, from: date) }
|
|
var isSunday: Bool { cal.component(.weekday, from: date) == 1 }
|
|
|
|
var body: some View {
|
|
VStack(spacing: 2) {
|
|
ZStack {
|
|
if isToday { Circle().fill(AppColors.accent).frame(width: 26, height: 26) }
|
|
else if isSelected { Circle().fill(AppColors.accentSoft).frame(width: 26, height: 26) }
|
|
Text(dayNum)
|
|
.font(AppFonts.sans(12.5, weight: isToday ? .bold : (isCurrentMonth ? .medium : .regular)))
|
|
.foregroundColor(isToday ? .white : !isCurrentMonth ? AppColors.text3(cs) : AppColors.text(cs))
|
|
}
|
|
.frame(width: 26, height: 26)
|
|
.overlay(alignment: .topLeading) {
|
|
if isSunday {
|
|
Text("W\(weekNum)").font(AppFonts.mono(6.5)).foregroundColor(AppColors.text3(cs)).offset(x: -2, y: -10)
|
|
}
|
|
}
|
|
HStack(spacing: 2) {
|
|
ForEach(dots.indices, id: \.self) { i in
|
|
Circle().fill(dots[i]).frame(width: 3.5, height: 3.5)
|
|
}
|
|
}
|
|
.frame(height: 4)
|
|
}
|
|
.frame(maxWidth: .infinity).frame(height: 42).contentShape(Rectangle())
|
|
}
|
|
}
|
|
|
|
// MARK: - Day Timeline
|
|
|
|
private struct DayTLEntry: Identifiable {
|
|
var id = UUID()
|
|
var timeLabel: String
|
|
var ampm: String?
|
|
var title: String
|
|
var subtitle: String?
|
|
var color: Color
|
|
var isComplete: Bool
|
|
var isAllDay: Bool
|
|
var sortKey: Int // < 0 = all-day/special; else minute-of-day
|
|
var sortGroup: Int = 0 // 0 = active, 1 = completed, 2 = overdue
|
|
var taskRef: TaskItem?
|
|
var workoutRef: WorkoutProgram?
|
|
var eventRef: EKEvent?
|
|
}
|
|
|
|
struct DayTimelineView: View {
|
|
@Environment(\.colorScheme) private var cs
|
|
|
|
let date: Date
|
|
let tasks: [TaskItem]
|
|
var overdueTasks: [TaskItem] = []
|
|
let calEvents: [EKEvent]
|
|
let workout: WorkoutProgram?
|
|
var workoutDone: Bool = false // per-DATE completion (from workoutDates), not live sets
|
|
var workoutMissed: Bool = false
|
|
var workoutCompensation: Bool = false
|
|
var onWorkoutDone: (() -> Void)? = nil
|
|
var onWorkoutMissed: (() -> Void)? = nil
|
|
var onWorkoutCompensate: (() -> Void)? = nil
|
|
var onWorkoutTap: (() -> Void)? = nil
|
|
var onToggle: ((TaskItem) -> Void)? = nil
|
|
var onPin: ((TaskItem) -> Void)? = nil
|
|
var onPostpone: ((TaskItem) -> Void)? = nil
|
|
var onEdit: ((TaskItem) -> Void)? = nil
|
|
var onDelete: ((TaskItem) -> Void)? = nil
|
|
var onSetDate: ((TaskItem, Date?) -> Void)? = nil
|
|
var onMove: ((TaskItem, Quadrant) -> Void)? = nil
|
|
var onSetPriority: ((TaskItem, Priority) -> Void)? = nil
|
|
var onSetCategory: ((TaskItem, TaskCategory) -> Void)? = nil
|
|
var onResetMatrixUrgency: ((TaskItem) -> Void)? = nil
|
|
// Calendar events (non-subscription only): edit/delete in the real calendar.
|
|
var onEditEvent: ((EKEvent) -> Void)? = nil
|
|
var onDeleteEvent: ((EKEvent) -> Void)? = nil
|
|
|
|
private let cal = Calendar.current
|
|
private static let overdueFmt: DateFormatter = {
|
|
let f = DateFormatter(); f.dateFormat = "MMM d"; return f
|
|
}()
|
|
|
|
private var entries: [DayTLEntry] {
|
|
var out: [DayTLEntry] = []
|
|
|
|
// All-day tasks (no specific time)
|
|
for t in tasks where !t.hasTime {
|
|
let grp = t.isComplete ? 1 : 0
|
|
out.append(DayTLEntry(
|
|
timeLabel: "All Day",
|
|
title: t.title,
|
|
color: t.isComplete ? AppColors.green : t.quadrant.color,
|
|
isComplete: t.isComplete,
|
|
isAllDay: true, sortKey: -1, sortGroup: grp, taskRef: t
|
|
))
|
|
}
|
|
|
|
// All-day calendar events
|
|
for ev in calEvents where ev.isAllDay {
|
|
out.append(DayTLEntry(
|
|
timeLabel: "All Day",
|
|
title: ev.title ?? "Event",
|
|
color: Color(cgColor: ev.calendar.cgColor),
|
|
isComplete: false, isAllDay: true, sortKey: -1, sortGroup: 0,
|
|
eventRef: ev
|
|
))
|
|
}
|
|
|
|
// Workout — always pinned first (sortGroup -1). Completion is tied to THIS
|
|
// date (workoutDates), never the program's shared live set state.
|
|
if let w = workout {
|
|
let done = workoutDone
|
|
let status = done ? "Done" : workoutMissed ? "Missed" : "Pending"
|
|
let kind = workoutCompensation ? "Compensation · " : ""
|
|
out.append(DayTLEntry(
|
|
timeLabel: "Workout",
|
|
title: w.name,
|
|
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
|
|
))
|
|
}
|
|
|
|
// Timed tasks
|
|
for t in tasks where t.hasTime {
|
|
guard let d = t.dueDate else { continue }
|
|
let h = cal.component(.hour, from: d), m = cal.component(.minute, from: d)
|
|
let hd = h == 0 ? 12 : (h > 12 ? h - 12 : h)
|
|
let ms = m == 0 ? "" : ":\(String(format: "%02d", m))"
|
|
let grp = t.isComplete ? 1 : 0
|
|
out.append(DayTLEntry(
|
|
timeLabel: "\(hd)\(ms)", ampm: h < 12 ? "AM" : "PM",
|
|
title: t.title, color: t.isComplete ? AppColors.green : t.quadrant.color,
|
|
isComplete: t.isComplete, isAllDay: false,
|
|
sortKey: h * 60 + m, sortGroup: grp, taskRef: t
|
|
))
|
|
}
|
|
|
|
// Timed calendar events
|
|
for ev in calEvents where !ev.isAllDay {
|
|
guard let s = ev.startDate else { continue }
|
|
let h = cal.component(.hour, from: s), m = cal.component(.minute, from: s)
|
|
let hd = h == 0 ? 12 : (h > 12 ? h - 12 : h)
|
|
let ms = m == 0 ? "" : ":\(String(format: "%02d", m))"
|
|
let endLabel: String? = ev.endDate.map {
|
|
let eh = cal.component(.hour, from: $0), em = cal.component(.minute, from: $0)
|
|
let ehd = eh == 0 ? 12 : (eh > 12 ? eh - 12 : eh)
|
|
let ems = em == 0 ? "" : ":\(String(format: "%02d", em))"
|
|
let eap = eh < 12 ? "AM" : "PM"
|
|
return "until \(ehd)\(ems) \(eap)"
|
|
}
|
|
out.append(DayTLEntry(
|
|
timeLabel: "\(hd)\(ms)", ampm: h < 12 ? "AM" : "PM",
|
|
title: ev.title ?? "Event", subtitle: endLabel,
|
|
color: Color(cgColor: ev.calendar.cgColor),
|
|
isComplete: false, isAllDay: false, sortKey: h * 60 + m, sortGroup: 0,
|
|
eventRef: ev
|
|
))
|
|
}
|
|
|
|
// Overdue tasks — shown last with accent color and their date as label
|
|
for t in overdueTasks {
|
|
let label = t.dueDate.map {
|
|
cal.isDateInYesterday($0) ? "Yest." : Self.overdueFmt.string(from: $0)
|
|
} ?? "Overdue"
|
|
out.append(DayTLEntry(
|
|
timeLabel: label,
|
|
title: t.title,
|
|
color: AppColors.accent,
|
|
isComplete: false, isAllDay: true,
|
|
sortKey: -1, sortGroup: 2, taskRef: t
|
|
))
|
|
}
|
|
|
|
return out.sorted {
|
|
if $0.sortGroup != $1.sortGroup { return $0.sortGroup < $1.sortGroup }
|
|
if $0.sortKey < 0 && $1.sortKey < 0 { return $0.sortKey > $1.sortKey }
|
|
if $0.sortKey < 0 { return true }
|
|
if $1.sortKey < 0 { return false }
|
|
return $0.sortKey < $1.sortKey
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
if entries.isEmpty {
|
|
VStack(spacing: 6) {
|
|
Image(systemName: "calendar.badge.checkmark")
|
|
.font(.system(size: 24))
|
|
.foregroundColor(AppColors.text3(cs))
|
|
Text("Nothing scheduled")
|
|
.font(AppFonts.sans(13, weight: .medium))
|
|
.foregroundColor(AppColors.text2(cs))
|
|
Text("Tap + to add a task")
|
|
.font(AppFonts.sans(11))
|
|
.foregroundColor(AppColors.text3(cs))
|
|
}
|
|
.frame(maxWidth: .infinity).padding(.vertical, 36)
|
|
} else {
|
|
VStack(spacing: 0) {
|
|
ForEach(Array(entries.enumerated()), id: \.element.id) { i, entry in
|
|
let row = DayTLRow(
|
|
entry: entry,
|
|
isFirst: i == 0,
|
|
isLast: i == entries.count - 1,
|
|
cs: cs,
|
|
onWorkoutTap: entry.workoutRef != nil ? onWorkoutTap : nil,
|
|
onToggle: entry.taskRef.map { t in { onToggle?(t) } }
|
|
)
|
|
if let t = entry.taskRef {
|
|
row.contextMenu { taskContextMenu(t) }
|
|
} else if entry.workoutRef != nil,
|
|
onWorkoutDone != nil || onWorkoutMissed != nil {
|
|
row.contextMenu { workoutContextMenu }
|
|
} else if let ev = entry.eventRef,
|
|
onEditEvent != nil || onDeleteEvent != nil,
|
|
ev.calendar?.type != .subscription,
|
|
ev.calendar?.allowsContentModifications == true {
|
|
row.contextMenu { eventContextMenu(ev) }
|
|
} else {
|
|
row
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func taskContextMenu(_ task: TaskItem) -> some View {
|
|
TaskMenuItems(
|
|
task: task,
|
|
onPin: { onPin?($0) },
|
|
onSetDate: { onSetDate?($0, $1) },
|
|
onMove: { onMove?($0, $1) },
|
|
onSetPriority: { onSetPriority?($0, $1) },
|
|
onSetCategory: { onSetCategory?($0, $1) },
|
|
onResetMatrixUrgency: onResetMatrixUrgency,
|
|
onLiveActivity:{ LiveActivityManager.toggle(for: $0) },
|
|
onEdit: onEdit,
|
|
onDelete: { onDelete?($0) }
|
|
)
|
|
}
|
|
|
|
// Quick actions for the day's workout — applies to THIS date only.
|
|
@ViewBuilder
|
|
private var workoutContextMenu: some View {
|
|
if let onWorkoutDone {
|
|
Button { onWorkoutDone() } label: {
|
|
Label("Mark as Done", systemImage: "checkmark.circle")
|
|
}
|
|
}
|
|
if let onWorkoutMissed {
|
|
Button { onWorkoutMissed() } label: {
|
|
Label("Mark as Not Done", systemImage: "xmark.circle")
|
|
}
|
|
}
|
|
if let onWorkoutCompensate {
|
|
Divider()
|
|
Button { onWorkoutCompensate() } label: {
|
|
Label("Move to Rest Day / Compensate", systemImage: "arrow.uturn.forward")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Edit / Delete for a real (non-subscription) calendar event.
|
|
@ViewBuilder
|
|
private func eventContextMenu(_ event: EKEvent) -> some View {
|
|
if let onEditEvent {
|
|
Button { onEditEvent(event) } label: { Label("Edit", systemImage: "pencil") }
|
|
}
|
|
if let onDeleteEvent {
|
|
Divider()
|
|
Button(role: .destructive) { onDeleteEvent(event) } label: {
|
|
Label("Delete", systemImage: "trash")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct DayTLRow: View {
|
|
let entry: DayTLEntry
|
|
let isFirst: Bool
|
|
let isLast: Bool
|
|
let cs: ColorScheme
|
|
var onWorkoutTap: (() -> Void)?
|
|
var onToggle: (() -> Void)?
|
|
|
|
private let circleD: CGFloat = 18
|
|
private let timeW: CGFloat = 46
|
|
private let trackW: CGFloat = 26
|
|
private let topGap: CGFloat = 10
|
|
|
|
@ViewBuilder private var marker: some View {
|
|
ZStack {
|
|
if entry.isComplete {
|
|
Circle()
|
|
.fill(entry.color.opacity(0.14))
|
|
.frame(width: circleD, height: circleD)
|
|
Image(systemName: "checkmark")
|
|
.font(.system(size: 7.5, weight: .bold))
|
|
.foregroundColor(entry.color)
|
|
} else if entry.workoutRef != nil {
|
|
Circle()
|
|
.fill(AppColors.blue.opacity(0.14))
|
|
.frame(width: circleD, height: circleD)
|
|
Image(systemName: "dumbbell.fill")
|
|
.font(.system(size: 8))
|
|
.foregroundColor(AppColors.blue)
|
|
} else if entry.isAllDay {
|
|
Circle()
|
|
.strokeBorder(entry.color, lineWidth: 1.5)
|
|
.frame(width: circleD, height: circleD)
|
|
} else {
|
|
Circle()
|
|
.fill(entry.color)
|
|
.frame(width: 9, height: 9)
|
|
}
|
|
}
|
|
.frame(width: circleD, height: circleD)
|
|
}
|
|
|
|
var body: some View {
|
|
HStack(alignment: .top, spacing: 0) {
|
|
|
|
// Time column
|
|
VStack(alignment: .trailing, spacing: 0) {
|
|
Text(entry.timeLabel)
|
|
.font(AppFonts.mono(
|
|
entry.isAllDay ? 8.5 : 11,
|
|
weight: entry.isAllDay ? .medium : .bold
|
|
))
|
|
.foregroundColor(entry.isAllDay ? AppColors.text3(cs) : AppColors.text(cs))
|
|
if let ap = entry.ampm {
|
|
Text(ap)
|
|
.font(AppFonts.mono(7, weight: .medium))
|
|
.foregroundColor(AppColors.text3(cs))
|
|
}
|
|
}
|
|
.frame(width: timeW, alignment: .trailing)
|
|
.padding(.top, topGap)
|
|
|
|
// Vertical track: top line → circle → bottom fill line
|
|
VStack(spacing: 0) {
|
|
Rectangle()
|
|
.fill(isFirst ? Color.clear : AppColors.border(cs))
|
|
.frame(width: 1, height: topGap)
|
|
.frame(maxWidth: trackW)
|
|
marker
|
|
Rectangle()
|
|
.fill(isLast ? Color.clear : AppColors.border(cs))
|
|
.frame(minWidth: 1, maxWidth: 1, maxHeight: .infinity)
|
|
.frame(maxWidth: trackW)
|
|
}
|
|
.frame(width: trackW)
|
|
|
|
// Event card
|
|
HStack(alignment: .center, spacing: 8) {
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(entry.title)
|
|
.font(AppFonts.sans(13, weight: .medium))
|
|
.foregroundColor(entry.isComplete ? AppColors.text3(cs) : AppColors.text(cs))
|
|
.strikethrough(entry.isComplete, color: AppColors.text3(cs))
|
|
.lineLimit(2)
|
|
if let sub = entry.subtitle {
|
|
Text(sub)
|
|
.font(AppFonts.mono(9.5))
|
|
.foregroundColor(AppColors.text3(cs))
|
|
}
|
|
}
|
|
Spacer(minLength: 0)
|
|
if let toggle = onToggle {
|
|
Button(action: toggle) {
|
|
Image(systemName: entry.isComplete ? "checkmark.circle.fill" : "circle")
|
|
.font(.system(size: 18))
|
|
.foregroundColor(entry.isComplete ? entry.color : AppColors.text3(cs))
|
|
}
|
|
.buttonStyle(.plain)
|
|
} else if onWorkoutTap != nil {
|
|
Image(systemName: "chevron.right")
|
|
.font(.system(size: 10, weight: .semibold))
|
|
.foregroundColor(AppColors.text3(cs))
|
|
}
|
|
}
|
|
.padding(.horizontal, 13).padding(.vertical, 11)
|
|
.background(AppColors.surface(cs))
|
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
.overlay(RoundedRectangle(cornerRadius: 10).stroke(AppColors.border(cs), lineWidth: 1))
|
|
.padding(.leading, 8)
|
|
.padding(.bottom, 8)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture { if onWorkoutTap != nil { onWorkoutTap?() } }
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Calendar Task Row (used in task list toggle mode)
|
|
private struct CalTaskRow: View {
|
|
@Environment(\.colorScheme) private var cs
|
|
let task: TaskItem
|
|
let onToggle: () -> Void
|
|
|
|
private static let timeFmt: DateFormatter = {
|
|
let f = DateFormatter(); f.dateFormat = "H:mm"; return f
|
|
}()
|
|
|
|
private var timeLabel: String? {
|
|
guard let d = task.dueDate, task.hasTime else { return nil }
|
|
let comps = Calendar.current.dateComponents([.hour, .minute], from: d)
|
|
guard comps.hour != 0 || comps.minute != 0 else { return nil }
|
|
return Self.timeFmt.string(from: d)
|
|
}
|
|
|
|
var body: some View {
|
|
HStack(spacing: 12) {
|
|
Button(action: onToggle) {
|
|
ZStack {
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.fill(task.isComplete ? AppColors.surface3(cs) : Color.clear)
|
|
.frame(width: 22, height: 22)
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.strokeBorder(
|
|
task.isComplete ? Color.clear : task.quadrant.color,
|
|
lineWidth: 1.5
|
|
)
|
|
.frame(width: 22, height: 22)
|
|
if task.isComplete {
|
|
Image(systemName: "checkmark")
|
|
.font(.system(size: 10, weight: .bold))
|
|
.foregroundColor(AppColors.text3(cs))
|
|
}
|
|
}
|
|
}
|
|
.buttonStyle(PressButtonStyle(scale: 0.88))
|
|
|
|
Text(task.title)
|
|
.font(AppFonts.sans(13, weight: task.isComplete ? .regular : .medium))
|
|
.foregroundColor(task.isComplete ? AppColors.text3(cs) : AppColors.text(cs))
|
|
.strikethrough(task.isComplete, color: AppColors.text3(cs))
|
|
|
|
Spacer()
|
|
|
|
if let t = timeLabel {
|
|
Text(t)
|
|
.font(AppFonts.mono(11, weight: .semibold))
|
|
.foregroundColor(AppColors.accent)
|
|
} else if task.isComplete {
|
|
Text("Today")
|
|
.font(AppFonts.mono(10))
|
|
.foregroundColor(AppColors.text3(cs))
|
|
}
|
|
}
|
|
.padding(.horizontal, 16).padding(.vertical, 13)
|
|
.contentShape(Rectangle())
|
|
}
|
|
}
|
|
|
|
// MARK: - Workout Section Bar
|
|
struct WorkoutSectionBar: View {
|
|
let count: Int
|
|
var body: some View {
|
|
let n = max(1, min(6, count))
|
|
VStack(spacing: 2.5) {
|
|
ForEach(0..<n, id: \.self) { _ in
|
|
RoundedRectangle(cornerRadius: 1.5)
|
|
.fill(AppColors.blue)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
}
|
|
.padding(7)
|
|
.background(AppColors.blueSoft)
|
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
}
|
|
}
|
|
|
|
// MARK: - Event Edit Sheet
|
|
|
|
/// Identifiable wrapper so an EKEvent can drive `.sheet(item:)`.
|
|
struct EditableEvent: Identifiable {
|
|
let id = UUID()
|
|
let event: EKEvent
|
|
}
|
|
|
|
/// Wraps the system event editor (EKEventEditViewController).
|
|
struct EventEditView: UIViewControllerRepresentable {
|
|
let event: EKEvent
|
|
let store: EKEventStore
|
|
var onDone: () -> Void
|
|
|
|
func makeUIViewController(context: Context) -> EKEventEditViewController {
|
|
let vc = EKEventEditViewController()
|
|
vc.eventStore = store
|
|
vc.event = event
|
|
vc.editViewDelegate = context.coordinator
|
|
return vc
|
|
}
|
|
|
|
func updateUIViewController(_ vc: EKEventEditViewController, context: Context) {}
|
|
|
|
func makeCoordinator() -> Coordinator { Coordinator(onDone: onDone) }
|
|
|
|
final class Coordinator: NSObject, EKEventEditViewDelegate {
|
|
let onDone: () -> Void
|
|
init(onDone: @escaping () -> Void) { self.onDone = onDone }
|
|
func eventEditViewController(_ controller: EKEventEditViewController,
|
|
didCompleteWith action: EKEventEditViewAction) {
|
|
controller.dismiss(animated: true)
|
|
onDone()
|
|
}
|
|
}
|
|
}
|