mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
1064 lines
40 KiB
Swift
1064 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 []
|
|
}
|
|
}
|
|
|
|
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())
|
|
}
|