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)
+ }
+ }
+ }
+}