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 = [] @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 name Spacer() Text(monthShortTitle) .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) } } let taskDots = taskVM.tasks .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) 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.tasks.filter { t in guard let d = t.dueDate else { return false } return cal.isDate(d, inSameDayAs: selectedDate) }, calEvents: calStore.isAuthorized ? calStore.events(for: selectedDate) : [], workout: workoutVM.program(for: 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) }, 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 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 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 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 // 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) if let w = workout { let done = w.doneSets == w.totalSets && w.totalSets > 0 out.append(DayTLEntry( timeLabel: "Workout", title: w.name, subtitle: "\(w.totalExercises) exercise\(w.totalExercises == 1 ? "" : "s")", color: 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 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) }, onLiveActivity:{ LiveActivityManager.toggle(for: $0) }, onEdit: onEdit, onDelete: { onDelete?($0) } ) } // 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.. 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() } } }