mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 23:24:32 +00:00
493 lines
18 KiB
Swift
493 lines
18 KiB
Swift
//
|
|
// DynamicIslandCalendar.swift
|
|
// DynamicIsland
|
|
//
|
|
// Created by Harsh Vardhan Goswami on 08/09/24.
|
|
//
|
|
|
|
import SwiftUI
|
|
import Defaults
|
|
import EventKit
|
|
|
|
struct Config: Equatable {
|
|
var past: Int = 7
|
|
var future: Int = 14
|
|
var steps: Int = 1
|
|
var spacing: CGFloat = 0
|
|
var showsText: Bool = true
|
|
var offset: Int = 2
|
|
}
|
|
|
|
struct WheelPicker: View {
|
|
@EnvironmentObject var vm: DynamicIslandViewModel
|
|
@Binding var selectedDate: Date
|
|
@State private var scrollPosition: Int?
|
|
@State private var haptics: Bool = false
|
|
@State private var byClick: Bool = false
|
|
let config: Config
|
|
|
|
var body: some View {
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: config.spacing) {
|
|
let spacerNum = config.offset
|
|
let dateCount = totalDateItems()
|
|
let totalItems = dateCount + 2 * spacerNum
|
|
ForEach(0..<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 EmptyEventsView: View {
|
|
let selectedDate: Date
|
|
|
|
var body: some View {
|
|
VStack {
|
|
Image(systemName: "calendar.badge.checkmark")
|
|
.font(.title)
|
|
.foregroundColor(Color(white: 0.65))
|
|
Text(Calendar.current.isDateInToday(selectedDate) ? "No events today" : "No events")
|
|
.font(.subheadline)
|
|
.foregroundColor(.white)
|
|
Text("Enjoy your free time!")
|
|
.font(.caption)
|
|
.foregroundColor(Color(white: 0.65))
|
|
}
|
|
}
|
|
}
|
|
|
|
struct EventListView: View {
|
|
@Environment(\.openURL) private var openURL
|
|
@ObservedObject private var calendarManager = CalendarManager.shared
|
|
let events: [EventModel]
|
|
@Default(.autoScrollToNextEvent) private var autoScrollToNextEvent
|
|
@Default(.showFullEventTitles) private var showFullEventTitles
|
|
@Default(.hideCompletedReminders) private var hideCompletedReminders
|
|
@Default(.hideAllDayEvents) private var hideAllDayEvents
|
|
|
|
static func filteredEvents(
|
|
events: [EventModel],
|
|
hideCompletedReminders: Bool,
|
|
hideAllDayEvents: Bool
|
|
) -> [EventModel] {
|
|
events.filter { event in
|
|
if event.type.isReminder {
|
|
if case .reminder(let completed) = event.type {
|
|
return !completed || !hideCompletedReminders
|
|
}
|
|
}
|
|
if event.isAllDay && hideAllDayEvents {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
}
|
|
|
|
private var filteredEvents: [EventModel] {
|
|
Self.filteredEvents(
|
|
events: events,
|
|
hideCompletedReminders: hideCompletedReminders,
|
|
hideAllDayEvents: hideAllDayEvents
|
|
)
|
|
}
|
|
|
|
private func scrollToRelevantEvent(proxy: ScrollViewProxy) {
|
|
guard autoScrollToNextEvent else { return }
|
|
let now = Date()
|
|
let nonAllDayUpcoming = filteredEvents.first(where: { !$0.isAllDay && $0.end > now })
|
|
let firstAllDay = filteredEvents.first(where: { $0.isAllDay })
|
|
let lastEvent = filteredEvents.last
|
|
guard let target = nonAllDayUpcoming ?? firstAllDay ?? lastEvent else { return }
|
|
|
|
Task { @MainActor in
|
|
withTransaction(Transaction(animation: nil)) {
|
|
proxy.scrollTo(target.id, anchor: .top)
|
|
}
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
ScrollViewReader { proxy in
|
|
ZStack {
|
|
List {
|
|
ForEach(filteredEvents) { event in
|
|
Button(action: {
|
|
if let url = event.calendarAppURL() {
|
|
openURL(url)
|
|
}
|
|
}) {
|
|
eventRow(event)
|
|
}
|
|
.id(event.id)
|
|
.padding(.leading, -5)
|
|
.buttonStyle(PlainButtonStyle())
|
|
.listRowSeparator(.automatic)
|
|
.listRowSeparatorTint(.gray.opacity(0.2))
|
|
.listRowBackground(Color.clear)
|
|
}
|
|
}
|
|
.listStyle(.plain)
|
|
.scrollIndicators(.never)
|
|
.scrollContentBackground(.hidden)
|
|
.background(Color.clear)
|
|
|
|
LinearGradient(colors: [Color.black.opacity(0.65), .clear], startPoint: .top, endPoint: .bottom)
|
|
.frame(height: 16)
|
|
.allowsHitTesting(false)
|
|
.alignmentGuide(.top) { d in d[.top] }
|
|
.frame(maxHeight: .infinity, alignment: .top)
|
|
|
|
LinearGradient(colors: [.clear, Color.black.opacity(0.65)], startPoint: .top, endPoint: .bottom)
|
|
.frame(height: 16)
|
|
.allowsHitTesting(false)
|
|
.alignmentGuide(.bottom) { d in d[.bottom] }
|
|
.frame(maxHeight: .infinity, alignment: .bottom)
|
|
}
|
|
.onAppear {
|
|
scrollToRelevantEvent(proxy: proxy)
|
|
}
|
|
.onChange(of: filteredEvents) { _, _ in
|
|
scrollToRelevantEvent(proxy: proxy)
|
|
}
|
|
}
|
|
Spacer(minLength: 0)
|
|
}
|
|
|
|
private func eventRow(_ event: EventModel) -> some View {
|
|
if event.type.isReminder {
|
|
let isCompleted: Bool
|
|
if case .reminder(let completed) = event.type {
|
|
isCompleted = completed
|
|
} else {
|
|
isCompleted = false
|
|
}
|
|
return AnyView(
|
|
HStack(spacing: 8) {
|
|
ReminderToggle(
|
|
isOn: Binding(
|
|
get: { isCompleted },
|
|
set: { newValue in
|
|
Task {
|
|
await calendarManager.setReminderCompleted(
|
|
reminderID: event.id, completed: newValue
|
|
)
|
|
}
|
|
}
|
|
),
|
|
color: Color(event.calendar.color)
|
|
)
|
|
.opacity(1.0)
|
|
HStack {
|
|
Text(event.title)
|
|
.font(.callout)
|
|
.foregroundColor(.white)
|
|
.lineLimit(showFullEventTitles ? nil : 1)
|
|
Spacer(minLength: 0)
|
|
VStack(alignment: .trailing, spacing: 4) {
|
|
if event.isAllDay {
|
|
Text("All-day")
|
|
.font(.caption)
|
|
.fontWeight(.medium)
|
|
.foregroundColor(.white)
|
|
.lineLimit(1)
|
|
} else {
|
|
Text(event.start, style: .time)
|
|
.foregroundColor(.white)
|
|
.font(.caption)
|
|
}
|
|
}
|
|
}
|
|
.opacity(
|
|
isCompleted
|
|
? 0.4
|
|
: event.start < Date.now && Calendar.current.isDateInToday(event.start)
|
|
? 0.6 : 1.0
|
|
)
|
|
}
|
|
.padding(.vertical, 4)
|
|
)
|
|
} else {
|
|
return AnyView(
|
|
HStack(alignment: .top, spacing: 4) {
|
|
Rectangle()
|
|
.fill(Color(event.calendar.color))
|
|
.frame(width: 3)
|
|
.cornerRadius(1.5)
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(event.title)
|
|
.font(.callout)
|
|
.fontWeight(.medium)
|
|
.foregroundColor(.white)
|
|
.lineLimit(showFullEventTitles ? nil : 2)
|
|
|
|
if let location = event.location, !location.isEmpty {
|
|
Text(location)
|
|
.font(.caption)
|
|
.foregroundColor(Color(white: 0.65))
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
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")
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
CalendarView()
|
|
.frame(width: 250)
|
|
.padding(.horizontal)
|
|
.background(.black)
|
|
.environmentObject(DynamicIslandViewModel.init())
|
|
}
|