/* * Atoll (DynamicIsland) * Copyright (C) 2024-2026 Atoll Contributors * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ 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 StandaloneCalendarView: View { @EnvironmentObject var vm: DynamicIslandViewModel @ObservedObject private var calendarManager = CalendarManager.shared @State private var selectedDate = Date() @State private var displayedMonth = Date() @State private var datePickerScrollTarget: Date? @Default(.hideAllDayEvents) private var hideAllDayEvents @Default(.hideCompletedReminders) private var hideCompletedReminders private let calendar = Calendar.current private var weekdaySymbols: [String] { let symbols = calendar.veryShortStandaloneWeekdaySymbols guard !symbols.isEmpty else { return symbols } let firstWeekdayIndex = max(0, min(symbols.count - 1, calendar.firstWeekday - 1)) var ordered = Array(symbols[firstWeekdayIndex...]) ordered.append(contentsOf: symbols[.. 0 ? height : openNotchSize.height } private var headerHeight: CGFloat { max(24, vm.effectiveClosedNotchHeight) } private var maxTabContentHeight: CGFloat { let available = resolvedNotchHeight - headerHeight - 36 return max(130, available) } var body: some View { GeometryReader { geometry in let paneSpacing: CGFloat = 12 let paneWidth = max((geometry.size.width - paneSpacing) / 2, 0) let paneHeight = max(0, geometry.size.height) HStack(alignment: .top, spacing: paneSpacing) { leftPickerPane .frame(width: paneWidth, alignment: .topLeading) .frame(height: paneHeight, alignment: .topLeading) .layoutPriority(1) rightEventsPane .frame(width: paneWidth, alignment: .topLeading) .frame(height: paneHeight, alignment: .topLeading) .layoutPriority(1) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .clipped() } .frame(height: maxTabContentHeight) .clipped() .onAppear { selectedDate = Date.now displayedMonth = selectedDate.startOfMonth requestDatePickerCenterOnCurrentDate() Task { await calendarManager.updateCurrentDate(selectedDate) } } .onChange(of: selectedDate) { _, newDate in withAnimation(.smooth(duration: 0.22)) { displayedMonth = newDate.startOfMonth } Task { await calendarManager.updateCurrentDate(newDate) } } .onChange(of: vm.notchState) { _, newState in guard newState == .open else { return } selectedDate = Date.now displayedMonth = selectedDate.startOfMonth requestDatePickerCenterOnCurrentDate() Task { await calendarManager.updateCurrentDate(selectedDate) } } } private var leftPickerPane: some View { GeometryReader { geometry in let pickerViewportHeight = max(96, geometry.size.height - 56) VStack(alignment: .leading, spacing: 10) { HStack(alignment: .center) { HStack(alignment: .firstTextBaseline, spacing: 4) { Text(monthTitle) .font(.title3) .fontWeight(.semibold) .foregroundStyle(.white) Text(yearTitle) .font(.subheadline) .fontWeight(.medium) .foregroundStyle(Color(white: 0.65)) } Spacer() HStack(spacing: 6) { Button(action: showPreviousMonth) { Image(systemName: "chevron.left") .font(.system(size: 11, weight: .bold)) .frame(width: 24, height: 24) } .buttonStyle(.plain) Button(action: showNextMonth) { Image(systemName: "chevron.right") .font(.system(size: 11, weight: .bold)) .frame(width: 24, height: 24) } .buttonStyle(.plain) } .foregroundStyle(.white) } ScrollViewReader { proxy in VStack(spacing: 6) { LazyVGrid(columns: Array(repeating: GridItem(.flexible(minimum: 14), spacing: 6), count: 7), spacing: 6) { ForEach(Array(weekdaySymbols.enumerated()), id: \.offset) { _, symbol in Text(symbol.prefix(1)) .font(.caption2) .fontWeight(.semibold) .foregroundStyle(Color(white: 0.55)) .frame(maxWidth: .infinity) } } ZStack { ScrollView(.vertical, showsIndicators: false) { LazyVGrid(columns: Array(repeating: GridItem(.flexible(minimum: 14), spacing: 6), count: 7), spacing: 6) { ForEach(monthDays, id: \.self) { day in dayCell(for: day) .id(calendar.startOfDay(for: day)) } } .padding(.bottom, 2) } .onChange(of: datePickerScrollTarget) { _, target in guard let target else { return } centerDatePicker(on: target, proxy: proxy) } LinearGradient(colors: [Color.black.opacity(0.65), .clear], startPoint: .top, endPoint: .bottom) .frame(height: 16) .allowsHitTesting(false) .frame(maxHeight: .infinity, alignment: .top) LinearGradient(colors: [.clear, Color.black.opacity(0.65)], startPoint: .top, endPoint: .bottom) .frame(height: 16) .allowsHitTesting(false) .frame(maxHeight: .infinity, alignment: .bottom) } .frame(height: max(0, pickerViewportHeight - 22)) .clipped() } .frame(height: pickerViewportHeight) } .frame(height: pickerViewportHeight) .clipped() } .frame(maxHeight: .infinity, alignment: .top) } .padding(.horizontal, 6) .padding(.top, 4) .clipped() } private var rightEventsPane: some View { Group { if filteredEvents.isEmpty { EmptyEventsView(selectedDate: selectedDate) .frame(maxWidth: .infinity, maxHeight: .infinity) } else { StandaloneEventCardList( events: filteredEvents, showFullEventTitles: Defaults[.showFullEventTitles], onToggleReminder: { reminderID, completed in Task { await calendarManager.setReminderCompleted(reminderID: reminderID, completed: completed) } } ) .frame(maxWidth: .infinity, maxHeight: .infinity) } } .frame(maxWidth: .infinity, maxHeight: .infinity) .clipped() } private func dayCell(for day: Date) -> some View { let isCurrentMonth = calendar.isDate(day, equalTo: displayedMonth, toGranularity: .month) let isSelected = calendar.isDate(day, inSameDayAs: selectedDate) let isToday = calendar.isDateInToday(day) return Button { withAnimation(.smooth(duration: 0.18)) { selectedDate = day } } label: { ZStack { if isSelected { Circle() .fill(Color.effectiveAccent) .frame(width: 28, height: 28) } Text(day.formatted(.dateTime.day())) .font(.system(size: 12, weight: isSelected ? .semibold : .medium)) .foregroundStyle(dayTextColor(isCurrentMonth: isCurrentMonth, isSelected: isSelected, isToday: isToday)) } .frame(maxWidth: .infinity, minHeight: 30) .contentShape(Rectangle()) } .buttonStyle(.plain) } private func dayTextColor(isCurrentMonth: Bool, isSelected: Bool, isToday: Bool) -> Color { if isSelected { return .white } if !isCurrentMonth { return Color(white: 0.35) } if isToday { return Color.effectiveAccent } return .white } private func showPreviousMonth() { guard let newMonth = calendar.date(byAdding: .month, value: -1, to: displayedMonth) else { return } withAnimation(.smooth(duration: 0.22)) { displayedMonth = newMonth.startOfMonth selectedDate = newMonth.startOfMonth } } private func showNextMonth() { guard let newMonth = calendar.date(byAdding: .month, value: 1, to: displayedMonth) else { return } withAnimation(.smooth(duration: 0.22)) { displayedMonth = newMonth.startOfMonth selectedDate = newMonth.startOfMonth } } private func requestDatePickerCenterOnCurrentDate() { datePickerScrollTarget = calendar.startOfDay(for: selectedDate) } private func centerDatePicker(on target: Date, proxy: ScrollViewProxy) { let normalizedTarget = calendar.startOfDay(for: target) DispatchQueue.main.async { withAnimation(.smooth(duration: 0.24)) { proxy.scrollTo(normalizedTarget, anchor: .center) } if datePickerScrollTarget == normalizedTarget { datePickerScrollTarget = nil } } } } 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)) } } } private extension Date { var startOfMonth: Date { Calendar.current.date(from: Calendar.current.dateComponents([.year, .month], from: self)) ?? self } } private struct StandaloneEventCardList: View { @Environment(\.openURL) private var openURL let events: [EventModel] let showFullEventTitles: Bool let onToggleReminder: (String, Bool) -> Void var body: some View { ZStack { ScrollView { LazyVStack(spacing: 8) { ForEach(events) { event in eventCard(event) } } .padding(.vertical, 2) } .clipped() LinearGradient(colors: [Color.black.opacity(0.65), .clear], startPoint: .top, endPoint: .bottom) .frame(height: 16) .allowsHitTesting(false) .frame(maxHeight: .infinity, alignment: .top) LinearGradient(colors: [.clear, Color.black.opacity(0.65)], startPoint: .top, endPoint: .bottom) .frame(height: 16) .allowsHitTesting(false) .frame(maxHeight: .infinity, alignment: .bottom) } .clipped() } @ViewBuilder private func eventCard(_ event: EventModel) -> some View { if event.type.isReminder { Button { if let url = event.calendarAppURL() { openURL(url) } } label: { reminderCard(event) } .buttonStyle(.plain) .padding(.horizontal, 2) } else { calendarEventCard(event) .contentShape(Rectangle()) .onTapGesture { if let url = event.calendarAppURL() { openURL(url) } } .padding(.horizontal, 2) } } private func reminderCard(_ event: EventModel) -> some View { let isCompleted: Bool if case .reminder(let completed) = event.type { isCompleted = completed } else { isCompleted = false } return HStack(spacing: 10) { ReminderToggle( isOn: Binding( get: { isCompleted }, set: { newValue in onToggleReminder(event.id, newValue) } ), color: Color(event.calendar.color) ) VStack(alignment: .leading, spacing: 4) { Text(event.title) .font(.callout) .fontWeight(.medium) .foregroundColor(.white) .lineLimit(showFullEventTitles ? nil : 2) if event.isAllDay { Text("All-day") .font(.caption) .foregroundColor(Color(white: 0.65)) } } Spacer(minLength: 8) if !event.isAllDay { Text(event.start, style: .time) .font(.caption) .foregroundColor(Color(white: 0.75)) } } .padding(.horizontal, 11) .padding(.vertical, 8) .frame(maxWidth: .infinity, minHeight: 50, alignment: .leading) .background( RoundedRectangle(cornerRadius: 14, style: .continuous) .fill(Color.white.opacity(0.05)) ) .opacity(isCompleted ? 0.55 : 1) } private func calendarEventCard(_ event: EventModel) -> some View { HStack(alignment: .top, spacing: 10) { RoundedRectangle(cornerRadius: 2) .fill(Color(event.calendar.color)) .frame(width: 4, height: 36) VStack(alignment: .leading, spacing: 4) { Text(event.title) .font(.callout) .fontWeight(.semibold) .foregroundColor(.white) .lineLimit(showFullEventTitles ? nil : 2) if let location = event.location, !location.isEmpty { Text(location) .font(.caption) .foregroundColor(Color(white: 0.65)) .lineLimit(1) } if let conferenceURL = event.conferenceURL { ConferenceJoinButton(url: conferenceURL, event: event) } } Spacer(minLength: 8) VStack(alignment: .trailing, spacing: 2) { if event.isAllDay { Text("All-day") .font(.caption) .fontWeight(.medium) .foregroundColor(.white) } else { Text(event.start, style: .time) .font(.caption) .foregroundColor(.white) Text(event.end, style: .time) .font(.caption2) .foregroundColor(Color(white: 0.65)) } } } .padding(.horizontal, 11) .padding(.vertical, 8) .frame(maxWidth: .infinity, minHeight: 50, alignment: .leading) .background( RoundedRectangle(cornerRadius: 14, style: .continuous) .fill(Color.white.opacity(0.05)) ) .opacity(event.eventStatus == .ended && Calendar.current.isDateInToday(event.start) ? 0.6 : 1) } } 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()) }