Atoll/DynamicIsland/components/Calendar/DynamicIslandCalendar.swift
Amir Zarrinkafsh 93786b4a9c
refactor(calendar): open native url schemes from meeting buttons
Open conference meetings directly via native app URL schemes instead of routing through the browser. Adds native scheme support for Zoom, Teams, FaceTime, and Discord with fallback to HTTPS.
2026-04-07 21:24:49 +10:00

1084 lines
40 KiB
Swift

/*
* 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 <https://www.gnu.org/licenses/>.
*/
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..<totalItems, id: \.self) { index in
if index < spacerNum || index >= 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[..<firstWeekdayIndex])
return ordered
}
private var monthTitle: String {
displayedMonth.formatted(.dateTime.month(.wide))
}
private var yearTitle: String {
displayedMonth.formatted(.dateTime.year())
}
private var monthDays: [Date] {
guard let monthInterval = calendar.dateInterval(of: .month, for: displayedMonth),
let firstWeekInterval = calendar.dateInterval(of: .weekOfMonth, for: monthInterval.start),
let lastDay = calendar.date(byAdding: .day, value: -1, to: monthInterval.end),
let lastWeekInterval = calendar.dateInterval(of: .weekOfMonth, for: lastDay)
else { return [] }
var days: [Date] = []
var current = firstWeekInterval.start
while current < lastWeekInterval.end {
days.append(current)
guard let next = calendar.date(byAdding: .day, value: 1, to: current) else { break }
current = next
}
return days
}
private var filteredEvents: [EventModel] {
EventListView.filteredEvents(
events: calendarManager.events,
hideCompletedReminders: hideCompletedReminders,
hideAllDayEvents: hideAllDayEvents
)
}
private var resolvedNotchHeight: CGFloat {
let height = vm.notchSize.height
return height > 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 []
}
}
var nativeURLScheme: String? {
switch self {
case .zoom: return "zoommtg"
case .teams: return "msteams"
case .facetime: return "facetime"
case .discord: return "discord"
default: return nil
}
}
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: {
if let scheme = provider.nativeURLScheme,
var components = URLComponents(url: url, resolvingAgainstBaseURL: false) {
components.scheme = scheme
if let nativeURL = components.url {
openURL(nativeURL)
return
}
}
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())
}