// // DynamicIslandCalendar.swift // DynamicIsland // // Created by Harsh Vardhan Goswami on 08/09/24. // import SwiftUI import Defaults import EventKit struct Config: Equatable { var past: Int = 7 var future: Int = 14 var steps: Int = 1 var spacing: CGFloat = 0 var showsText: Bool = true var offset: Int = 2 } struct WheelPicker: View { @EnvironmentObject var vm: DynamicIslandViewModel @Binding var selectedDate: Date @State private var scrollPosition: Int? @State private var haptics: Bool = false @State private var byClick: Bool = false let config: Config var body: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: config.spacing) { let spacerNum = config.offset let dateCount = totalDateItems() let totalItems = dateCount + 2 * spacerNum ForEach(0..= spacerNum + dateCount { Spacer() .frame(width: 24, height: 24) .id(index) } else { let date = dateForItemIndex(index: index, spacerNum: spacerNum) let isSelected = Calendar.current.isDate(date, inSameDayAs: selectedDate) dateButton(date: date, isSelected: isSelected, id: index) { selectedDate = date byClick = true withAnimation { scrollPosition = index } if Defaults[.enableHaptics] { haptics.toggle() } } } } } .frame(height: 50) .scrollTargetLayout() } .scrollIndicators(.never) .scrollPosition(id: $scrollPosition, anchor: .center) .scrollTargetBehavior(.viewAligned) .safeAreaPadding(.horizontal) .sensoryFeedback(.alignment, trigger: haptics) .onChange(of: scrollPosition) { _, newValue in if !byClick { handleScrollChange(newValue: newValue, config: config) } else { byClick = false } } .onAppear { scrollToToday(config: config) } .onChange(of: selectedDate) { _, newValue in let targetIndex = indexForDate(newValue) if scrollPosition != targetIndex { byClick = true withAnimation { scrollPosition = targetIndex } } } } private func dateButton(date: Date, isSelected: Bool, id: Int, onClick: @escaping () -> Void) -> some View { let isToday = Calendar.current.isDateInToday(date) return Button(action: onClick) { VStack(spacing: 8) { dayText(date: dateToString(for: date), isToday: isToday, isSelected: isSelected) dateCircle(date: date, isToday: isToday, isSelected: isSelected) } .padding(.vertical, 4) .padding(.horizontal, 4) .background(isSelected ? Color.effectiveAccentBackground : Color.clear) .cornerRadius(8) } .buttonStyle(PlainButtonStyle()) .id(id) } private func dayText(date: String, isToday: Bool, isSelected: Bool) -> some View { Text(date) .font(.caption) .foregroundColor(isSelected ? .white : Color(white: 0.65)) } private func dateCircle(date: Date, isToday: Bool, isSelected: Bool) -> some View { ZStack { Circle() .fill(isToday ? Color.effectiveAccent : .clear) .frame(width: 20, height: 20) Text(date.date) .font(.body) .fontWeight(.medium) .foregroundColor(isSelected ? .white : Color(white: isToday ? 0.9 : 0.65)) } } func handleScrollChange(newValue: Int?, config: Config) { guard let newIndex = newValue else { return } let spacerNum = config.offset let dateCount = totalDateItems() guard (spacerNum..<(spacerNum + dateCount)).contains(newIndex) else { return } let date = dateForItemIndex(index: newIndex, spacerNum: spacerNum) if !Calendar.current.isDate(date, inSameDayAs: selectedDate) { selectedDate = date if Defaults[.enableHaptics] { haptics.toggle() } } } private func scrollToToday(config: Config) { let today = Date() byClick = true scrollPosition = indexForDate(today) selectedDate = today } private func indexForDate(_ date: Date) -> Int { let spacerNum = config.offset let cal = Calendar.current let today = cal.startOfDay(for: Date()) let startDate = cal.startOfDay(for: cal.date(byAdding: .day, value: -config.past, to: today) ?? today) let target = cal.startOfDay(for: date) let days = cal.dateComponents([.day], from: startDate, to: target).day ?? 0 let stepIndex = max(0, min(days / max(config.steps, 1), totalDateItems() - 1)) return spacerNum + stepIndex } private func dateForItemIndex(index: Int, spacerNum: Int) -> Date { let cal = Calendar.current let today = cal.startOfDay(for: Date()) let startDate = cal.date(byAdding: .day, value: -config.past, to: today) ?? today let stepIndex = index - spacerNum return cal.date(byAdding: .day, value: stepIndex * max(config.steps, 1), to: startDate) ?? today } private func totalDateItems() -> Int { let range = config.past + config.future let step = max(config.steps, 1) return Int(ceil(Double(range) / Double(step))) + 1 } private func dateToString(for date: Date) -> String { let formatter = DateFormatter() formatter.dateFormat = "E" return formatter.string(from: date) } } struct CalendarView: View { @EnvironmentObject var vm: DynamicIslandViewModel @ObservedObject private var calendarManager = CalendarManager.shared @State private var selectedDate = Date() @Default(.hideAllDayEvents) private var hideAllDayEvents @Default(.hideCompletedReminders) private var hideCompletedReminders var body: some View { VStack(spacing: 0) { HStack(alignment: .top, spacing: 8) { VStack(alignment: .leading) { Text(selectedDate.formatted(.dateTime.month(.abbreviated))) .font(.title3) .fontWeight(.semibold) .foregroundColor(.white) Text(selectedDate.formatted(.dateTime.year())) .font(.title3) .fontWeight(.light) .foregroundColor(Color(white: 0.65)) } ZStack(alignment: .top) { WheelPicker(selectedDate: $selectedDate, config: Config()) HStack(alignment: .top) { LinearGradient( colors: [Color.black, .clear], startPoint: .leading, endPoint: .trailing ) .frame(width: 20) Spacer() LinearGradient( colors: [.clear, Color.black], startPoint: .leading, endPoint: .trailing ) .frame(width: 20) } } } let filteredEvents = EventListView.filteredEvents( events: calendarManager.events, hideCompletedReminders: hideCompletedReminders, hideAllDayEvents: hideAllDayEvents ) if filteredEvents.isEmpty { EmptyEventsView(selectedDate: selectedDate) Spacer(minLength: 0) } else { EventListView(events: calendarManager.events) } } .listRowBackground(Color.clear) .frame(height: 120) .onChange(of: selectedDate) { Task { await calendarManager.updateCurrentDate(selectedDate) } } .onChange(of: vm.notchState) { _, _ in Task { await calendarManager.updateCurrentDate(Date.now) selectedDate = Date.now } } .onAppear { Task { await calendarManager.updateCurrentDate(Date.now) selectedDate = Date.now } } } } struct EmptyEventsView: View { let selectedDate: Date var body: some View { VStack { Image(systemName: "calendar.badge.checkmark") .font(.title) .foregroundColor(Color(white: 0.65)) Text(Calendar.current.isDateInToday(selectedDate) ? "No events today" : "No events") .font(.subheadline) .foregroundColor(.white) Text("Enjoy your free time!") .font(.caption) .foregroundColor(Color(white: 0.65)) } } } struct EventListView: View { @Environment(\.openURL) private var openURL @ObservedObject private var calendarManager = CalendarManager.shared let events: [EventModel] @Default(.autoScrollToNextEvent) private var autoScrollToNextEvent @Default(.showFullEventTitles) private var showFullEventTitles @Default(.hideCompletedReminders) private var hideCompletedReminders @Default(.hideAllDayEvents) private var hideAllDayEvents static func filteredEvents( events: [EventModel], hideCompletedReminders: Bool, hideAllDayEvents: Bool ) -> [EventModel] { events.filter { event in if event.type.isReminder { if case .reminder(let completed) = event.type { return !completed || !hideCompletedReminders } } if event.isAllDay && hideAllDayEvents { return false } return true } } private var filteredEvents: [EventModel] { Self.filteredEvents( events: events, hideCompletedReminders: hideCompletedReminders, hideAllDayEvents: hideAllDayEvents ) } private func scrollToRelevantEvent(proxy: ScrollViewProxy) { guard autoScrollToNextEvent else { return } let now = Date() let nonAllDayUpcoming = filteredEvents.first(where: { !$0.isAllDay && $0.end > now }) let firstAllDay = filteredEvents.first(where: { $0.isAllDay }) let lastEvent = filteredEvents.last guard let target = nonAllDayUpcoming ?? firstAllDay ?? lastEvent else { return } Task { @MainActor in withTransaction(Transaction(animation: nil)) { proxy.scrollTo(target.id, anchor: .top) } } } var body: some View { ScrollViewReader { proxy in ZStack { List { ForEach(filteredEvents) { event in Button(action: { if let url = event.calendarAppURL() { openURL(url) } }) { eventRow(event) } .id(event.id) .padding(.leading, -5) .buttonStyle(PlainButtonStyle()) .listRowSeparator(.automatic) .listRowSeparatorTint(.gray.opacity(0.2)) .listRowBackground(Color.clear) } } .listStyle(.plain) .scrollIndicators(.never) .scrollContentBackground(.hidden) .background(Color.clear) LinearGradient(colors: [Color.black.opacity(0.65), .clear], startPoint: .top, endPoint: .bottom) .frame(height: 16) .allowsHitTesting(false) .alignmentGuide(.top) { d in d[.top] } .frame(maxHeight: .infinity, alignment: .top) LinearGradient(colors: [.clear, Color.black.opacity(0.65)], startPoint: .top, endPoint: .bottom) .frame(height: 16) .allowsHitTesting(false) .alignmentGuide(.bottom) { d in d[.bottom] } .frame(maxHeight: .infinity, alignment: .bottom) } .onAppear { scrollToRelevantEvent(proxy: proxy) } .onChange(of: filteredEvents) { _, _ in scrollToRelevantEvent(proxy: proxy) } } Spacer(minLength: 0) } private func eventRow(_ event: EventModel) -> some View { if event.type.isReminder { let isCompleted: Bool if case .reminder(let completed) = event.type { isCompleted = completed } else { isCompleted = false } return AnyView( HStack(spacing: 8) { ReminderToggle( isOn: Binding( get: { isCompleted }, set: { newValue in Task { await calendarManager.setReminderCompleted( reminderID: event.id, completed: newValue ) } } ), color: Color(event.calendar.color) ) .opacity(1.0) HStack { Text(event.title) .font(.callout) .foregroundColor(.white) .lineLimit(showFullEventTitles ? nil : 1) Spacer(minLength: 0) VStack(alignment: .trailing, spacing: 4) { if event.isAllDay { Text("All-day") .font(.caption) .fontWeight(.medium) .foregroundColor(.white) .lineLimit(1) } else { Text(event.start, style: .time) .foregroundColor(.white) .font(.caption) } } } .opacity( isCompleted ? 0.4 : event.start < Date.now && Calendar.current.isDateInToday(event.start) ? 0.6 : 1.0 ) } .padding(.vertical, 4) ) } else { return AnyView( HStack(alignment: .top, spacing: 4) { Rectangle() .fill(Color(event.calendar.color)) .frame(width: 3) .cornerRadius(1.5) VStack(alignment: .leading, spacing: 2) { Text(event.title) .font(.callout) .fontWeight(.medium) .foregroundColor(.white) .lineLimit(showFullEventTitles ? nil : 2) if let location = event.location, !location.isEmpty { Text(location) .font(.caption) .foregroundColor(Color(white: 0.65)) .lineLimit(1) } // Show Join button if conference URL is available if let conferenceURL = event.conferenceURL { ConferenceJoinButton(url: conferenceURL, event: event) } } Spacer(minLength: 0) VStack(alignment: .trailing, spacing: 4) { if event.isAllDay { Text("All-day") .font(.caption) .fontWeight(.medium) .foregroundColor(.white) .lineLimit(1) } else { Text(event.start, style: .time) .foregroundColor(.white) Text(event.end, style: .time) .foregroundColor(Color(white: 0.65)) } } .font(.caption) .frame(minWidth: 44, alignment: .trailing) } .opacity( event.eventStatus == .ended && Calendar.current.isDateInToday(event.start) ? 0.6 : 1.0) ) } } } struct ReminderToggle: View { @Binding var isOn: Bool var color: Color var body: some View { Button(action: { isOn.toggle() }) { ZStack { Circle() .strokeBorder(color, lineWidth: 2) .frame(width: 14, height: 14) if isOn { Circle() .fill(color) .frame(width: 8, height: 8) } Circle() .fill(Color.black.opacity(0.001)) .frame(width: 14, height: 14) } } .buttonStyle(PlainButtonStyle()) .padding(0) .accessibilityLabel(isOn ? "Mark as incomplete" : "Mark as complete") } } // MARK: - Conference Provider enum ConferenceProvider: CaseIterable { case zoom, teams, meet, webex, facetime, gotomeeting, bluejeans, whereby, jitsi, discord, generic var name: String { switch self { case .zoom: return "Zoom" case .teams: return "Teams" case .meet: return "Meet" case .webex: return "Webex" case .facetime: return "FaceTime" case .gotomeeting: return "GoToMeeting" case .bluejeans: return "BlueJeans" case .whereby: return "Whereby" case .jitsi: return "Jitsi" case .discord: return "Discord" case .generic: return "" } } var color: Color { switch self { case .zoom: return Color(red: 0.16, green: 0.52, blue: 0.95) case .teams: return Color(red: 0.36, green: 0.42, blue: 0.89) case .meet: return Color(red: 0.0, green: 0.65, blue: 0.42) case .webex: return Color(red: 0.0, green: 0.71, blue: 0.84) case .facetime: return Color(red: 0.2, green: 0.78, blue: 0.35) case .gotomeeting: return Color(red: 0.95, green: 0.5, blue: 0.13) case .bluejeans: return Color(red: 0.0, green: 0.48, blue: 0.87) case .whereby: return Color(red: 0.27, green: 0.51, blue: 0.96) case .jitsi: return Color(red: 0.16, green: 0.68, blue: 0.95) case .discord: return Color(red: 0.35, green: 0.39, blue: 0.98) case .generic: return Color.accentColor } } private var hostIdentifiers: [String] { switch self { case .zoom: return ["zoom.us"] case .teams: return ["teams.microsoft.com"] case .meet: return ["meet.google.com"] case .webex: return ["webex.com"] case .facetime: return ["facetime.apple.com"] case .gotomeeting: return ["gotomeeting.com"] case .bluejeans: return ["bluejeans.com"] case .whereby: return ["whereby.com"] case .jitsi: return ["meet.jit.si", "jitsi"] case .discord: return ["discord.gg", "discord.com"] case .generic: return [] } } static func detect(from url: URL) -> ConferenceProvider { let host = url.host?.lowercased() ?? "" return allCases.first { provider in provider.hostIdentifiers.contains { host.contains($0) } } ?? .generic } } // MARK: - Conference Join Button struct ConferenceJoinButton: View { let url: URL let event: EventModel @Environment(\.openURL) private var openURL private var provider: ConferenceProvider { .detect(from: url) } private var isJoinable: Bool { event.start.addingTimeInterval(-15 * 60) <= Date() } private var buttonText: String { event.eventStatus == .inProgress ? "Rejoin" : "Join" } var body: some View { Button(action: { openURL(url) }) { HStack(spacing: 4) { Image(systemName: "video.fill") .font(.system(size: 9)) Text(provider.name.isEmpty ? buttonText : "\(buttonText) \(provider.name)") .font(.caption2) .fontWeight(.medium) } .foregroundColor(isJoinable ? .white : Color(white: 0.5)) .padding(.horizontal, 8) .padding(.vertical, 4) .background(isJoinable ? provider.color.opacity(0.85) : Color.gray.opacity(0.3)) .cornerRadius(6) } .buttonStyle(PlainButtonStyle()) .disabled(!isJoinable) .help(isJoinable ? "Join the meeting" : "Meeting starts at \(event.start.formatted(date: .omitted, time: .shortened))") } } #Preview { CalendarView() .frame(width: 250) .padding(.horizontal) .background(.black) .environmentObject(DynamicIslandViewModel.init()) }