diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/ECWeekView.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/ECWeekView.xcscheme index 453b4bb..21405fe 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/ECWeekView.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/ECWeekView.xcscheme @@ -40,8 +40,19 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "YES"> + shouldUseLaunchSchemeArgsEnv = "YES" + codeCoverageEnabled = "YES"> + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Example/ECWeekViewExample/AppDelegate.swift b/Example/ECWeekViewExample/AppDelegate.swift deleted file mode 100644 index b57ebc5..0000000 --- a/Example/ECWeekViewExample/AppDelegate.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// AppDelegate.swift -// ECWeekViewExample -// -// Created by Evan Cooper on 2020-10-07. -// - -import UIKit - -@main -class AppDelegate: UIResponder, UIApplicationDelegate { - - - - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - // Override point for customization after application launch. - return true - } - - // MARK: UISceneSession Lifecycle - - func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { - // Called when a new scene session is being created. - // Use this method to select a configuration to create the new scene with. - return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) - } - - func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { - // Called when the user discards a scene session. - // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. - // Use this method to release any resources that were specific to the discarded scenes, as they will not return. - } - - -} - diff --git a/Example/ECWeekViewExample/Base.lproj/LaunchScreen.storyboard b/Example/ECWeekViewExample/Base.lproj/LaunchScreen.storyboard deleted file mode 100644 index 865e932..0000000 --- a/Example/ECWeekViewExample/Base.lproj/LaunchScreen.storyboard +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Example/ECWeekViewExample/Base.lproj/Main.storyboard b/Example/ECWeekViewExample/Base.lproj/Main.storyboard deleted file mode 100644 index 05502d8..0000000 --- a/Example/ECWeekViewExample/Base.lproj/Main.storyboard +++ /dev/null @@ -1,54 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Example/ECWeekViewExample/ECWeekViewExampleApp.swift b/Example/ECWeekViewExample/ECWeekViewExampleApp.swift new file mode 100644 index 0000000..9dfe9a3 --- /dev/null +++ b/Example/ECWeekViewExample/ECWeekViewExampleApp.swift @@ -0,0 +1,15 @@ +import ECWeekView +import SwiftUI + +@main +struct ECWeekViewExampleApp: App { + var body: some Scene { + WindowGroup { + ECWeekView(visibleDays: 2, visibleHours: 8, startHour: 9) { event in + ECEventView(event: event) + } header: { date in + Text(date.formatted(date: .abbreviated, time: .omitted)) + } + } + } +} diff --git a/Example/ECWeekViewExample/EventDetailLauncher.swift b/Example/ECWeekViewExample/EventDetailLauncher.swift deleted file mode 100644 index adbccc6..0000000 --- a/Example/ECWeekViewExample/EventDetailLauncher.swift +++ /dev/null @@ -1,68 +0,0 @@ -import ECWeekView -import Foundation -import UIKit - -class EventDetailLauncher { - - lazy var eventView: UIView = { - let view = UIView() - view.backgroundColor = UIColor.white - view.translatesAutoresizingMaskIntoConstraints = false - view.layer.cornerRadius = 10 - return view - }() - - lazy var blackView: UIView = { - let view = UIView() - view.backgroundColor = UIColor(white: 0, alpha: 0.6) - let tapGuesture = UITapGestureRecognizer(target: self, action: #selector(self.dismiss)) - view.addGestureRecognizer(tapGuesture) - return view - }() - - var event: ECWeekViewEvent? - - @objc func present() { - guard let window = UIApplication.shared.windows.first(where: { $0.isKeyWindow }) else { return } - - let height: CGFloat = 100 - let buffer: CGFloat = 64 - - blackView.frame = window.frame - blackView.alpha = 0 - window.addSubview(blackView) - - eventView.frame = CGRect(x: buffer, y: window.frame.height, width: window.frame.width - (buffer * 2), height: height) - let eventLabel = UILabel(frame: CGRect(x: 16, y: 16, width: eventView.frame.width - 32, height: (eventView.frame.height / 2) - 16)) - eventLabel.text = event!.title - eventLabel.font = UIFont.systemFont(ofSize: 20) - eventLabel.textAlignment = .center - let idLabel = UILabel(frame: CGRect(x: 16, y: eventView.frame.height / 2, width: eventView.frame.width - 32, height: eventView.frame.height / 2)) - idLabel.text = event!.uuid - idLabel.font = UIFont.systemFont(ofSize: 10) - idLabel.textAlignment = .center - eventView.addSubview(eventLabel) - eventView.addSubview(idLabel) - window.addSubview(eventView) - - UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 1, initialSpringVelocity: 1, options: .curveEaseOut, animations: { - self.eventView.frame.origin.y = (window.frame.height / 2) - (height / 2) - self.blackView.alpha = 0.6 - }) - } - - @objc func dismiss() { - guard let window = UIApplication.shared.windows.first(where: { $0.isKeyWindow }) else { return } - - UIView.animate(withDuration: 0.5, delay: 0, usingSpringWithDamping: 1, initialSpringVelocity: 1, options: .curveEaseOut, animations: { - self.eventView.frame.origin.y = window.frame.height - self.blackView.alpha = 0 - }) { (animationComplete) in - self.eventView.removeFromSuperview() - for subview in self.eventView.subviews { - subview.removeFromSuperview() - } - self.blackView.removeFromSuperview() - } - } -} diff --git a/Example/ECWeekViewExample/Info.plist b/Example/ECWeekViewExample/Info.plist index 192ee54..a151623 100644 --- a/Example/ECWeekViewExample/Info.plist +++ b/Example/ECWeekViewExample/Info.plist @@ -2,6 +2,8 @@ + NSCalendarsUsageDescription + ECWeekViewExample needs access to your calendar to view and manage calendar events CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable @@ -23,28 +25,12 @@ UIApplicationSceneManifest UIApplicationSupportsMultipleScenes - - UISceneConfigurations - - UIWindowSceneSessionRoleApplication - - - UISceneConfigurationName - Default Configuration - UISceneDelegateClassName - $(PRODUCT_MODULE_NAME).SceneDelegate - UISceneStoryboardFile - Main - - - + UIApplicationSupportsIndirectInputEvents - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main + UILaunchScreen + UIRequiredDeviceCapabilities armv7 @@ -52,8 +38,8 @@ UISupportedInterfaceOrientations UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeRight UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight UISupportedInterfaceOrientations~ipad diff --git a/Example/ECWeekViewExample/Preview Content/Preview Assets.xcassets/Contents.json b/Example/ECWeekViewExample/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Example/ECWeekViewExample/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Example/ECWeekViewExample/SceneDelegate.swift b/Example/ECWeekViewExample/SceneDelegate.swift deleted file mode 100644 index 37913c1..0000000 --- a/Example/ECWeekViewExample/SceneDelegate.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// SceneDelegate.swift -// ECWeekViewExample -// -// Created by Evan Cooper on 2020-10-07. -// - -import UIKit - -class SceneDelegate: UIResponder, UIWindowSceneDelegate { - - var window: UIWindow? - - - func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { - // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. - // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. - // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). - guard let _ = (scene as? UIWindowScene) else { return } - } - - func sceneDidDisconnect(_ scene: UIScene) { - // Called as the scene is being released by the system. - // This occurs shortly after the scene enters the background, or when its session is discarded. - // Release any resources associated with this scene that can be re-created the next time the scene connects. - // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead). - } - - func sceneDidBecomeActive(_ scene: UIScene) { - // Called when the scene has moved from an inactive state to an active state. - // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. - } - - func sceneWillResignActive(_ scene: UIScene) { - // Called when the scene will move from an active state to an inactive state. - // This may occur due to temporary interruptions (ex. an incoming phone call). - } - - func sceneWillEnterForeground(_ scene: UIScene) { - // Called as the scene transitions from the background to the foreground. - // Use this method to undo the changes made on entering the background. - } - - func sceneDidEnterBackground(_ scene: UIScene) { - // Called as the scene transitions from the foreground to the background. - // Use this method to save data, release shared resources, and store enough scene-specific state information - // to restore the scene back to its current state. - } - - -} - diff --git a/Example/ECWeekViewExample/ViewController.swift b/Example/ECWeekViewExample/ViewController.swift deleted file mode 100644 index dcea9cf..0000000 --- a/Example/ECWeekViewExample/ViewController.swift +++ /dev/null @@ -1,59 +0,0 @@ -import ECWeekView -import SwiftDate -import UIKit - -class ViewController: UIViewController { - - // MARK: - Outlets - - @IBOutlet private var weekView: ECWeekView! - - // MARK: - Private Properties - - private let eventDetailLauncher = EventDetailLauncher() - - // MARK: - Lifecycle - - override func viewDidLoad() { - super.viewDidLoad() - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - weekView.dataSource = self - weekView.delegate = self - } -} - -// MARK: - ECWeekViewDataSource - -extension ViewController: ECWeekViewDataSource { - func weekViewGenerateEvents(_ weekView: ECWeekView, date: DateInRegion, eventCompletion: @escaping ([ECWeekViewEvent]?) -> Void) -> [ECWeekViewEvent]? { -// let start1 = date.dateBySet(hour: (date.day % 5) + 9, min: 0, secs: 0)! -// let end1 = date.dateBySet(hour: start1.hour + (date.day % 3) + 1, min: 30 * (date.day % 2), secs: 0)! -// let event = ECWeekViewEvent(title: "Title \(date.day)", subtitle: "Subtitle \(date.day)", start: start1, end: end1) - - let lunchStart = date.dateBySet(hour: 12, min: 0, secs: 0)! - let lunchEnd = date.dateBySet(hour: 13, min: 0, secs: 0)! - let lunch = ECWeekViewEvent(title: "Lunch", subtitle: "lunch", start: lunchStart, end: lunchEnd) - - DispatchQueue.global(qos: .background).async { - eventCompletion([lunch]) - } - - return nil - } -} - -// MARK: - ECWeekViewDelegate - -extension ViewController: ECWeekViewDelegate { - func weekViewDidClickOnEvent(_ weekView: ECWeekView, event: ECWeekViewEvent, view: UIView) { - eventDetailLauncher.event = event - eventDetailLauncher.present() - } - - func weekViewDidClickOnFreeTime(_ weekView: ECWeekView, date: DateInRegion) { - print(#function, "date:", date.toString()) - } -} diff --git a/Example/ECWeekViewExampleTests/ECWeekViewExampleTests.swift b/Example/ECWeekViewExampleTests/ECWeekViewExampleTests.swift deleted file mode 100644 index 9f101e2..0000000 --- a/Example/ECWeekViewExampleTests/ECWeekViewExampleTests.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// ECWeekViewExampleTests.swift -// ECWeekViewExampleTests -// -// Created by Evan Cooper on 2020-10-07. -// - -import XCTest -@testable import ECWeekViewExample - -class ECWeekViewExampleTests: XCTestCase { - - override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - } - - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. - } - - func testExample() throws { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct results. - } - - func testPerformanceExample() throws { - // This is an example of a performance test case. - self.measure { - // Put the code you want to measure the time of here. - } - } - -} diff --git a/Example/ECWeekViewExampleTests/Info.plist b/Example/ECWeekViewExampleTests/Info.plist deleted file mode 100644 index 64d65ca..0000000 --- a/Example/ECWeekViewExampleTests/Info.plist +++ /dev/null @@ -1,22 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - 1.0 - CFBundleVersion - 1 - - diff --git a/Example/ECWeekViewExampleUITests/ECWeekViewExampleUITests.swift b/Example/ECWeekViewExampleUITests/ECWeekViewExampleUITests.swift deleted file mode 100644 index 098c0cc..0000000 --- a/Example/ECWeekViewExampleUITests/ECWeekViewExampleUITests.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// ECWeekViewExampleUITests.swift -// ECWeekViewExampleUITests -// -// Created by Evan Cooper on 2020-10-07. -// - -import XCTest - -class ECWeekViewExampleUITests: XCTestCase { - - override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - - // In UI tests it is usually best to stop immediately when a failure occurs. - continueAfterFailure = false - - // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. - } - - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. - } - - func testExample() throws { - // UI tests must launch the application that they test. - let app = XCUIApplication() - app.launch() - - // Use recording to get started writing UI tests. - // Use XCTAssert and related functions to verify your tests produce the correct results. - } - - func testLaunchPerformance() throws { - if #available(macOS 10.15, iOS 13.0, tvOS 13.0, *) { - // This measures how long it takes to launch your application. - measure(metrics: [XCTApplicationLaunchMetric()]) { - XCUIApplication().launch() - } - } - } -} diff --git a/Example/ECWeekViewExampleUITests/Info.plist b/Example/ECWeekViewExampleUITests/Info.plist deleted file mode 100644 index 64d65ca..0000000 --- a/Example/ECWeekViewExampleUITests/Info.plist +++ /dev/null @@ -1,22 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - 1.0 - CFBundleVersion - 1 - - diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7aed1f0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Evan Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Media/interacting_with_events.gif b/Media/interacting_with_events.gif deleted file mode 100644 index 36f165e..0000000 Binary files a/Media/interacting_with_events.gif and /dev/null differ diff --git a/Media/screen1.png b/Media/screen1.png deleted file mode 100644 index 571616f..0000000 Binary files a/Media/screen1.png and /dev/null differ diff --git a/Media/scrolling_through_events.gif b/Media/scrolling_through_events.gif deleted file mode 100644 index 733521b..0000000 Binary files a/Media/scrolling_through_events.gif and /dev/null differ diff --git a/Package.swift b/Package.swift index e5b0614..e366249 100644 --- a/Package.swift +++ b/Package.swift @@ -1,33 +1,21 @@ -// swift-tools-version:5.3 -// The swift-tools-version declares the minimum version of Swift required to build this package. +// swift-tools-version:6.0 import PackageDescription let package = Package( name: "ECWeekView", platforms: [ - .iOS(.v12) + .iOS(.v17) ], products: [ - .library( - name: "ECWeekView", - targets: ["ECWeekView"]), + .library(name: "ECWeekView", type: .dynamic, targets: ["ECWeekView"]), ], dependencies: [ - .package(url: "https://github.com/EvanCooper9/ECTimelineView", from: "1.0.0"), - .package(url: "https://github.com/malcommac/SwiftDate.git", from: "5.0.0") + .package(url: "https://github.com/EvanCooper9/ECKit", branch: "main"), + .package(url: "https://github.com/EvanCooper9/ECScrollView", from: "1.0.0") ], targets: [ - .target( - name: "ECWeekView", - dependencies: [ - .byName(name: "ECTimelineView"), - .byName(name: "SwiftDate") - ], - path: "Sources", - resources: [ - .process("Resources") - ] - ), + .target(name: "ECWeekView", dependencies: ["ECKit", "ECScrollView"], path: "Sources"), + .testTarget(name: "ECWeekViewTests", dependencies: ["ECWeekView"], path: "Tests") ] ) diff --git a/README.md b/README.md index 5f15db9..cc3570d 100644 --- a/README.md +++ b/README.md @@ -1,89 +1,30 @@ # ECWeekView -An iOS calendar library for displaying calendar events in a week view. -

- - - -

+An iOS calendar library for displaying calendar events in a week view. ## Features - See calendar events in a week view -- Asynchronously load calendar events -- Interaction with specific events by clicking -- Interaction with free time spaces by clicking -- Custom styling +- Pull events from EventKit +- Drag & drop +- Event editing - Infinite horizontal scrolling ## Installation ### Swift Package Manager ``` -.package(url: "https://github.com/EvanCooper9/swift-week-view") +.package(url: "https://github.com/EvanCooper9/swift-week-view", branch: "swiftui") ``` ## Usage -### 1. Implement the `ECWeekViewDataSource` -Implement the `weekViewGenerateEvents` protocol function. This function should return a list of `ECWeekViewEvent`s specific to the day of `date`. Events that can be created immediately should be returned to this function. Events that require time to create should be passed to `eventCompletion`, which will overwrite previously returned events. See [here](malcommac.github.io/SwiftDate/manipulate_dates.html#dateatunit) for SwiftDate documentation on creating date objects at specific times. Currently, events rely on a [24-hour clock](https://en.wikipedia.org/wiki/24-hour_clock). -```Swift -func weekViewGenerateEvents(_ weekView: ECWeekView, date: DateInRegion, eventCompletion: @escaping ([ECWeekViewEvent]?) -> Void) -> [ECWeekViewEvent]? { - let start: DateInRegion = date.dateBySet(hour: 12, min: 0, secs: 0)! - let end: DateInRegion = date.dateBySet(hour: 13, min: 0, secs: 0)! - let event: ECWeekViewEvent = ECWeekViewEvent(title: "Lunch", start: start, end: end) +```swift +import ECWeekView - DispatchQueue.global(.background).async { - // do some async work & create events... - eventCompletion([event, ...]) - } - - return [event] +struct ContentView: View { + var body: some View { + ECWeekView() + } } ``` -#### Available arguments for `ECWeekViewEvent` -- `title`: the title of the event -- `subtitle`: a subtitle or description of the event -- `start`: the start time of the event -- `end`: the end time of the event - -### 2. Initialize the instance -#### 2A. Programmatically -Create an instance of `ECWeekView`, specify it's data source, and add it as a subview. - -```Swift -let weekView = ECWeekView(frame: frame, visibleDays: 5) -weekView.dataSource = self -addSubview(weekView) -``` -##### Available arguments for `ECWeekView` -- `frame`: the frame of the calendar view -- `visibleDays`: amount of days that are visible on one page. Default = 5 -- `date`: (Optional) the day `ECWeekView` will initially load. Default = today - -#### 2B. Storyboard -Add a view to the storyboard and set it's class `ECWeekView`. Assign the view's data source programmatically. -```Swift -@IBOutlet weak var weekView: ECWeekView! -weekView.dataSource = self -``` -## User Interaction -To handle interaction with `ECWeekView`, implement the `ECWeekViewDelegate` protocol and set the `delegate` property to the implementing class. - -```Swift -// Fires when a calendar event is touched on -func weekViewDidClickOnEvent(_ weekView: ECWeekView, event: ECWeekViewEvent, view: UIView) - -// Fires when a space without an event is tapped -func weekViewDidClickOnFreeTime(_ weekView: ECWeekView, date: DateInRegion) -``` - -## Custom Styling -To use custom styling, implement the `ECWeekViewStyler` protocol and assign the `styler` property to the implementing class. `ECWeekView` by default is its own styler. - -```Swift -// Creates the view for an event -func weekViewStylerECEventView(_ weekView: ECWeekView, eventContainer: CGRect, event: ECWeekViewEvent) -> UIView - -// Create the header view for the day in the calendar. This would normally contain information about the date -func weekViewStylerHeaderView(_ weekView: ECWeekView, with date: DateInRegion, in cell: UICollectionViewCell) -> UIView -``` +>You should add `NSCalendarsUsageDescription` to your app's `Info.plist` diff --git a/Sources/Extensions/CoreGraphics/CGFloat+Extensions.swift b/Sources/Extensions/CoreGraphics/CGFloat+Extensions.swift new file mode 100644 index 0000000..3acbe53 --- /dev/null +++ b/Sources/Extensions/CoreGraphics/CGFloat+Extensions.swift @@ -0,0 +1,7 @@ +import CoreGraphics + +public extension CGFloat { + func roundToNearest(_ nearest: CGFloat) -> CGFloat { + (self / nearest).rounded() * nearest + } +} diff --git a/Sources/Extensions/EventKit/EKEvent+Extensions.swift b/Sources/Extensions/EventKit/EKEvent+Extensions.swift new file mode 100644 index 0000000..0adc8fb --- /dev/null +++ b/Sources/Extensions/EventKit/EKEvent+Extensions.swift @@ -0,0 +1,62 @@ +import EventKit + +extension EKEvent: @retroactive Identifiable {} + +public extension EKEvent { + private var cal: Calendar { .current } + var startHour: Int { cal.component(.hour, from: startDate) } + var startMinute: Int { cal.component(.minute, from: startDate) } + var endHour: Int { cal.component(.hour, from: endDate) } + var endMinute: Int { cal.component(.minute, from: endDate) } + + func collides(with event: EKEvent) -> Bool { + let startComparison = event.startDate.compare(startDate) + let startsBeforeStart = startComparison == .orderedAscending + let startsAfterStart = startComparison == .orderedDescending + let startsSameStart = startComparison == .orderedSame + + let startEndComparison = event.startDate.compare(endDate) + let startsBeforeEnd = startEndComparison == .orderedAscending +// let startsAfterEnd = startEndComparison == .orderedDescending +// let startsSameEnd = startEndComparison == .orderedSame + + let endStartComparison = event.endDate.compare(startDate) +// let endsBeforeStart = endStartComparison == .orderedAscending + let endsAfterStart = endStartComparison == .orderedDescending +// let endSameStart = endStartComparison == .orderedSame + + let endComparison = event.endDate.compare(endDate) + let endsBeforeEnd = endComparison == .orderedAscending + let endsAfterEnd = endComparison == .orderedDescending + let endsSameEnd = endComparison == .orderedSame + + let cases: [Bool] = [ + (startsBeforeStart && endsAfterStart), + (startsBeforeEnd && endsAfterEnd), + (startsAfterStart && endsBeforeEnd), + (startsSameStart || endsSameEnd) + ] + + return cases.contains { $0 } + } +} + +extension EKEvent: @retroactive Comparable { + public static func < (lhs: EKEvent, rhs: EKEvent) -> Bool { + let comparison = lhs.compareStartDate(with: rhs) + guard comparison != .orderedSame else { return lhs.title < rhs.title } + return comparison == .orderedAscending + } +} + + +public extension Array where Element: EKEvent { + func overlappingEvents(against event: EKEvent) -> Self { + self + .filter { !$0.isAllDay } + .filter { someEvent in + guard !someEvent.isAllDay, someEvent.id != event.id else { return false } + return event.collides(with: someEvent) + } + } +} diff --git a/Sources/Extensions/EventKit/EKEventStore+Extensions.swift b/Sources/Extensions/EventKit/EKEventStore+Extensions.swift new file mode 100644 index 0000000..d76f4bd --- /dev/null +++ b/Sources/Extensions/EventKit/EKEventStore+Extensions.swift @@ -0,0 +1,12 @@ +import EventKit + +@objc +public protocol EventStoreAuthorization { + func authorizationStatus(for entityType: EKEntityType) -> EKAuthorizationStatus +} + +extension EKEventStore: EventStoreAuthorization { + open func authorizationStatus(for entityType: EKEntityType) -> EKAuthorizationStatus { + EKEventStore.authorizationStatus(for: entityType) + } +} diff --git a/Sources/Extensions/Foundation/Date+Extensions.swift b/Sources/Extensions/Foundation/Date+Extensions.swift new file mode 100644 index 0000000..c614100 --- /dev/null +++ b/Sources/Extensions/Foundation/Date+Extensions.swift @@ -0,0 +1,15 @@ +import Foundation + +public extension Date { + var isToday: Bool { + isSameDay(as: Date()) + } + + func isSameDay(as date: Date) -> Bool { + Calendar.current.compare(self, to: date, toGranularity: .day) == .orderedSame + } + + mutating func addTimeInterval(_ timeInterval: Int) { + addTimeInterval(TimeInterval(timeInterval)) + } +} diff --git a/Sources/Extensions/Foundation/Notification+Extensions.swift b/Sources/Extensions/Foundation/Notification+Extensions.swift new file mode 100644 index 0000000..5923c52 --- /dev/null +++ b/Sources/Extensions/Foundation/Notification+Extensions.swift @@ -0,0 +1,7 @@ +import Foundation + +extension Notification { + var isCalendarDataChanged: Bool { + userInfo?["EKEventStoreCalendarDataChangedUserInfoKey"] as? Bool ?? false + } +} diff --git a/Sources/Extensions/Foundation/TimeInterval+Extensions.swift b/Sources/Extensions/Foundation/TimeInterval+Extensions.swift new file mode 100644 index 0000000..d87853d --- /dev/null +++ b/Sources/Extensions/Foundation/TimeInterval+Extensions.swift @@ -0,0 +1,17 @@ +import Foundation + +public extension TimeInterval { + static var second: Self { 1.seconds } + static var minute: Self { 1.minutes } + static var hour: Self { 1.hours } + static var day: Self { 1.days } + static var week: Self { 1.days * 7 } + static var year: Self { 1.weeks * 52 } + + var seconds: Self { self } + var minutes: Self { self * 60 } + var hours: Self { self.minutes * 60 } + var days: Self { self.hours * 24 } + var weeks: Self { self.days * 7 } + var years: Self { self.weeks * 52 } +} diff --git a/Sources/Extensions/StdLib/Array+Extensions.swift b/Sources/Extensions/StdLib/Array+Extensions.swift new file mode 100644 index 0000000..1e2213f --- /dev/null +++ b/Sources/Extensions/StdLib/Array+Extensions.swift @@ -0,0 +1,13 @@ +extension Array { + func appending(_ element: Element) -> Self { + var array = self + array.append(element) + return array + } + + func filterNot(_ keyPath: KeyPath) -> Self { + filter { element in + !element[keyPath: keyPath] + } + } +} diff --git a/Sources/Extensions/StdLib/Int+Extensions.swift b/Sources/Extensions/StdLib/Int+Extensions.swift new file mode 100644 index 0000000..e7d6250 --- /dev/null +++ b/Sources/Extensions/StdLib/Int+Extensions.swift @@ -0,0 +1,8 @@ +import Foundation + +public extension Int { + var seconds: Self { self } + var minutes: Self { self * 60 } + var hours: Self { self.minutes * 60 } + var days: Self { self.hours * 24 } +} diff --git a/Sources/Extensions/SwiftUI/Environment+Extensions.swift b/Sources/Extensions/SwiftUI/Environment+Extensions.swift new file mode 100644 index 0000000..f991826 --- /dev/null +++ b/Sources/Extensions/SwiftUI/Environment+Extensions.swift @@ -0,0 +1,8 @@ +import EventKit +import SwiftUI + +extension EnvironmentValues { + @Entry var eventStore = EKEventStore() + @Entry var calendarQueue = DispatchQueue(label: "com.evancooper.calendarqueue") + @Entry var dragHelper = DragHelper() +} diff --git a/Sources/Extensions/UIColor+Extensions.swift b/Sources/Extensions/UIColor+Extensions.swift deleted file mode 100644 index 9e9f3ca..0000000 --- a/Sources/Extensions/UIColor+Extensions.swift +++ /dev/null @@ -1,28 +0,0 @@ -import UIKit - -extension UIColor { - convenience init(red: Int, green: Int, blue: Int) { - assert(red >= 0 && red <= 255, "Invalid red component") - assert(green >= 0 && green <= 255, "Invalid green component") - assert(blue >= 0 && blue <= 255, "Invalid blue component") - - self.init(red: CGFloat(red) / 255.0, green: CGFloat(green) / 255.0, blue: CGFloat(blue) / 255.0, alpha: 1.0) - } - - convenience init(rgb: Int) { - self.init( - red: (rgb >> 16) & 0xFF, - green: (rgb >> 8) & 0xFF, - blue: rgb & 0xFF - ) - } - - var coreImageColor: CIColor { - return CIColor(color: self) - } - - var components: (red: CGFloat, green: CGFloat, blue: CGFloat, alpha: CGFloat) { - let coreImageColor = self.coreImageColor - return (coreImageColor.red, coreImageColor.green, coreImageColor.blue, coreImageColor.alpha) - } -} diff --git a/Sources/Extensions/UITextView+Extensions.swift b/Sources/Extensions/UITextView+Extensions.swift deleted file mode 100644 index d442682..0000000 --- a/Sources/Extensions/UITextView+Extensions.swift +++ /dev/null @@ -1,19 +0,0 @@ -import UIKit - -extension UITextView { - func centerTextVertically() { - var topCorrect = (self.bounds.size.height - self.contentSize.height * self.zoomScale) / 2 - topCorrect = (topCorrect < 0.0) ? 0.0 : topCorrect - self.contentInset.top = topCorrect - } - - func pushTextToTop() { - self.contentInset.top = 0.0 - } - - func removeTextInsets() { - textContainerInset = .init(top: 0, left: 5, bottom: 0, right: 5) - textContainer.lineFragmentPadding = 0 - } -} - diff --git a/Sources/Extensions/UIView+Extensions.swift b/Sources/Extensions/UIView+Extensions.swift deleted file mode 100644 index 4b47854..0000000 --- a/Sources/Extensions/UIView+Extensions.swift +++ /dev/null @@ -1,7 +0,0 @@ -import UIKit - -extension UIView { - class func fromNib() -> T { - Bundle.module.loadNibNamed(String(describing: T.self), owner: nil, options: nil)?.first as! T - } -} diff --git a/Sources/GestureRecognizers/ECWeekViewEventTapGestureRecognizer.swift b/Sources/GestureRecognizers/ECWeekViewEventTapGestureRecognizer.swift deleted file mode 100644 index 701b878..0000000 --- a/Sources/GestureRecognizers/ECWeekViewEventTapGestureRecognizer.swift +++ /dev/null @@ -1,12 +0,0 @@ -import UIKit - -final class ECWeekViewEventTapGestureRecognizer: UITapGestureRecognizer { - let event: ECWeekViewEvent - let eventView: UIView - - init(target: Any?, action: Selector?, event: ECWeekViewEvent, eventView: UIView) { - self.event = event - self.eventView = eventView - super.init(target: target, action: action) - } -} diff --git a/Sources/GestureRecognizers/ECWeekViewFreeTimeTapGestureRecognizer.swift b/Sources/GestureRecognizers/ECWeekViewFreeTimeTapGestureRecognizer.swift deleted file mode 100644 index 558bb2c..0000000 --- a/Sources/GestureRecognizers/ECWeekViewFreeTimeTapGestureRecognizer.swift +++ /dev/null @@ -1,11 +0,0 @@ -import UIKit -import SwiftDate - -final class ECWeekViewFreeTimeTapGestureRecognizer: UITapGestureRecognizer { - let date: DateInRegion? - - init(target: Any?, action: Selector?, date: DateInRegion?) { - self.date = date - super.init(target: target, action: action) - } -} diff --git a/Sources/Model/ECWeekViewEvent.swift b/Sources/Model/ECWeekViewEvent.swift deleted file mode 100644 index dc84d8b..0000000 --- a/Sources/Model/ECWeekViewEvent.swift +++ /dev/null @@ -1,45 +0,0 @@ -import Foundation -import SwiftDate - -public struct ECWeekViewEvent { - - public let uuid: String - public let title: String - public let subtitle: String - public let start: DateInRegion - public let end: DateInRegion - - public init(title: String, subtitle: String, start: DateInRegion, end: DateInRegion) { - uuid = UUID().uuidString - self.title = title - self.subtitle = subtitle - self.start = start - self.end = end - } - - func overlaps(with event: ECWeekViewEvent) -> Bool { - (start == event.start && end == event.end) || - (start > event.start && start < event.end) || - (end > event.start && end < event.end) || - (start < event.end && end > event.end) - } -} - -// MARK: - Hashable - -extension ECWeekViewEvent: Hashable {} - -// MARK: - Comparable - -extension ECWeekViewEvent: Comparable { - public static func < (lhs: ECWeekViewEvent, rhs: ECWeekViewEvent) -> Bool { - return lhs.start < rhs.start - } - - public static func == (lhs: ECWeekViewEvent, rhs: ECWeekViewEvent) -> Bool { - return lhs.title == rhs.title && - lhs.subtitle == rhs.subtitle && - lhs.start == rhs.start && - lhs.end == rhs.end - } -} diff --git a/Sources/Model/Theme.swift b/Sources/Model/Theme.swift deleted file mode 100644 index 469a68e..0000000 --- a/Sources/Model/Theme.swift +++ /dev/null @@ -1,50 +0,0 @@ -import UIKit - -public enum Theme { - case light, dark - - var baseColor: UIColor { - switch self { - case .light: - return UIColor(rgb: 0xffffff) - case .dark: - return UIColor(rgb: 0x373737) - } - } - - var hourLineColor: UIColor { - switch self { - case .light: - return UIColor(rgb: 0xe6e5e6) - case .dark: - return UIColor(rgb: 0x252525) - } - } - - var hourTextColor: UIColor { - switch self { - case .light: - return UIColor(rgb: 0x373737) - case .dark: - return UIColor(rgb: 0xc0c0c0) - } - } - - var eventTextColor: UIColor { - switch self { - case .light: - return UIColor(rgb: 0xfafafa) - case .dark: - return UIColor(rgb: 0xc0c0c0) - } - } - - var weekendColor: UIColor { - switch self { - case .light: - return UIColor(rgb: 0xf4f4f4) - case .dark: - return UIColor(rgb: 0x414141) - } - } -} diff --git a/Sources/Models/DropData.swift b/Sources/Models/DropData.swift new file mode 100644 index 0000000..5559e99 --- /dev/null +++ b/Sources/Models/DropData.swift @@ -0,0 +1,34 @@ +import CoreGraphics +import Foundation +import MobileCoreServices + +public final class DropData: NSObject, Codable, NSItemProviderWriting, NSItemProviderReading, Sendable { + + public static var readableTypeIdentifiersForItemProvider: [String] { [(kUTTypeData) as String] } + public static var writableTypeIdentifiersForItemProvider: [String] { [(kUTTypeData) as String] } + + public let dropAreaSize: CGSize + public let eventIdentifier: String + + public init(dropAreaSize: CGSize, eventIdentifier: String) { + self.dropAreaSize = dropAreaSize + self.eventIdentifier = eventIdentifier + } + + public func loadData(withTypeIdentifier typeIdentifier: String, forItemProviderCompletionHandler completionHandler: @escaping (Data?, Error?) -> Void) -> Progress? { + let progress = Progress(totalUnitCount: 1) + do { + let data = try JSONEncoder().encode(self) + progress.completedUnitCount = progress.totalUnitCount + completionHandler(data, nil) + } catch { + progress.completedUnitCount = progress.totalUnitCount + completionHandler(nil, error) + } + return progress + } + + public static func object(withItemProviderData data: Data, typeIdentifier: String) throws -> DropData { + try JSONDecoder().decode(DropData.self, from: data) + } +} diff --git a/Sources/Protocols/ECWeekViewDataSource.swift b/Sources/Protocols/ECWeekViewDataSource.swift deleted file mode 100644 index d6520b0..0000000 --- a/Sources/Protocols/ECWeekViewDataSource.swift +++ /dev/null @@ -1,22 +0,0 @@ -import Foundation -import UIKit -import SwiftDate - -/** - Used to delegate the creation of events for the WeekView - */ -public protocol ECWeekViewDataSource: class { - /** - Generate and return a set of events for a specific day. Events can be returned synchronously or asynchronously - - - Returns: - A collection of WeekViewEvents specific to the day of the provided date - - - Parameters: - - weekView: the WeekView that is calling this function - - date: the date for which to create events for - - - Important: Events that can be created immediately should be returned to this function. Events that require time to create should be passed to `eventCompletion`, which will overwrite previously returned events. - */ - func weekViewGenerateEvents(_ weekView: ECWeekView, date: DateInRegion, eventCompletion: @escaping ([ECWeekViewEvent]?) -> Void) -> [ECWeekViewEvent]? -} diff --git a/Sources/Protocols/ECWeekViewDelegate.swift b/Sources/Protocols/ECWeekViewDelegate.swift deleted file mode 100644 index 6042c43..0000000 --- a/Sources/Protocols/ECWeekViewDelegate.swift +++ /dev/null @@ -1,26 +0,0 @@ -import UIKit -import SwiftDate - -/** - Used to delegate events and actions that occur. - */ -public protocol ECWeekViewDelegate: class { - /** - Fires when a calendar event is touched on - - - parameters: - - weekView: the WeekView that is calling this function - - event: the event that was clicked - - view: the view that was clicked - */ - func weekViewDidClickOnEvent(_ weekView: ECWeekView, event: ECWeekViewEvent, view: UIView) - - /** - Fires when a space without an event is tapped - - - parameters: - - weekView: the WeekView that was tapped - - date: the date that was clicked. Accurate down to the minute. - */ - func weekViewDidClickOnFreeTime(_ weekView: ECWeekView, date: DateInRegion) -} diff --git a/Sources/Protocols/ECWeekViewStyler.swift b/Sources/Protocols/ECWeekViewStyler.swift deleted file mode 100644 index 0d7f186..0000000 --- a/Sources/Protocols/ECWeekViewStyler.swift +++ /dev/null @@ -1,29 +0,0 @@ -import UIKit -import SwiftDate - -/// Used to delegate the creation of different view types within the WeekView. -public protocol ECWeekViewStyler: class { - - /// The font used by WeekView - var font: UIFont { get } - - /// Bool indicating if each cell should have a header - var showsDateHeader: Bool { get } - - /// The height for a cell's header, if it's being shown - var dateHeaderHeight: CGFloat { get } - - /// Create the view for an event - /// - Parameters: - /// - weekView: the WeekView that the view will be added to - /// - eventContainer: the container of which the eventView needs to conform to - /// - event: the event it's self - func weekViewStylerECEventView(_ weekView: ECWeekView, eventContainer: CGRect, event: ECWeekViewEvent) -> UIView - - /// Create the header view for the day in the calendar. This would normally contain information about the date - /// - Parameters: - /// - weekView: the WeekView that the header will be added to - /// - date: the date to create the header view for - /// - cell: the cell that will have the header added to it - func weekViewStylerHeaderView(_ weekView: ECWeekView, with date: DateInRegion, in cell: UICollectionViewCell) -> UIView? -} diff --git a/Sources/Resources/ECEventView.xib b/Sources/Resources/ECEventView.xib deleted file mode 100644 index bd1298b..0000000 --- a/Sources/Resources/ECEventView.xib +++ /dev/null @@ -1,60 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Sources/Utility/DragHelper.swift b/Sources/Utility/DragHelper.swift new file mode 100644 index 0000000..b3bc0c2 --- /dev/null +++ b/Sources/Utility/DragHelper.swift @@ -0,0 +1,37 @@ +// +// File.swift +// ECWeekView +// +// Created by Evan Cooper on 2025-03-10. +// + +import Foundation + +final class DragHelper: Observable, ObservableObject { + + struct DraggedEventData { + let eventIdentifier: String + var location: CGPoint? + var date: Date + } + + @Published private(set) var draggedEventData: DraggedEventData? + + func begin(_ draggedEventData: DraggedEventData) { + print(#function, draggedEventData) + self.draggedEventData = draggedEventData + } + + func update(_ location: CGPoint) { + draggedEventData?.location = location + } + + func update(_ date: Date) { + print(#function, date) + draggedEventData?.date = date + } + + func end() { + draggedEventData = nil + } +} diff --git a/Sources/Views/Components/DayView.swift b/Sources/Views/Components/DayView.swift new file mode 100644 index 0000000..ac2dc8b --- /dev/null +++ b/Sources/Views/Components/DayView.swift @@ -0,0 +1,235 @@ +import ECKit +import EventKit +import EventKitUI +import SwiftUI + +struct DayView: View { + + private let date: Date + @State private var events = [EKEvent]() + + @Environment(\.eventStore) private var eventStore + @Environment(\.calendarQueue) private var calendarQueue + @EnvironmentObject private var dragHelper: DragHelper + + private let eventViewBuilder: (EKEvent) -> Event + + init( + day: Date, + @ViewBuilder eventViewBuilder: @escaping (EKEvent) -> Event + ) { + self.date = day + self.eventViewBuilder = eventViewBuilder + } + + var body: some View { + ZStack(alignment: .leading) { + VStack { + ForEach(0..<25) { hour in + VStack { + LinearGradient( + gradient: .init(colors: [Color(.lightGray), .clear]), + startPoint: .leading, + endPoint: .trailing + ) + .frame(height: 1) + .opacity(0.2) + Spacer() + } + } + } + + GeometryReader { proxy in + ZStack { + events(with: proxy) + if let draggedEventData = dragHelper.draggedEventData, + draggedEventData.date == date, + let location = draggedEventData.location, + let rawEvent = eventStore.event(withIdentifier: draggedEventData.eventIdentifier) { + let event = edited(event: rawEvent, for: location, in: proxy) + ECEventView(event: event) + .frame(width: proxy.size.width, height: height(for: event, with: proxy, ignoreDay: true)) + .offset(x: 0, y: startHourOffset(for: event, with: proxy)) + } + } + } + .contentShape(Rectangle()) + .clipped() + .onDrop(of: [.data], delegate: self) + } + .onAppearOnce { + subscribeToEvents() + } + } + + @ViewBuilder + private func events(with geometry: GeometryProxy) -> some View { + let eventsWithIdentifiers = events + .filterNot(\.isAllDay) + .compactMap { event -> (eventIdentifier: String, event: EKEvent)? in + guard let eventIdentifier = event.eventIdentifier else { return nil } + return (eventIdentifier, event) + } + + ForEach(eventsWithIdentifiers, id: \.eventIdentifier) { eventIdentifier, event in + eventViewBuilder(event) + .onDrag { + dragHelper.begin(.init( + eventIdentifier: eventIdentifier, + location: nil, + date: date + )) + return NSItemProvider(object: DropData(dropAreaSize: geometry.size, eventIdentifier: eventIdentifier)) + } preview: { + eventViewBuilder(event) + .frame( + width: width(for: event, with: geometry), + height: height(for: event, with: geometry) + ) + } + .frame( + width: width(for: event, with: geometry), + height: height(for: event, with: geometry) + ) + .offset( + x: xOffset(for: event, with: geometry), + y: startHourOffset(for: event, with: geometry) + ) + } + } + + private func edited(event: EKEvent, for location: CGPoint, in proxy: GeometryProxy) -> EKEvent { + let newStartTime = location.y / secondHeight(for: proxy.size.height) + let roundedNewStartTime = newStartTime.roundToNearest(CGFloat(15.minutes)) + let dateComponent = Calendar.current.dateComponents([.year, .month, .day, .timeZone], from: date) + let date = Calendar.current.date(from: dateComponent)! + let duration = event.endDate.timeIntervalSince1970 - event.startDate.timeIntervalSince1970 + + event.startDate = date.addingTimeInterval(TimeInterval(roundedNewStartTime)) + event.endDate = event.startDate.addingTimeInterval(duration) + + return event + } + + private func height(for event: EKEvent, with geometry: GeometryProxy, ignoreDay: Bool = false) -> CGFloat { + let isFromPreviousDay = ignoreDay ? false : !event.startDate.isSameDay(as: date) + let isToNextDay = ignoreDay ? false : !event.endDate.isSameDay(as: date) + let start = isFromPreviousDay ? 0 : event.startHour.hours + event.startMinute.minutes + let end = isToNextDay ? 2.days : event.endHour.hours + event.endMinute.minutes + return CGFloat(end - start) * secondHeight(for: geometry) + } + + private func width(for event: EKEvent, with geometry: GeometryProxy) -> CGFloat { + let overlappingEvents = events.overlappingEvents(against: event) + return geometry.size.width / CGFloat(overlappingEvents.count + 1) + } + + private func xOffset(for event: EKEvent, with geometry: GeometryProxy) -> CGFloat { + let events = events + .overlappingEvents(against: event) + .appending(event) + .sorted() + + let index = events.firstIndex(of: event) ?? 0 + + return CGFloat(index) * width(for: event, with: geometry) + } + + private func startHourOffset(for event: EKEvent, with geometry: GeometryProxy) -> CGFloat { + guard event.startDate.isSameDay(as: date) else { + return 0 + } + + let start = event.startHour.hours + event.startMinute.minutes + return CGFloat(start) * secondHeight(for: geometry) + } + + private func secondHeight(for geometry: GeometryProxy) -> CGFloat { + secondHeight(for: geometry.size.height) + } + + private func secondHeight(for height: CGFloat) -> CGFloat { + let hourHeight = height / 25 + let minuteHeight = hourHeight / 60 + return minuteHeight / 60 + } + + private func subscribeToEvents() { + NotificationCenter.default.addObserver(forName: .EKEventStoreChanged, object: nil, queue: .main) { notification in + guard notification.isCalendarDataChanged else { return } + Task { + await MainActor.run { + fetchEvents() + } + } + } + fetchEvents() + } + + private func fetchEvents() { + calendarQueue.sync { [eventStore] in + guard let start = Calendar.current.date(bySettingHour: 0, minute: 0, second: 0, of: date), + let end = Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: date) + else { + self.events = [] + return + } + let predicate = eventStore.predicateForEvents(withStart: start, end: end, calendars: nil) + let events = eventStore.events(matching: predicate) + self.events = events + } + } +} + +extension DayView: DropDelegate { + func performDrop(info: DropInfo) -> Bool { + for item in info.itemProviders(for: [.data]) { + item.loadObject(ofClass: DropData.self) { dropData, error in + guard let dropData = dropData as? DropData else { return } + let location = info.location + + Task { [location] in + await MainActor.run { [location] in + guard let event = eventStore.event(withIdentifier: dropData.eventIdentifier) else { return } + + let newStartTime = location.y / secondHeight(for: dropData.dropAreaSize.height) + let roundedNewStartTime = newStartTime.roundToNearest(CGFloat(15.minutes)) + let dateComponent = Calendar.current.dateComponents([.year, .month, .day, .timeZone], from: date) + let date = Calendar.current.date(from: dateComponent)! + let duration = event.endDate.timeIntervalSince1970 - event.startDate.timeIntervalSince1970 + + event.startDate = date.addingTimeInterval(TimeInterval(roundedNewStartTime)) + event.endDate = event.startDate.addingTimeInterval(duration) + + try? eventStore.save(event, span: .thisEvent) + } + } + } + } + dragHelper.end() + return true + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + dragHelper.update(info.location) + return .init(operation: .move) + } + + func dropEntered(info: DropInfo) { + dragHelper.update(date) + } +} + +struct DayView_Preview: PreviewProvider { + static var previews: some View { + HStack { + TimeView(visibleHours: 24) + DayView(day: .now) { event in + ECEventView(event: event) + } + } + .background(.white) + .padding() + .background(.red) + } +} diff --git a/Sources/Views/Components/EventEditView.swift b/Sources/Views/Components/EventEditView.swift new file mode 100644 index 0000000..d8941e8 --- /dev/null +++ b/Sources/Views/Components/EventEditView.swift @@ -0,0 +1,41 @@ +import EventKit +import EventKitUI +import SwiftUI + +struct EventEditView: UIViewControllerRepresentable { + typealias UIViewControllerType = EKEventEditViewController + + let event: EKEvent + let eventStore: EKEventStore + let delegate = EventEditViewDelegate() + + func makeUIViewController(context: Context) -> EKEventEditViewController { + let vc = EKEventEditViewController() + vc.event = event + vc.eventStore = eventStore + vc.editViewDelegate = delegate + return vc + } + + func updateUIViewController(_ uiViewController: EKEventEditViewController, context: Context) {} +} + +final class EventEditViewDelegate: NSObject, @preconcurrency EKEventEditViewDelegate { + @MainActor func eventEditViewController(_ controller: EKEventEditViewController, didCompleteWith action: EKEventEditViewAction) { + defer { controller.dismiss(animated: true) } + guard let event = controller.event else { return } + + do { + switch action { + case .deleted: + try controller.eventStore.remove(event, span: .thisEvent) + case .saved: + try controller.eventStore.save(event, span: .thisEvent) + default: + break + } + } catch { + print(error.localizedDescription) + } + } +} diff --git a/Sources/Views/Components/EventView.swift b/Sources/Views/Components/EventView.swift new file mode 100644 index 0000000..84eaef4 --- /dev/null +++ b/Sources/Views/Components/EventView.swift @@ -0,0 +1,72 @@ +import EventKit +import SwiftUI + +public struct ECEventView: View { + + let event: EKEvent + + public init(event: EKEvent) { + self.event = event + } + + @State private var presentEdit = false + @Environment(\.eventStore) private var eventStore + private var color: Color { Color(event.calendar.cgColor) } + + public var body: some View { + color + .opacity(0.2) + .overlay(alignment: .topLeading) { + HStack(alignment: .top) { + Capsule() + .foregroundStyle(color) + .width(4) + VStack(alignment: .leading) { + Text(event.title) + .font(.caption) + .foregroundColor(color) + .fontWeight(.semibold) + if let location = event.location { + Label(location, systemImage: .locationCircle) + .font(.caption2) + .foregroundColor(color) + } else { + Label { + Text(event.startDate ... event.endDate) + } icon: { + Image(systemName: .clock) + } + .font(.caption2) + .foregroundColor(color) + } + } + } + .padding(4) + } + .cornerRadius(3) + .onTapGesture { presentEdit.toggle() } + .sheet(isPresented: $presentEdit) { EventEditView(event: event, eventStore: eventStore) } + } +} + +struct EventView_Preview: PreviewProvider { + + private static var event: EKEvent { + let eventStore = EKEventStore() + let calendar = EKCalendar(for: .event, eventStore: eventStore) + calendar.cgColor = Color(.red).cgColor + + let event = EKEvent(eventStore: eventStore) + event.title = "Interview @Apple" + event.location = "Cupertino, CA" + event.startDate = Date() + event.endDate = Date().addingTimeInterval(1.hours) + event.calendar = calendar + return event + } + + static var previews: some View { + ECEventView(event: event) + .frame(width: 300, height: 150) + } +} diff --git a/Sources/Views/Components/TimeView.swift b/Sources/Views/Components/TimeView.swift new file mode 100644 index 0000000..5a77c2f --- /dev/null +++ b/Sources/Views/Components/TimeView.swift @@ -0,0 +1,45 @@ +import SwiftUI + +struct TimeView: View { + + let visibleHours: Int + + @State private var time: TimeInterval = 0 + + public var body: some View { + VStack(alignment: .trailing) { + ForEach(0..<25) { hour in + Text(stringForHour(hour)) + .font(.footnote) + .foregroundColor(Color(.gray)) + .id(hour.hours) + Spacer() + } + } + } + + private func secondHeight(for geometry: GeometryProxy) -> CGFloat { + geometry.size.height / 25 / 60 / 60 + } + + private func stringForHour(_ hour: Int) -> String { + let midnightToday = Calendar.current.date(bySettingHour: 0, minute: 0, second: 0, of: Date())! + let currentTime = Date().timeIntervalSince(midnightToday) + guard abs(hour.hours - Int(currentTime)) > 10.minutes else { return "" } + guard hour > 0 else { return "12 AM" } + guard hour < 24 else { return "12 AM" } + return "\(hour <= 12 ? hour : hour - 12) \(hour < 12 ? "AM" : "PM")" + } + + private func stringForCurrentTime() -> String { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "h:mm" + return dateFormatter.string(from: Date()) + } +} + +struct TimeView_Preivews: PreviewProvider { + static var previews: some View { + TimeView(visibleHours: 14) + } +} diff --git a/Sources/Views/ECDayCell.swift b/Sources/Views/ECDayCell.swift deleted file mode 100644 index a486e22..0000000 --- a/Sources/Views/ECDayCell.swift +++ /dev/null @@ -1,9 +0,0 @@ -import UIKit - -final class ECDayCell: UICollectionViewCell { - override func prepareForReuse() { - subviews.forEach { subview in - subview.removeFromSuperview() - } - } -} diff --git a/Sources/Views/ECEventView.swift b/Sources/Views/ECEventView.swift deleted file mode 100644 index ce26089..0000000 --- a/Sources/Views/ECEventView.swift +++ /dev/null @@ -1,57 +0,0 @@ -import Foundation -import UIKit - -final class ECEventView: UIView { - - private let boldAttribute: [NSAttributedString.Key: Any] = [NSAttributedString.Key.font: UIFont.boldSystemFont(ofSize: 11)] - private let regularAttribute: [NSAttributedString.Key: Any] = [NSAttributedString.Key.font: UIFont.systemFont(ofSize: 11)] - private let colorAttribute: [NSAttributedString.Key: Any] = [NSAttributedString.Key.foregroundColor: UIColor.white] - - private lazy var titleAttributes: [NSAttributedString.Key: Any] = { - var attributes = boldAttribute - colorAttribute.forEach({ (key: NSAttributedString.Key, value: Any) in - attributes[key] = value - }) - return attributes - }() - - private lazy var subtitleAttributes: [NSAttributedString.Key: Any] = { - var attributes = regularAttribute - colorAttribute.forEach({ (key: NSAttributedString.Key, value: Any) in - attributes[key] = value - }) - return attributes - }() - - var event: ECWeekViewEvent? { - didSet { - let titleAttributed = NSAttributedString(string: event!.title, attributes: titleAttributes) - let lineBreak = NSAttributedString(string: "\n", attributes: nil) - let subtitleAttributed = NSAttributedString(string: event!.subtitle, attributes: subtitleAttributes) - - let labelText = NSMutableAttributedString() - labelText.append(titleAttributed) - labelText.append(lineBreak) - labelText.append(subtitleAttributed) - - textView.textColor = .white - textView.attributedText = labelText - } - } - - @IBOutlet weak var textView: UITextView! - - override init(frame: CGRect) { - super.init(frame: frame) - commonInit() - } - - required init?(coder aDecoder: NSCoder) { - super.init(coder: aDecoder) - commonInit() - } - - private func commonInit() { - backgroundColor = backgroundColor?.withAlphaComponent(0.75) - } -} diff --git a/Sources/Views/ECWeekView.swift b/Sources/Views/ECWeekView.swift index 4e43760..a775a75 100644 --- a/Sources/Views/ECWeekView.swift +++ b/Sources/Views/ECWeekView.swift @@ -1,321 +1,151 @@ -import Foundation -import UIKit -import SwiftDate -import ECTimelineView - -@IBDesignable -public final class ECWeekView: UIView { - - // MARK: - Private properties - - private lazy var timeView: UIView = { - let view = UIView(frame: .zero) - view.translatesAutoresizingMaskIntoConstraints = false - view.backgroundColor = colorTheme.baseColor - return view - }() - - private lazy var timelineView: ECTimelineView<[ECWeekViewEvent], ECDayCell> = { - let timelineView = ECTimelineView<[ECWeekViewEvent], ECDayCell>() - timelineView.translatesAutoresizingMaskIntoConstraints = false - timelineView.backgroundColor = .clear - timelineView.scrollDirection = .horizontal - timelineView.timelineDataSource = self - return timelineView - }() - - private lazy var nowLine: CAShapeLayer = { - let now: DateInRegion = DateInRegion() - let linePath = UIBezierPath(rect: CGRect(x: timeView.frame.width, y: timeView.frame.origin.y + (hourHeight * CGFloat(now.hour - startHour)) + ((hourHeight/60) * CGFloat(now.minute)), width: timelineView.bounds.width, height: 0.1)) - let nowLine = CAShapeLayer() - nowLine.path = linePath.cgPath - nowLine.strokeColor = nowLineColor.cgColor - nowLine.fillColor = nowLineColor.cgColor - return nowLine - }() - - private var nowLinePath: CGPath { - UIBezierPath(rect: CGRect(x: nowLineCenter.x, y: nowLineCenter.y, width: timelineView.contentSize.width, height: 0.1)).cgPath - } - - private lazy var nowCircle: UIView = { - let view = UIView(frame: CGRect(x: 0, y: 0, width: 6, height: 6)) - view.layer.cornerRadius = 3 - view.backgroundColor = nowLineColor - view.clipsToBounds = true - return view - }() - - private var nowLineCenter: CGPoint { - let now = DateInRegion() - return CGPoint(x: timeView.frame.width, y: timeView.frame.origin.y + (hourHeight * CGFloat(now.hour - startHour)) + ((hourHeight/60) * CGFloat(now.minute))) - } - - private var hourHeight: CGFloat { - (frame.height - dateHeaderHeight) / CGFloat(endHour - startHour) - } - - private var minuteHeight: CGFloat { - hourHeight / 60 - } - - // MARK: - Public properties - - public weak var dataSource: ECWeekViewDataSource? { - didSet { timelineView.timelineDataSource = self } - } - - public weak var delegate: ECWeekViewDelegate? { - didSet { timelineView.reloadData() } - } - - public weak var styler: ECWeekViewStyler? { - didSet { timelineView.reloadData() } - } - - @IBInspectable public var visibleDays: Int = 5 { - didSet { timelineView.visibleCellCount = visibleDays } - } - - public var initDate: DateInRegion = DateInRegion() - public var startHour: Int = 9 - public var endHour: Int = 17 - public var nowLineEnabled: Bool = true - public var colorTheme: Theme = .light - public var nowLineColor: UIColor = .red - - // MARK: - Lifecycle - - public init(visibleDays: Int, date: DateInRegion = DateInRegion()) { - self.visibleDays = visibleDays - super.init(frame: .zero) - commonInit(frame: .zero, visibleDays: visibleDays, date: date) - } - - required init?(coder aDecoder: NSCoder) { - super.init(coder: aDecoder) - commonInit(frame: frame, visibleDays: visibleDays, date: DateInRegion()) - } - - public override func prepareForInterfaceBuilder() { - super.prepareForInterfaceBuilder() - commonInit(frame: frame, visibleDays: visibleDays, date: DateInRegion()) - } - - // MARK: - Private Methods - - private func commonInit(frame: CGRect, visibleDays: Int, date: DateInRegion) { - self.frame = frame - self.visibleDays = visibleDays - initDate = date - visibleDays.days - styler = self - - addSubview(timeView) - addSubview(timelineView) - - NSLayoutConstraint.activate([ - timeView.widthAnchor.constraint(equalToConstant: 40), - timeView.topAnchor.constraint(equalTo: topAnchor), - timeView.bottomAnchor.constraint(equalTo: bottomAnchor), - timeView.leftAnchor.constraint(equalTo: leftAnchor), - timeView.rightAnchor.constraint(equalTo: timelineView.leftAnchor), - timelineView.rightAnchor.constraint(equalTo: rightAnchor), - timelineView.topAnchor.constraint(equalTo: topAnchor), - timelineView.bottomAnchor.constraint(equalTo: bottomAnchor) - ]) - - addHourInfo() - insertNowLine() - } - - private func insertNowLine() { - DispatchQueue.global(qos: .userInteractive).async { [weak self] in - guard let self = self else { return } - while true { - if (self.nowLineEnabled) { - DispatchQueue.main.async { - self.nowLine.removeFromSuperlayer() - self.nowCircle.removeFromSuperview() - - let now = DateInRegion() - if (now.hour > self.startHour && now.hour < self.endHour) { - self.nowCircle.center = self.nowLineCenter - self.nowLine.path = self.nowLinePath - self.layer.addSublayer(self.nowLine) - self.addSubview(self.nowCircle) +import Combine +import EventKit +import ECScrollView +import SwiftUI +import SwiftUIX + +public struct ECWeekView: View { + + @StateObject private var viewModel: ECWeekViewModel + @State private var loaded = false + + private var eventViewBuilder: (EKEvent) -> Event + private var headerViewBuilder: (Date) -> Header + + private let startHour: Int + + public init( + visibleDays: Int = 3, + visibleHours: Int = 8, + startHour: Int = 9, + @ViewBuilder event: @escaping (EKEvent) -> Event, + @ViewBuilder header: @escaping (Date) -> Header + ) { + _viewModel = .init(wrappedValue: .init( + visibleDays: visibleDays, + visibleHours: visibleHours + )) + eventViewBuilder = event + headerViewBuilder = header + self.startHour = startHour + } + + // MARK: - Public Properties + + public var body: some View { + ScrollViewReader { scrollViewReader in + ScrollView(showsIndicators: false) { + HStack(spacing: 0) { + TimeView(visibleHours: viewModel.visibleHours) + .frame(width: 45) + .padding(.leading, 3) + ECScrollView(.horizontal, showsIndicators: false) { + LazyHStack(spacing: 0) { + ForEach(viewModel.days) { day in + DayView(day: day, eventViewBuilder: eventViewBuilder) + .containerRelativeFrame(.horizontal) { containerWidth, _ in + containerWidth / CGFloat(viewModel.visibleDays) + } + .id(day) + } } + .scrollTargetLayout() + } + .didEndDecelerating { offset, proxy in + viewModel.didEndDecelerating(offset, scrollViewProxy: proxy) } - sleep(15) - } else { - self.nowLine.removeFromSuperlayer() - self.nowCircle.removeFromSuperview() - break + .onContentOffsetChanged { offset, size, proxy in + if !loaded { + proxy.scrollTo( + viewModel.days[2], + anchor: .leading + ) + loaded.toggle() + } + + viewModel.contentOffsetChanged(offset, with: size, scrollViewSize: size, scrollViewProxy: proxy) + } + .scrollTargetBehavior(.viewAligned) + } + .containerRelativeFrame(.vertical) { containerHeight, _ in + let hourHeight = containerHeight / CGFloat(viewModel.visibleHours) + return hourHeight * 24.0 + } + .onAppearOnce { + scrollViewReader.scrollTo((startHour - 1).hours, anchor: .top) } } } + .environmentObject(DragHelper()) } - - private func addHourInfo() { - for hour in startHour...endHour { - let label = UILabel(frame: .zero) - label.translatesAutoresizingMaskIntoConstraints = false - label.text = "\(hour):00" - label.textAlignment = .right - label.backgroundColor = .clear - label.font = styler?.font ?? UIFont.init(descriptor: UIFontDescriptor(), size: 12) - label.textColor = colorTheme.hourTextColor - - let verticalOffset = dateHeaderHeight + hourHeight * CGFloat(hour - startHour) - - timeView.addSubview(label) - NSLayoutConstraint.activate([ - label.leftAnchor.constraint(equalTo: timeView.leftAnchor), - label.rightAnchor.constraint(equalTo: timeView.rightAnchor), - label.topAnchor.constraint(equalTo: timeView.topAnchor, constant: verticalOffset - (font.pointSize / 2)) - ]) +} - let hourLayer = CAShapeLayer() - hourLayer.strokeColor = colorTheme.hourLineColor.cgColor - hourLayer.fillColor = colorTheme.hourLineColor.cgColor - let linePath = UIBezierPath(rect: CGRect(x: timeView.bounds.maxX, y: verticalOffset, width: bounds.width - timeView.bounds.maxX, height: 0.1)) - hourLayer.path = linePath.cgPath - layer.addSublayer(hourLayer) +public extension ECWeekView where Event == AnyView { + init( + visibleDays: Int = 3, + visibleHours: Int = 8, + startHour: Int = 9, + @ViewBuilder header: @escaping (Date) -> Header + ) { + _viewModel = .init(wrappedValue: .init( + visibleDays: visibleDays, + visibleHours: visibleHours + )) + eventViewBuilder = { event in + AnyView(ECEventView(event: event)) } + headerViewBuilder = header + self.startHour = startHour } } -// MARK: - Placing events graphically - -extension ECWeekView { - private func placeEvents(_ events: [ECWeekViewEvent], in cell: UICollectionViewCell) -> [ECWeekViewEvent: CGRect] { - let threshold = 20 - - var mutableEvents = events.sorted() - var placedEvents = [ECWeekViewEvent]() - var placedEventRects = [ECWeekViewEvent: CGRect]() - - while !mutableEvents.isEmpty { - let eventsToPlace = mutableEvents.compactMap { event -> ECWeekViewEvent? in - return (event.start > mutableEvents.first!.start + threshold.minutes) ? nil : event - } - - var eventGroupRect = CGRect(x: 0, y: dateHeaderHeight, width: cell.bounds.width, height: cell.bounds.height - dateHeaderHeight) - placedEvents.reversed().forEach { placedEvent in - if let firstEventToPlace = eventsToPlace.first, let placedEventRect = placedEventRects[placedEvent], firstEventToPlace.overlaps(with: placedEvent) { - let groupRectHeight = cell.bounds.height - placedEventRect.minY - let overlapOffset: CGFloat = 5 - eventGroupRect = CGRect(x: placedEventRect.minX + overlapOffset, y: placedEventRect.minY, width: placedEventRect.width - overlapOffset, height: groupRectHeight) - } - } - - for (index, event) in eventsToPlace.enumerated() { - placedEventRects[event] = rect(for: event, in: eventGroupRect, overlapingEvents: eventsToPlace, widthIndex: index) - } - - mutableEvents.removeFirst(eventsToPlace.count) - placedEvents.append(contentsOf: eventsToPlace) +public extension ECWeekView where Header == AnyView { + init( + visibleDays: Int = 3, + visibleHours: Int = 8, + startHour: Int = 9, + @ViewBuilder event: @escaping (EKEvent) -> Event + ) { + _viewModel = .init(wrappedValue: .init( + visibleDays: visibleDays, + visibleHours: visibleHours + )) + eventViewBuilder = event + headerViewBuilder = { date in + AnyView(Text(date.formatted(date: .abbreviated, time: .omitted))) } - - return placedEventRects - } - - private func rect(for event: ECWeekViewEvent, in rect: CGRect, overlapingEvents: [ECWeekViewEvent], widthIndex: Int) -> CGRect { - let eventStartHour = event.start.hour - let eventStartMinute = event.start.minute - let eventEndHour = event.end.hour - let eventEndMinute = event.end.minute - let eventY = (hourHeight * CGFloat(eventStartHour - startHour)) + (minuteHeight * CGFloat(eventStartMinute)) + dateHeaderHeight - let eventHeight = (hourHeight * CGFloat(eventEndHour - eventStartHour)) + (minuteHeight * CGFloat(eventEndMinute - eventStartMinute)) - - let eventWidth = rect.width / CGFloat(overlapingEvents.count) - let eventX: CGFloat = rect.minX + (eventWidth * CGFloat(widthIndex)) - - let rectInset: CGFloat = 0.5 - return CGRect(x: eventX + rectInset, y: eventY + rectInset, width: eventWidth - (2 * rectInset), height: eventHeight - (2 * rectInset)) + self.startHour = startHour } } -// MARK: - Gesture recognizer selectors - -extension ECWeekView { - @objc private func handle(tap: UITapGestureRecognizer) { - if let tap = tap as? ECWeekViewEventTapGestureRecognizer { - delegate?.weekViewDidClickOnEvent(self, event: tap.event, view: tap.eventView) - } else if let tap = tap as? ECWeekViewFreeTimeTapGestureRecognizer { - let location = tap.location(in: tap.view).y - dateHeaderHeight - let hour = Int(floor(location / hourHeight)) + startHour - let minute = Int(floor((location - (CGFloat(hour - startHour) * hourHeight)) / minuteHeight)) - guard let date = tap.date?.dateBySet(hour: hour, min: minute, secs: 0) else { return } - delegate?.weekViewDidClickOnFreeTime(self, date: date) +public extension ECWeekView where Event == AnyView, Header == AnyView { + init( + visibleDays: Int = 3, + visibleHours: Int = 8, + startHour: Int = 9 + ) { + _viewModel = .init(wrappedValue: .init( + visibleDays: visibleDays, + visibleHours: visibleHours + )) + eventViewBuilder = { event in + AnyView(ECEventView(event: event)) } + headerViewBuilder = { date in + AnyView(Text(date.formatted(date: .abbreviated, time: .omitted))) + } + self.startHour = startHour } } -// MARK: - Default WeekViewStyler implementation - -extension ECWeekView: ECWeekViewStyler { - public var font: UIFont { - get { .init(descriptor: .init(), size: 11) } - } - - public var showsDateHeader: Bool { - get { true } - } - - public var dateHeaderHeight: CGFloat { - get { 20 } - } - - public func weekViewStylerECEventView(_ weekView: ECWeekView, eventContainer: CGRect, event: ECWeekViewEvent) -> UIView { - let weekViewECEventView: ECEventView = .fromNib() - weekViewECEventView.frame = eventContainer - weekViewECEventView.event = event - return weekViewECEventView - } - - public func weekViewStylerHeaderView(_ weekView: ECWeekView, with date: DateInRegion, in cell: UICollectionViewCell) -> UIView? { - guard showsDateHeader else { return nil } - let labelFrame = CGRect(x: 0, y: 0, width: cell.frame.width, height: dateHeaderHeight) - let label = UILabel(frame: labelFrame) - label.font = font - label.text = date.toFormat("EEE d") - label.textColor = .black - label.textAlignment = .center - return label +extension Date: @retroactive Identifiable { + public var id: String { + formatted(date: .complete, time: .complete) } } - -// MARK: - ECTimelineViewDataSource - -extension ECWeekView: ECTimelineViewDataSource { - public func timelineView(_ timelineView: ECTimelineView, dataFor index: Int, asyncClosure: @escaping (T?) -> Void) -> T? where U : UICollectionViewCell { - let viewDate = initDate + index.days - let events = dataSource?.weekViewGenerateEvents(self, date: viewDate, eventCompletion: { asyncEvents in - if let asyncEvents = asyncEvents as? T { - asyncClosure(asyncEvents) - } - }) - return events as? T - } - - public func configure(_ cell: U, withData data: T?) where U : UICollectionViewCell { - guard let data = data as? [ECWeekViewEvent] else { return } - let weekViewFreeTimeTapGestureRecognizer = ECWeekViewFreeTimeTapGestureRecognizer(target: self, action: #selector(handle(tap:)), date: data.first?.start) - cell.addGestureRecognizer(weekViewFreeTimeTapGestureRecognizer) - cell.backgroundColor = .clear - - if let date = data.first?.start, let cellHeader = styler?.weekViewStylerHeaderView(self, with: date, in: cell) { - cell.addSubview(cellHeader) - } - - let eventRects = placeEvents(data.sorted(), in: cell) - data.forEach { event in - if let eventRect = eventRects[event], let eventView = styler?.weekViewStylerECEventView(self, eventContainer: eventRect, event: event) { - let weekViewEventTapGestureRecognizer = ECWeekViewEventTapGestureRecognizer(target: self, action: #selector(handle(tap:)), event: event, eventView: eventView) - eventView.addGestureRecognizer(weekViewEventTapGestureRecognizer) - cell.addSubview(eventView) - } - } +struct ECWeekView_Previews: PreviewProvider { + static var previews: some View { + ECWeekView() } } diff --git a/Sources/Views/ECWeekViewModel.swift b/Sources/Views/ECWeekViewModel.swift new file mode 100644 index 0000000..d453c59 --- /dev/null +++ b/Sources/Views/ECWeekViewModel.swift @@ -0,0 +1,113 @@ +// +// File.swift +// ECWeekView +// +// Created by Evan Cooper on 2025-02-23. +// + +import Combine +import Foundation +import SwiftUI + +public final class ECWeekViewModel: ObservableObject { + + private enum LoadDirection { + case all, positive, negative + + var all: Bool { self == .all } + var positive: Bool { self == .positive } + var negative: Bool { self == .negative } + } + + // MARK: - Public Properties + + @Published public var visibleDays: Int + @Published public var visibleHours: Int + @Published public var days = [Date]() + + // MARK: - Private Properties + + private var negativeRefrenceDate: Date { + didSet { + setDays() + } + } + + private var positiveReferenceDate: Date { + didSet { + setDays() + } + } + + private var contentSize = CGSize.zero + private var scrollViewSize = CGSize.zero + + private var initialContentLoaded = false + + private var cancellables = Set() + + // MARK: - Lifecycle + + public init(visibleDays: Int, visibleHours: Int) { + + self.visibleDays = visibleDays + self.visibleHours = visibleHours + + let initialReferenceDate = Date.now + positiveReferenceDate = initialReferenceDate.addingTimeInterval(TimeInterval(visibleDays.days * 2)) + negativeRefrenceDate = initialReferenceDate.addingTimeInterval(TimeInterval(-visibleDays.days)) + setDays() + } + + // MARK: - Public Methods + + @MainActor + func contentOffsetChanged(_ contentOffset: CGPoint, with contentSize: CGSize, scrollViewSize: CGSize, scrollViewProxy: ScrollViewProxy) { + self.contentSize = contentSize + self.scrollViewSize = scrollViewSize + + if !initialContentLoaded { + initialContentLoaded.toggle() + + let middleDay = days[visibleDays] + DispatchQueue.main.asyncAfter(deadline: .now()) { + scrollViewProxy.scrollTo(middleDay, anchor: .leading) + } + } + } + + @MainActor + func didEndDecelerating(_ contentOffset: CGPoint, scrollViewProxy: ScrollViewProxy) { + guard !days.isEmpty else { return } + + if contentOffset.x <= 0 { + negativeRefrenceDate.addTimeInterval(-loadCount(for: .negative).days) + } else if contentOffset.x >= contentSize.width - scrollViewSize.width { + positiveReferenceDate.addTimeInterval(loadCount(for: .positive).days) + } + } + + // MARK: - Private Methods + + private func loadCount(for loadDirection: LoadDirection) -> Int { + switch loadDirection { + case .all: + return Int(negativeRefrenceDate.distance(to: positiveReferenceDate) / 1.days) + case .positive, .negative: + return visibleDays * 3 + } + } + + private func setDays() { + let start = negativeRefrenceDate.formatted(date: .abbreviated, time: .omitted) + let end = positiveReferenceDate.formatted(date: .abbreviated, time: .omitted) + print("date range \(start) - \(end)") + + self.days = stride( + from: negativeRefrenceDate.timeIntervalSince1970, + through: positiveReferenceDate.timeIntervalSince1970, + by: 1.days + ) + .map(Date.init(timeIntervalSince1970:)) + } +} diff --git a/Tests/Mocks/MockEKEventStore.swift b/Tests/Mocks/MockEKEventStore.swift new file mode 100644 index 0000000..b3db6f1 --- /dev/null +++ b/Tests/Mocks/MockEKEventStore.swift @@ -0,0 +1,29 @@ +import EventKit +import ECWeekView + +@objc +class MockEKEventStore: EKEventStore { + + var authorizationStatus = EKAuthorizationStatus.notDetermined + var authorized = false + + private(set) var didRequestAuthorization = false + private(set) var events = [EKEvent]() + + override func authorizationStatus(for entityType: EKEntityType) -> EKAuthorizationStatus { + return authorizationStatus + } + + override func requestAccess(to entityType: EKEntityType, completion: @escaping EKEventStoreRequestAccessCompletionHandler) { + didRequestAuthorization = true + completion(authorized, nil) + } + + override func events(matching predicate: NSPredicate) -> [EKEvent] { + events + } + + override func save(_ event: EKEvent, span: EKSpan) throws { + events.append(event) + } +} diff --git a/Tests/Tests/Extensions/CoreGraphics/CGFloatTests.swift b/Tests/Tests/Extensions/CoreGraphics/CGFloatTests.swift new file mode 100644 index 0000000..d4bb766 --- /dev/null +++ b/Tests/Tests/Extensions/CoreGraphics/CGFloatTests.swift @@ -0,0 +1,19 @@ +import CoreGraphics +import XCTest + +@testable import ECWeekView + +final class CGFloatTest: XCTestCase { + func testRoundToNearest() { + XCTAssertEqual(2.0.cgFloat.roundToNearest(10), 0) + XCTAssertEqual(4.9.cgFloat.roundToNearest(10), 0) + XCTAssertEqual(5.0.cgFloat.roundToNearest(10), 10) + XCTAssertEqual(6.0.cgFloat.roundToNearest(10), 10) + XCTAssertEqual(13.0.cgFloat.roundToNearest(10), 10) + XCTAssertEqual(17.0.cgFloat.roundToNearest(10), 20) + } +} + +private extension Double { + var cgFloat: CGFloat { CGFloat(self) } +} diff --git a/Tests/Tests/Extensions/EventKit/EKEventStoreTests.swift b/Tests/Tests/Extensions/EventKit/EKEventStoreTests.swift new file mode 100644 index 0000000..0a86073 --- /dev/null +++ b/Tests/Tests/Extensions/EventKit/EKEventStoreTests.swift @@ -0,0 +1,14 @@ +import EventKit +import XCTest + +@testable import ECWeekView + +final class EKEventStoreTests: XCTestCase { + func testThatAuthorizationStatusIsCorrect() { + let eventAuthorization = EKEventStore().authorizationStatus(for: .event) + let reminderAuthorization = EKEventStore().authorizationStatus(for: .reminder) + + XCTAssertEqual(eventAuthorization, EKEventStore.authorizationStatus(for: .event)) + XCTAssertEqual(reminderAuthorization, EKEventStore.authorizationStatus(for: .reminder)) + } +} diff --git a/Tests/Tests/Extensions/EventKit/EKEventTests.swift b/Tests/Tests/Extensions/EventKit/EKEventTests.swift new file mode 100644 index 0000000..e9b14bd --- /dev/null +++ b/Tests/Tests/Extensions/EventKit/EKEventTests.swift @@ -0,0 +1,38 @@ +import EventKit +import XCTest + +@testable import ECWeekView + +final class EKEventTests: XCTestCase { + + private var eventStore: MockEKEventStore! + + override func setUp() { + super.setUp() + eventStore = MockEKEventStore() + } + + func testThatStartHourIsCorrect() { + let event = EKEvent(eventStore: eventStore) + event.startDate = Calendar.current.date(bySetting: .hour, value: 1, of: Date()) + XCTAssertEqual(event.startHour, 1) + } + + func testThatStartMinuteIsCorrect() { + let event = EKEvent(eventStore: eventStore) + event.startDate = Calendar.current.date(bySetting: .minute, value: 1, of: Date()) + XCTAssertEqual(event.startMinute, 1) + } + + func testThatEndHourIsCorrect() { + let event = EKEvent(eventStore: eventStore) + event.endDate = Calendar.current.date(bySetting: .hour, value: 1, of: Date()) + XCTAssertEqual(event.endHour, 1) + } + + func testThatEndMinuteIsCorrect() { + let event = EKEvent(eventStore: eventStore) + event.endDate = Calendar.current.date(bySetting: .minute, value: 1, of: Date()) + XCTAssertEqual(event.endMinute, 1) + } +} diff --git a/Tests/Tests/Extensions/Foundation/DateTests.swift b/Tests/Tests/Extensions/Foundation/DateTests.swift new file mode 100644 index 0000000..180435c --- /dev/null +++ b/Tests/Tests/Extensions/Foundation/DateTests.swift @@ -0,0 +1,12 @@ +import XCTest + +@testable import ECWeekView + +final class DateTests: XCTestCase { + func testThatIsTodayIsCorrect() { + XCTAssertTrue(Date().isToday) + XCTAssertFalse(Date(timeIntervalSince1970: 0).isToday) + XCTAssertFalse(Date(timeIntervalSinceNow: 1.days).isToday) + XCTAssertFalse(Date(timeIntervalSinceNow: -1.days).isToday) + } +} diff --git a/Tests/Tests/Extensions/Foundation/NotificationTests.swift b/Tests/Tests/Extensions/Foundation/NotificationTests.swift new file mode 100644 index 0000000..5d9234b --- /dev/null +++ b/Tests/Tests/Extensions/Foundation/NotificationTests.swift @@ -0,0 +1,23 @@ +import Foundation +import XCTest + +@testable import ECWeekView + +final class NotificationTests: XCTestCase { + func testThatIsCalendarDataChangedIsCorrect() { + let n1 = Notification(name: .EKEventStoreChanged) + XCTAssertFalse(n1.isCalendarDataChanged) + + let n2 = Notification( + name: .EKEventStoreChanged, + userInfo: ["EKEventStoreCalendarDataChangedUserInfoKey": false] + ) + XCTAssertFalse(n2.isCalendarDataChanged) + + let n3 = Notification( + name: .EKEventStoreChanged, + userInfo: ["EKEventStoreCalendarDataChangedUserInfoKey": true] + ) + XCTAssertTrue(n3.isCalendarDataChanged) + } +} diff --git a/Tests/Tests/Extensions/Foundation/TimeIntervalTests.swift b/Tests/Tests/Extensions/Foundation/TimeIntervalTests.swift new file mode 100644 index 0000000..5a8db0e --- /dev/null +++ b/Tests/Tests/Extensions/Foundation/TimeIntervalTests.swift @@ -0,0 +1,56 @@ +import XCTest + +@testable import ECWeekView + +final class TimeIntervalTests: XCTestCase { + + func testThatSecondIsCorrect() { + XCTAssertEqual(TimeInterval.second, 1) + } + + func testThatMinuteIsCorrect() { + XCTAssertEqual(TimeInterval.minute, 60) + } + + func testThatHourIsCorrect() { + XCTAssertEqual(TimeInterval.hour, 3600) + } + + func testThatDayIsCorrect() { + XCTAssertEqual(TimeInterval.day, 86400) + } + + func testThatWeekIsCorrect() { + XCTAssertEqual(TimeInterval.week, 604800) + } + + func testThatSecondsIsCorrect() { + XCTAssertEqual(1.seconds, 1) + XCTAssertEqual(10.seconds, 10) + XCTAssertEqual(100.seconds, 100) + } + + func testThatMinutesIsCorrect() { + XCTAssertEqual(1.minutes, 60) + XCTAssertEqual(10.minutes, 600) + XCTAssertEqual(100.minutes, 6000) + } + + func testThatHoursIsCorrect() { + XCTAssertEqual(1.hours, 3600) + XCTAssertEqual(10.hours, 36000) + XCTAssertEqual(100.hours, 360000) + } + + func testThatDaysIsCorrect() { + XCTAssertEqual(1.days, 86400) + XCTAssertEqual(10.days, 864000) + XCTAssertEqual(100.days, 8640000) + } + + func testThatWeeksIsCorrect() { + XCTAssertEqual(1.weeks, 604800) + XCTAssertEqual(10.weeks, 6048000) + XCTAssertEqual(100.weeks, 60480000) + } +} diff --git a/Tests/Tests/Extensions/StdLib/ArrayTests.swift b/Tests/Tests/Extensions/StdLib/ArrayTests.swift new file mode 100644 index 0000000..ac7d6d0 --- /dev/null +++ b/Tests/Tests/Extensions/StdLib/ArrayTests.swift @@ -0,0 +1,9 @@ +import XCTest + +@testable import ECWeekView + +final class ArrayTests: XCTestCase { + func testThatAppendingIsCorrect() { + XCTAssertEqual([1].appending(2), [1, 2]) + } +} diff --git a/Tests/Tests/Extensions/StdLib/IntTests.swift b/Tests/Tests/Extensions/StdLib/IntTests.swift new file mode 100644 index 0000000..2782b34 --- /dev/null +++ b/Tests/Tests/Extensions/StdLib/IntTests.swift @@ -0,0 +1,30 @@ +import XCTest + +@testable import ECWeekView + +final class IntTests: XCTestCase { + + func testThatSecondsIsCorrect() { + XCTAssertEqual(1.seconds, 1) + XCTAssertEqual(10.seconds, 10) + XCTAssertEqual(100.seconds, 100) + } + + func testThatMinutesIsCorrect() { + XCTAssertEqual(1.minutes, 60) + XCTAssertEqual(10.minutes, 600) + XCTAssertEqual(100.minutes, 6000) + } + + func testThatHoursIsCorrect() { + XCTAssertEqual(1.hours, 3600) + XCTAssertEqual(10.hours, 36000) + XCTAssertEqual(100.hours, 360000) + } + + func testThatDaysIsCorrect() { + XCTAssertEqual(1.days, 86400) + XCTAssertEqual(10.days, 864000) + XCTAssertEqual(100.days, 8640000) + } +} diff --git a/Tests/Tests/Models/DropDataTests.swift b/Tests/Tests/Models/DropDataTests.swift new file mode 100644 index 0000000..eac3242 --- /dev/null +++ b/Tests/Tests/Models/DropDataTests.swift @@ -0,0 +1,30 @@ +import XCTest + +@testable import ECWeekView + +final class DropDataTests: XCTestCase { + + func testThatDataCanBeEncodedAndRead() { + let expected = DropData(dropAreaSize: .zero, eventIdentifier: #function) + + _ = expected.loadData(withTypeIdentifier: "") { data, error in + if let error = error { + XCTFail("Error is not nil: \(error.localizedDescription)") + return + } + + guard let data = data else { + XCTFail("Data is nil") + return + } + + do { + let decoded = try DropData.object(withItemProviderData: data, typeIdentifier: "") + XCTAssertEqual(decoded.dropAreaSize, expected.dropAreaSize) + XCTAssertEqual(decoded.eventIdentifier, expected.eventIdentifier) + } catch { + XCTFail(error.localizedDescription) + } + } + } +}