/*
* 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())
}