├── .spi.yml ├── Sources └── YCalendarPicker │ ├── Extensions │ ├── String+TextSize.swift │ ├── Date+dateOnly.swift │ ├── Date+dateWithTime.swift │ ├── String+toDate.swift │ ├── Date+TimeSinceDate.swift │ ├── Int+Modulo.swift │ ├── Date+toString.swift │ ├── DateFormatterCache.swift │ ├── Date+Compare.swift │ ├── DateFormatType.swift │ └── Date+CalendarDates.swift │ ├── Assets │ └── Strings │ │ └── en.lproj │ │ └── Localizable.strings │ ├── Protocols │ ├── CalendarPickerDelegate.swift │ └── CalendarViewDelegate.swift │ ├── Shared │ └── Constants.swift │ ├── SwiftUI │ ├── Observers │ │ ├── CalendarView+AppearanceObserver.swift │ │ └── CalendarView+DateObserver.swift │ └── Views │ │ ├── DaysView.swift │ │ ├── WeekdayView.swift │ │ ├── MonthView.swift │ │ ├── DayView.swift │ │ └── CalendarView.swift │ ├── Enums │ └── CalendarPicker+Strings.swift │ ├── UIKit │ ├── CalendarPicker+Appearance+Style.swift │ ├── Typography+CalendarPicker.swift │ ├── CalendarPicker+Appearance+Day.swift │ ├── CalendarPicker.swift │ └── CalendarPicker+Appearance.swift │ └── Model │ ├── CalendarMonthItem+DayAppearance.swift │ └── CalendarMonthItem.swift ├── .jazzy.yaml ├── Tests └── YCalendarPickerTests │ ├── Test Helpers │ ├── XCTestCase+MemoryLeakTracking.swift │ └── XCTestCase+TypographyTest.swift │ ├── Enums │ └── CalendarPicker+StringsTests.swift │ ├── Extensions │ ├── StringSizeTests.swift │ ├── Int+ModulusTests.swift │ ├── Date+onlyDateTests.swift │ ├── Date+dateWithTime.swift │ ├── TimeSinceDateTests.swift │ ├── CalendarMonthItem+appearance.swift │ ├── StringToDateTests.swift │ ├── DateFormatterCacheTests.swift │ ├── CompareDateUseCaseTests.swift │ ├── CalendarMonthDateUseCaseTests.swift │ └── DateToStringTests.swift │ ├── SwiftUI │ ├── WeekdayViewTests.swift │ ├── DaysViewTests.swift │ ├── MonthViewTests.swift │ ├── DayViewTests.swift │ └── CalendarViewTests.swift │ ├── UIKit │ ├── CalendarPickerAppearanceDayTests.swift │ ├── CalendarPicker+AppearanceTests.swift │ └── CalendarPickerTests.swift │ └── Model │ └── CalendarMonthItemTests.swift ├── Package.swift ├── .github ├── workflows │ ├── run_jazzy.yml │ └── run_linter_and_unit_tests.yml └── pull_request_template.md ├── .swiftlint.yml ├── .gitignore ├── .swiftpm └── xcode │ └── xcshareddata │ └── xcschemes │ └── YCalendarPicker.xcscheme ├── LICENSE └── README.md /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | external_links: 3 | documentation: "https://yml-org.github.io/ycalendarpicker-ios/" 4 | -------------------------------------------------------------------------------- /Sources/YCalendarPicker/Extensions/String+TextSize.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+TextSize.swift 3 | // YCalendarPicker 4 | // 5 | // Created by Sahil Saini on 05/12/22. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | 8 | import SwiftUI 9 | 10 | extension String { 11 | func size(of font: UIFont) -> CGSize { 12 | self.size(withAttributes: [NSAttributedString.Key.font: font]) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.jazzy.yaml: -------------------------------------------------------------------------------- 1 | author: 'Y Media Labs' 2 | author_url: https://yml.co 3 | min_acl: public 4 | hide_documentation_coverage: false 5 | theme: fullwidth 6 | output: ./docs 7 | documentation: ./*.md 8 | swift_build_tool: xcodebuild 9 | module: YCalendarPicker 10 | xcodebuild_arguments: 11 | - -scheme 12 | - YCalendarPicker 13 | - -sdk 14 | - iphonesimulator 15 | - -destination 16 | - 'platform=iOS Simulator,name=iPhone 13' 17 | -------------------------------------------------------------------------------- /Sources/YCalendarPicker/Assets/Strings/en.lproj/Localizable.strings: -------------------------------------------------------------------------------- 1 | /* 2 | Localizable.strings 3 | YCalendarPicker 4 | 5 | Created by Mark Pospesel on 1/13/23. 6 | Copyright © 2023 Y Media Labs. All rights reserved. 7 | */ 8 | 9 | "Previous_Month_Button_A11y_Label" = "Previous Month"; 10 | "Next_Month_Button_A11y_Label" = "Next Month"; 11 | 12 | "Day_Button_A11y_Hint" = "Double tap to select."; 13 | "Today_Day_Descriptor" = ". Today"; 14 | "Booked_Day_Descriptor" = ". Booked"; 15 | -------------------------------------------------------------------------------- /Sources/YCalendarPicker/Protocols/CalendarPickerDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CalendarPickerDelegate.swift 3 | // YCalendarPicker 4 | // 5 | // Created by Sahil Saini on 03/02/23. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | /// Protocol to observe changes in month 11 | public protocol CalendarPickerDelegate: AnyObject { 12 | /// Observe changes in month (Next/Previous). 13 | /// Called after the user changes the month. 14 | func calendarPicker(_ calendarPicker: CalendarPicker, didChangeMonthTo date: Date) 15 | } 16 | -------------------------------------------------------------------------------- /Sources/YCalendarPicker/Extensions/Date+dateOnly.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Date+dateOnly.swift 3 | // YCalendarPicker 4 | // 5 | // Created by Sahil Saini on 07/12/22. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | 8 | import Foundation 9 | 10 | /// Add `dateOnly` computed property 11 | extension Date { 12 | /// Returns a new `Date` representing the date calculated by setting hour, minute, and second 13 | /// to zero on a specified `Date` using the local time zone. 14 | public var dateOnly: Date { 15 | Calendar.current.date(bySettingHour: 0, minute: 0, second: 0, of: self) ?? self 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/YCalendarPicker/Shared/Constants.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Constants.swift 3 | // YCalendarPicker 4 | // 5 | // Created by Sahil on 31/10/22. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import SwiftUI 11 | import UIKit 12 | 13 | enum Constants { 14 | static let columns = [ 15 | GridItem(.flexible(), spacing: 4), 16 | GridItem(.flexible(), spacing: 4), 17 | GridItem(.flexible(), spacing: 4), 18 | GridItem(.flexible(), spacing: 4), 19 | GridItem(.flexible(), spacing: 4), 20 | GridItem(.flexible(), spacing: 4), 21 | GridItem(.flexible(), spacing: 4) 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /Tests/YCalendarPickerTests/Test Helpers/XCTestCase+MemoryLeakTracking.swift: -------------------------------------------------------------------------------- 1 | // 2 | // XCTestCase+MemoryLeakTracking.swift 3 | // YCarousel 4 | // 5 | // Created by Karthik K Manoj on 07/20/22. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | extension XCTestCase { 12 | func trackForMemoryLeak(_ instance: AnyObject, file: StaticString = #filePath, line: UInt = #line) { 13 | addTeardownBlock { [weak instance] in 14 | XCTAssertNil( 15 | instance, 16 | "Instance should have been deallocated. Potential memory leak.", 17 | file: file, 18 | line: line 19 | ) 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/YCalendarPicker/SwiftUI/Observers/CalendarView+AppearanceObserver.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CalendarView+AppearanceObserver.swift 3 | // YCalendarPicker 4 | // 5 | // Created by Sahil Saini on 29/11/22. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | // Observe changes in appearance. 12 | extension CalendarView { 13 | class AppearanceObserver: ObservableObject { 14 | @Published var appearance: CalendarPicker.Appearance 15 | 16 | /// Initializes an appearance (theme) observer. 17 | /// - Parameter appearance: appearance object 18 | init(appearance: CalendarPicker.Appearance = .default) { 19 | self.appearance = appearance 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/YCalendarPicker/Enums/CalendarPicker+Strings.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CalendarPicker+Strings.swift 3 | // YCalendarPicker 4 | // 5 | // Created by Mark Pospesel on 1/13/23. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import YCoreUI 11 | 12 | extension CalendarPicker { 13 | enum Strings: String, Localizable, CaseIterable { 14 | case previousMonthA11yLabel = "Previous_Month_Button_A11y_Label" 15 | case nextMonthA11yLabel = "Next_Month_Button_A11y_Label" 16 | case dayButtonA11yHint = "Day_Button_A11y_Hint" 17 | case todayDayDescriptor = "Today_Day_Descriptor" 18 | case bookedDayDescriptor = "Booked_Day_Descriptor" 19 | 20 | static var bundle: Bundle { .module } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/YCalendarPicker/Protocols/CalendarViewDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CalendarViewDelegate.swift 3 | // YCalendarPicker 4 | // 5 | // Created by Sahil Saini on 07/02/23. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | /// Protocol to observe changes in date(s) 11 | public protocol CalendarViewDelegate: AnyObject { 12 | /// Method for change in selected date. 13 | /// Called after the user changes the selection. 14 | /// - Parameter date: new selected date. 15 | func calendarViewDidSelectDate(_ date: Date?) 16 | /// Method for change in month. 17 | /// Called after the user changes the selection. 18 | /// - Parameter date: next/previous month(s) date from today 19 | func calendarViewDidChangeMonth(to date: Date) 20 | } 21 | -------------------------------------------------------------------------------- /Tests/YCalendarPickerTests/Enums/CalendarPicker+StringsTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CalendarPicker+StringsTests.swift 3 | // YCalendarPicker 4 | // 5 | // Created by Mark Pospesel on 1/13/23. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import YCoreUI 11 | @testable import YCalendarPicker 12 | 13 | final class CalendarPickerStringsTests: XCTestCase { 14 | func testLoad() { 15 | CalendarPicker.Strings.allCases.forEach { 16 | // Given a localized string constant 17 | let string = $0.localized 18 | // it should not be empty 19 | XCTAssertFalse(string.isEmpty) 20 | // and it should not equal its key 21 | XCTAssertNotEqual($0.rawValue, string) 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/YCalendarPicker/UIKit/CalendarPicker+Appearance+Style.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CalendarPicker+Appearance+Style.swift 3 | // YCalendarPicker 4 | // 5 | // Created by Sahil Saini on 09/02/23. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import YMatterType 11 | import UIKit 12 | 13 | extension CalendarPicker.Appearance { 14 | /// Appearance for weekday, month 15 | public enum DefaultStyles { 16 | /// Default value for weekday 17 | public static let weekday: (textColor: UIColor, typography: Typography) = ( 18 | CalendarPicker.Appearance.secondaryLabel, .weekday 19 | ) 20 | /// Default value for month 21 | public static let month: (textColor: UIColor, typography: Typography) = ( 22 | CalendarPicker.Appearance.tintColor, .month 23 | ) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/YCalendarPicker/Extensions/Date+dateWithTime.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Date+dateWithTime.swift 3 | // YCalendarPicker 4 | // 5 | // Created by Sahil Saini on 27/07/23. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | /// Add `dateWithTime` computed property 11 | extension Date { 12 | /// Returns a new `Date` representing the date calculated by setting hour, minute, and second 13 | /// to current values on a specified `Date` using the local time zone. 14 | public var dateWithTime: Date? { 15 | let currentDate = Date() 16 | let hours = Calendar.current.component(.hour, from: currentDate) 17 | let minutes = Calendar.current.component(.minute, from: currentDate) 18 | let seconds = Calendar.current.component(.second, from: currentDate) 19 | 20 | return Calendar.current.date(bySettingHour: hours, minute: minutes, second: seconds, of: self) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sources/YCalendarPicker/Extensions/String+toDate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+toDate.swift 3 | // YCalendarPicker 4 | // 5 | // Created by Sanjib Chakraborty on 20/06/22. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension String { 12 | /// Creates a new Date from a string with string format. 13 | /// - Parameters: 14 | /// - dateFormatType: The dateFormatType for conversion 15 | /// - timeZone: Optional timeZone 16 | /// - Returns: A date representation of string. If unable to parse the string, returns nil. 17 | func toDate( 18 | withFormatType dateFormatType: DateFormatType, 19 | timeZone: TimeZone? = nil 20 | ) -> Date? { 21 | let dateFormatter = DateFormatterCache.current.cachedDateFormatter( 22 | format: dateFormatType.stringFormat, 23 | timeZone: timeZone 24 | ) 25 | return dateFormatter.date(from: self) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/YCalendarPicker/Extensions/Date+TimeSinceDate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Date+TimeSinceDate.swift 3 | // 4 | // Created by Visakh Tharakan on 30/06/22. 5 | // Copyright © 2023 Y Media Labs. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | /// A date extension with helper function to get the time elapsed from a given point of time 11 | public extension Date { 12 | private static let relativeFormatter: RelativeDateTimeFormatter = { 13 | let formatter = RelativeDateTimeFormatter() 14 | formatter.unitsStyle = .full 15 | return formatter 16 | }() 17 | 18 | /// Returns the time elapsed in `String` from the given date to the relative date. 19 | /// - Parameter date: Reference date to calculate elapsed time. Default is `Date()`. 20 | /// - Returns: Time elapsed in `String`. 21 | func timeElapsed(relativeTo date: Date = Date()) -> String { 22 | Date.relativeFormatter.localizedString(for: self, relativeTo: date) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.5 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "YCalendarPicker", 7 | defaultLocalization: "en", 8 | platforms: [ 9 | .iOS(.v14) 10 | ], 11 | products: [ 12 | .library( 13 | name: "YCalendarPicker", 14 | targets: ["YCalendarPicker"] 15 | ) 16 | ], 17 | dependencies: [ 18 | .package( 19 | url: "https://github.com/yml-org/YCoreUI.git", 20 | from: "1.5.0" 21 | ), 22 | .package( 23 | url: "https://github.com/yml-org/YMatterType.git", 24 | from: "1.6.0" 25 | ) 26 | ], 27 | targets: [ 28 | .target( 29 | name: "YCalendarPicker", 30 | dependencies: ["YCoreUI", "YMatterType"], 31 | resources: [.process("Assets")] 32 | ), 33 | .testTarget( 34 | name: "YCalendarPickerTests", 35 | dependencies: ["YCalendarPicker"] 36 | ) 37 | ] 38 | ) 39 | -------------------------------------------------------------------------------- /Sources/YCalendarPicker/Model/CalendarMonthItem+DayAppearance.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CalendarMonthItem+DayAppearance.swift 3 | // YCalendarPicker 4 | // 5 | // Created by Sahil Saini on 09/02/23. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | extension CalendarMonthItem { 12 | func getDayAppearance(from appearance: CalendarPicker.Appearance) -> CalendarPicker.Appearance.Day { 13 | if isBooked { 14 | return appearance.bookedDayAppearance 15 | } else if !isEnabled { 16 | if isGrayedOut { 17 | return appearance.grayedDayAppearance 18 | } 19 | return appearance.disabledDayAppearance 20 | } else if isSelected { 21 | return appearance.selectedDayAppearance 22 | } else if isToday { 23 | return appearance.todayAppearance 24 | } else if isGrayedOut { 25 | return appearance.grayedDayAppearance 26 | } 27 | 28 | return appearance.normalDayAppearance 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /.github/workflows/run_jazzy.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: Run Jazzy 4 | 5 | # Controls when the workflow will run 6 | on: 7 | # Triggers the workflow on push events but only for the main branch 8 | push: 9 | branches: [ main ] 10 | 11 | # Allows you to run this workflow manually from the Actions tab 12 | workflow_dispatch: 13 | 14 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 15 | jobs: 16 | # This workflow contains a single job called "deploy_docs" 17 | deploy_docs: 18 | # The type of runner that the job will run on 19 | runs-on: macos-12 20 | 21 | # Steps represent a sequence of tasks that will be executed as part of the job 22 | steps: 23 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 24 | - uses: actions/checkout@v1 25 | - name: Publish Jazzy Docs 26 | uses: steven0351/publish-jazzy-docs@v1 27 | with: 28 | personal_access_token: ${{ secrets.ACCESS_TOKEN }} 29 | config: .jazzy.yaml 30 | -------------------------------------------------------------------------------- /.github/workflows/run_linter_and_unit_tests.yml: -------------------------------------------------------------------------------- 1 | name: Run linter and unit tests 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: macos-12 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | 20 | - name: Set Xcode version 21 | run: | 22 | ls -l /Applications | grep 'Xcode' 23 | sudo xcode-select -s /Applications/Xcode_14.0.1.app 24 | 25 | - name: Lint code using SwiftLint 26 | run: swiftlint --strict --reporter github-actions-logging 27 | 28 | - name: Build iOS 29 | run: | 30 | xcodebuild -scheme YCalendarPicker -sdk iphonesimulator16.0 -destination 'platform=iOS Simulator,name=iPhone 14' build-for-testing 31 | 32 | - name: Run tests iOS 33 | run: | 34 | xcodebuild -scheme YCalendarPicker -sdk iphonesimulator16.0 -destination 'platform=iOS Simulator,name=iPhone 14' test-without-building 35 | -------------------------------------------------------------------------------- /Sources/YCalendarPicker/Extensions/Int+Modulo.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Int+Modulo.swift 3 | // YCalendarPicker 4 | // 5 | // Created by Mark Pospesel on 1/19/23. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | 8 | import Foundation 9 | 10 | extension Int { 11 | /// Performs the modulus (not the remainder) operation on the receiver. 12 | /// 13 | /// `%` is the remainder operator in Swift and not the modulus operator. 14 | /// They return the same results for positive numbers, but they differ for negative numbers. 15 | /// 16 | /// -1 % 7 = -1 (remainder) 17 | /// 18 | /// -1 mod 7 = 6 (modulus) 19 | /// - Parameter divisor: the number to divide by 20 | /// - Returns: The modulus, whcih is guaranteed to be in the range of `0.. Int { 22 | // % is not a modulo operator in Swift 23 | // but rather a remainder operator. 24 | // The following expression ensures that 25 | // the return value is in the range 0.. WeekdayView { 44 | let sut = WeekdayView(appearance: .default, locale: locale ?? Locale.current) 45 | return sut 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Sources/YCalendarPicker/Extensions/Date+toString.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Date+toString.swift 3 | // YCalendarPicker 4 | // 5 | // Created by Sanjib Chakraborty on 20/06/22. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public extension Date { 12 | /// Converts the date to string based on a date format 13 | /// - Parameters: 14 | /// - dateFormatType: The dateFormatType for conversion 15 | /// - timeZone: Optional timeZone 16 | /// - Returns: The string representation of date 17 | func toString( 18 | withFormatType dateFormatType: DateFormatType, 19 | timeZone: TimeZone? = nil 20 | ) -> String? { 21 | let dateFormatter = DateFormatterCache.current.cachedDateFormatter( 22 | format: dateFormatType.stringFormat, 23 | timeZone: timeZone 24 | ) 25 | return dateFormatter.string(from: self) 26 | } 27 | 28 | /// Converts the date to string based on a provided template 29 | /// - Parameters: 30 | /// - template: The template string 31 | /// - locale: The locale to use. Pass `nil` (default) to use the current locale. 32 | /// - Returns: The localized string representation of the date 33 | func toString(withTemplate template: String, locale: Locale? = nil) -> String? { 34 | let dateFormatter = DateFormatterCache.current.cachedTemplateDateFormatter(template: template, locale: locale) 35 | return dateFormatter.string(from: self) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Tests/YCalendarPickerTests/UIKit/CalendarPickerAppearanceDayTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CalendarPickerAppearanceDayTests.swift 3 | // YCalendarPicker 4 | // 5 | // Created by Sahil Saini on 14/12/22. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import YCalendarPicker 11 | 12 | final class CalendarPickerAppearanceDayTests: XCTestCase { 13 | func testDefaultDayNotNil() { 14 | let sut = CalendarPicker.Appearance.Day() 15 | XCTAssertNotNil(sut.typography) 16 | XCTAssertNotNil(sut.foregroundColor) 17 | XCTAssertNotNil(sut.backgroundColor) 18 | XCTAssertNotNil(sut.borderColor) 19 | XCTAssertNotNil(sut.borderWidth) 20 | } 21 | 22 | func testDefaultDayValues() { 23 | let sut = CalendarPicker.Appearance.Day() 24 | XCTAssertTypographyEqual(sut.typography, .day) 25 | XCTAssertEqual(sut.foregroundColor, UIColor.label) 26 | XCTAssertEqual(sut.backgroundColor, UIColor.clear) 27 | XCTAssertEqual(sut.borderColor, UIColor.clear) 28 | XCTAssertEqual(sut.borderWidth, 1.0) 29 | } 30 | 31 | func testHiddenAppearance() { 32 | let sut = CalendarPicker.Appearance.Day(isHidden: true) 33 | XCTAssertTypographyEqual(sut.typography, .day) 34 | XCTAssertEqual(sut.foregroundColor, UIColor.clear) 35 | XCTAssertEqual(sut.backgroundColor, UIColor.clear) 36 | XCTAssertEqual(sut.borderColor, UIColor.clear) 37 | XCTAssertEqual(sut.borderWidth, 1.0) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Tests/YCalendarPickerTests/Extensions/Date+onlyDateTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Date+onlyDateTests.swift 3 | // YCalendarPicker 4 | // 5 | // Created by Mark Pospesel on 12/14/22. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import YCalendarPicker 11 | 12 | final class DateOnlyDateTests: XCTestCase { 13 | func testDateOnlyHasCorrectDate() throws { 14 | let date = try XCTUnwrap(makeDate()) 15 | let sut = date.dateOnly 16 | 17 | let comp1 = Calendar.current.dateComponents([.year, .month, .day], from: date) 18 | let comp2 = Calendar.current.dateComponents([.year, .month, .day], from: sut) 19 | 20 | XCTAssertEqual(comp1.year, comp2.year) 21 | XCTAssertEqual(comp1.month, comp2.month) 22 | XCTAssertEqual(comp1.day, comp2.day) 23 | } 24 | 25 | func testDateOnlyHasNoTime() throws { 26 | let date = try XCTUnwrap(makeDate()) 27 | let sut = date.dateOnly 28 | 29 | let components = Calendar.current.dateComponents([.hour, .minute, .second, .nanosecond], from: sut) 30 | 31 | XCTAssertEqual(components.hour, 0) 32 | XCTAssertEqual(components.minute, 0) 33 | XCTAssertEqual(components.second, 0) 34 | XCTAssertEqual(components.nanosecond, 0) 35 | } 36 | } 37 | 38 | private extension DateOnlyDateTests { 39 | func makeDate() -> Date? { 40 | Date() 41 | .date(byAddingMonth: Int.random(in: -6...6))? 42 | .date(byAddingDays: Int.random(in: -15...15)) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Tests/YCalendarPickerTests/SwiftUI/DaysViewTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DaysViewTests.swift 3 | // YCalendarPicker 4 | // 5 | // Created by Sahil on 15/11/22. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import SwiftUI 11 | @testable import YCalendarPicker 12 | 13 | final class DaysViewTests: XCTestCase { 14 | func testDayIsNotNil() throws { 15 | let sut = makeSUT() 16 | let startDate = Date().startDateOfMonth() 17 | let previousMonthDateCount = try XCTUnwrap(Calendar.current.dateComponents([.weekday], from: startDate).weekday) 18 | let dayCount = try XCTUnwrap(Int(Date().get(.day))) 19 | let index = previousMonthDateCount + dayCount 20 | let day = sut.getDay(index: index) 21 | XCTAssertNotNil(day) 22 | } 23 | 24 | func testDateBodyisNotNil() { 25 | let sut = makeSUT() 26 | XCTAssertNotNil(sut.body) 27 | } 28 | 29 | func testDateBodyPreviewisNotNil() { 30 | let sutPreview = DaysView_Previews.previews 31 | XCTAssertNotNil(sutPreview) 32 | } 33 | } 34 | 35 | private extension DaysViewTests { 36 | func makeSUT() -> DaysView { 37 | let allDates: [CalendarMonthItem] = Date().getAllDatesForSelectedMonth(firstWeekIndex: 0) 38 | let sut = DaysView( 39 | allDates: allDates, 40 | appearance: .default, 41 | selectedDate: .constant(Date()), 42 | locale: Locale.current, 43 | currentDate: Date() 44 | ) 45 | XCTAssertNotNil(sut.body) 46 | return sut 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Tests/YCalendarPickerTests/Model/CalendarMonthItemTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CalendarMonthItemTests.swift 3 | // YCalendarPicker 4 | // 5 | // Created by Mark Pospesel on 1/26/23. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import YCalendarPicker 11 | 12 | final class CalendarMonthItemTests: XCTestCase { 13 | func test_init_populatesComponents() throws { 14 | let now = try makeDate(for: "1947-01-08") 15 | let sut = now.toCalendarItem() 16 | 17 | XCTAssertEqual(sut.day, "8") 18 | XCTAssertEqual(sut.month, "1") 19 | XCTAssertEqual(sut.year, "1947") 20 | } 21 | 22 | func test_init_removesTime() { 23 | let now = Date() 24 | let sut = now.toCalendarItem() 25 | 26 | XCTAssertEqual(sut.date, now.dateOnly) 27 | } 28 | 29 | func test_isSelectable() { 30 | let now = Date() 31 | let enabledBooked = now.toCalendarItem(isEnabled: true, isBooked: true) 32 | let disabledBooked = now.toCalendarItem(isEnabled: false, isBooked: true) 33 | let enabledUnbooked = now.toCalendarItem(isEnabled: true, isBooked: false) 34 | let disabledUnbooked = now.toCalendarItem(isEnabled: false, isBooked: false) 35 | 36 | XCTAssertFalse(enabledBooked.isSelectable) 37 | XCTAssertFalse(disabledBooked.isSelectable) 38 | XCTAssertTrue(enabledUnbooked.isSelectable) 39 | XCTAssertFalse(disabledUnbooked.isSelectable) 40 | } 41 | } 42 | 43 | private extension CalendarMonthItemTests { 44 | func makeDate(for dateString: String) throws -> Date { 45 | let dateFormatter = DateFormatter() 46 | dateFormatter.dateFormat = "yyyy-MM-dd" 47 | 48 | return try XCTUnwrap(dateFormatter.date(from: dateString)) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Sources/YCalendarPicker/SwiftUI/Views/DaysView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DaysView.swift 3 | // YCalendarPicker 4 | // 5 | // Created by Sahil on 14/11/22. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | /// DaysView is the view shown for dates 12 | internal struct DaysView { 13 | var allDates: [CalendarMonthItem] 14 | var appearance: CalendarPicker.Appearance 15 | @Binding var selectedDate: Date? 16 | let locale: Locale 17 | let currentDate: Date 18 | } 19 | 20 | extension DaysView: View { 21 | var body: some View { 22 | getDaysView() 23 | } 24 | 25 | @ViewBuilder 26 | func getDaysView() -> some View { 27 | LazyVGrid(columns: Constants.columns, spacing: 0) { 28 | ForEach(0.. some View { 35 | var dateItem = allDates[index] 36 | dateItem.isSelected = dateItem.date == selectedDate 37 | var dayAppearance = dateItem.getDayAppearance(from: appearance) 38 | if !dateItem.date.isSameMonth(as: currentDate) { 39 | dayAppearance = appearance.grayedDayAppearance 40 | } 41 | let dayView = DayView( 42 | appearance: dayAppearance, 43 | dateItem: dateItem, 44 | locale: locale, 45 | selectedDate: $selectedDate 46 | ) 47 | return dayView 48 | } 49 | } 50 | 51 | struct DaysView_Previews: PreviewProvider { 52 | static var previews: some View { 53 | DaysView( 54 | allDates: Date().getAllDatesForSelectedMonth(firstWeekIndex: 0), 55 | appearance: .default, 56 | selectedDate: .constant(Date()), 57 | locale: Locale(identifier: "de_DE"), 58 | currentDate: Date() 59 | ) 60 | .padding(.horizontal, 16) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Tests/YCalendarPickerTests/Extensions/Date+dateWithTime.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Date+dateWithTime.swift 3 | // YCalendarPicker 4 | // 5 | // Created by Sahil Saini on 27/07/23. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import YCalendarPicker 11 | 12 | final class DateWithTime: XCTestCase { 13 | func testDateWithTimeHasCorrectDate() throws { 14 | let date = try XCTUnwrap(makeDate()) 15 | guard let sut = date.dateWithTime else { 16 | XCTFail("Unable to get date with time") 17 | return 18 | } 19 | 20 | let comp1 = Calendar.current.dateComponents([.year, .month, .day], from: date) 21 | let comp2 = Calendar.current.dateComponents([.year, .month, .day], from: sut) 22 | 23 | XCTAssertEqual(comp1.year, comp2.year) 24 | XCTAssertEqual(comp1.month, comp2.month) 25 | XCTAssertEqual(comp1.day, comp2.day) 26 | } 27 | 28 | func testDateOnlyHasNoTime() throws { 29 | let date = try XCTUnwrap(makeDate()) 30 | guard let sut = date.dateWithTime else { 31 | XCTFail("Unable to get date with time") 32 | return 33 | } 34 | let timeDate = Date() 35 | 36 | let components = Calendar.current.dateComponents([.hour, .minute, .second, .nanosecond], from: sut) 37 | let expectedComponents = Calendar.current.dateComponents([.hour, .minute, .second, .nanosecond], from: timeDate) 38 | 39 | XCTAssertEqual(components.hour, expectedComponents.hour) 40 | XCTAssertEqual(components.minute, expectedComponents.minute) 41 | XCTAssertEqual(components.second, expectedComponents.second) 42 | XCTAssertEqual(components.nanosecond, 0) 43 | } 44 | } 45 | 46 | private extension DateWithTime { 47 | func makeDate() -> Date? { 48 | Date() 49 | .date(byAddingMonth: Int.random(in: -6...6))? 50 | .date(byAddingDays: Int.random(in: -15...15)) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by MacOS 2 | .DS_Store 3 | 4 | # Xcode 5 | # 6 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 7 | 8 | ## User settings 9 | *.xcuserstate 10 | *.xcuserdatad 11 | *.xcuserdata 12 | xcschememanagement.plist 13 | */xcuserdata/* 14 | *.xcbkptlist 15 | *.xcworkspacedata 16 | IDEWorkspaceChecks.plist 17 | 18 | ## Obj-C/Swift specific 19 | *.hmap 20 | 21 | ## App packaging 22 | *.ipa 23 | *.dSYM.zip 24 | *.dSYM 25 | 26 | ## Playgrounds 27 | timeline.xctimeline 28 | playground.xcworkspace 29 | 30 | # Swift Package Manager 31 | # 32 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 33 | # Packages/ 34 | # Package.pins 35 | Package.resolved 36 | # *.xcodeproj 37 | # 38 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 39 | # hence it is not needed unless you have added a package configuration file to your project 40 | # .swiftpm 41 | 42 | .build/ 43 | 44 | # CocoaPods 45 | # 46 | # We recommend against adding the Pods directory to your .gitignore. However 47 | # you should judge for yourself, the pros and cons are mentioned at: 48 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 49 | # 50 | */Pods/* 51 | # 52 | # Add this line if you want to avoid checking in source code from the Xcode workspace 53 | # *.xcworkspace 54 | 55 | # Carthage 56 | # 57 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 58 | # Carthage/Checkouts 59 | 60 | Carthage/Build/ 61 | 62 | # Accio dependency management 63 | Dependencies/ 64 | .accio/ 65 | 66 | # fastlane 67 | # 68 | # It is recommended to not store the screenshots in the git repo. 69 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 70 | # For more information about the recommended setup visit: 71 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 72 | 73 | fastlane/report.xml 74 | fastlane/Preview.html 75 | fastlane/screenshots/**/*.png 76 | fastlane/test_output 77 | 78 | # Code Injection 79 | # 80 | # After new code Injection tools there's a generated folder /iOSInjectionProject 81 | # https://github.com/johnno1962/injectionforxcode 82 | 83 | iOSInjectionProject/ 84 | 85 | ## Docs 86 | /docs 87 | -------------------------------------------------------------------------------- /Sources/YCalendarPicker/SwiftUI/Views/WeekdayView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WeekdayView.swift 3 | // YCalendarPicker 4 | // 5 | // Created by Sahil on 14/11/22. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import YMatterType 11 | 12 | /// WeekdayView is the view shown for weekday names at top of days/dates 13 | internal struct WeekdayView { 14 | /// maximum scale factor of the week day text labels 15 | static let maximumScaleFactor: CGFloat = 1.33 16 | /// vertical padding around week day text labels 17 | static let verticalPadding: CGFloat = 2 18 | 19 | var firstWeekday: Int 20 | var appearance: CalendarPicker.Appearance 21 | let weekdayNames: [String] 22 | 23 | let locale: Locale 24 | 25 | init(firstWeekday: Int = 0, appearance: CalendarPicker.Appearance, locale: Locale) { 26 | self.firstWeekday = firstWeekday 27 | self.appearance = appearance 28 | self.locale = locale 29 | 30 | let dateFormetter = DateFormatter() 31 | dateFormetter.locale = locale 32 | 33 | weekdayNames = dateFormetter.shortWeekdaySymbols ?? ["🚨", "E", "R", "R", "O", "R", "!"] 34 | } 35 | } 36 | 37 | extension WeekdayView: View { 38 | var body: some View { 39 | getWeekdayView() 40 | .accessibilityHidden(true) 41 | } 42 | 43 | @ViewBuilder 44 | func getWeekdayView() -> some View { 45 | LazyVGrid(columns: Constants.columns, spacing: 0) { 46 | ForEach(firstWeekday...6 + firstWeekday, id: \.self) { index in 47 | getWeekText(for: index.modulo(7)) 48 | } 49 | } 50 | .padding(.vertical, WeekdayView.verticalPadding) 51 | } 52 | 53 | @ViewBuilder 54 | func getWeekText(for index: Int) -> some View { 55 | TextStyleLabel(weekdayNames[index], typography: appearance.weekdayStyle.typography, configuration: { label in 56 | label.textAlignment = .center 57 | label.maximumScaleFactor = WeekdayView.maximumScaleFactor 58 | label.textColor = appearance.weekdayStyle.textColor 59 | }) 60 | } 61 | } 62 | 63 | struct WeekdayView_Previews: PreviewProvider { 64 | static var previews: some View { 65 | WeekdayView(firstWeekday: 0, appearance: .default, locale: .current) 66 | .padding(.horizontal, 16) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Tests/YCalendarPickerTests/Extensions/TimeSinceDateTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TimeSinceDateTests.swift 3 | // YCalendarPicker 4 | // 5 | // Created by Visakh Tharakan on 30/06/22. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import Foundation 11 | import YCalendarPicker 12 | 13 | final class TimeSinceDateTests: XCTestCase { 14 | func test_timeElapsed_deliversStringInSecondsAgo() { 15 | let sut = makeSUT(with: "2022-06-30 - 13:18:00") 16 | XCTAssertEqual(sut.timeElapsed(relativeTo: makeDate(with: "2022-06-30 - 13:18:53")), "53 seconds ago") 17 | } 18 | 19 | func test_timeElapsed_deliversStringInMinutesAgo() { 20 | let sut = makeSUT(with: "2022-06-30 - 13:18:00") 21 | XCTAssertEqual(sut.timeElapsed(relativeTo: makeDate(with: "2022-06-30 - 13:32:53")), "14 minutes ago") 22 | } 23 | 24 | func test_timeElapsed_deliversStringInHoursAgo() { 25 | let sut = makeSUT(with: "2022-06-30 - 13:18:00") 26 | XCTAssertEqual(sut.timeElapsed(relativeTo: makeDate(with: "2022-06-30 - 16:32:53")), "3 hours ago") 27 | } 28 | 29 | func test_timeElapsed_deliversStringInDaysAgo() { 30 | let sut = makeSUT(with: "2022-06-30 - 13:18:00") 31 | XCTAssertEqual(sut.timeElapsed(relativeTo: makeDate(with: "2022-07-02 - 16:32:53")), "2 days ago") 32 | } 33 | 34 | func test_timeElapsed_deliversStringInWeeksAgo() { 35 | let sut = makeSUT(with: "2022-06-30 - 13:18:00") 36 | XCTAssertEqual(sut.timeElapsed(relativeTo: makeDate(with: "2022-07-15 - 16:32:53")), "2 weeks ago") 37 | } 38 | 39 | func test_timeElapsed_deliversStringInMonthsAgo() { 40 | let sut = makeSUT(with: "2022-06-30 - 13:18:00") 41 | XCTAssertEqual(sut.timeElapsed(relativeTo: makeDate(with: "2022-08-07 - 16:32:53")), "1 month ago") 42 | } 43 | 44 | func test_timeElapsed_deliversStringInYearsAgo() { 45 | let sut = makeSUT(with: "2019-06-30 - 13:18:00") 46 | XCTAssertEqual(sut.timeElapsed(relativeTo: makeDate(with: "2022-08-07 - 16:32:53")), "3 years ago") 47 | } 48 | } 49 | 50 | private extension TimeSinceDateTests { 51 | func makeSUT(with dateString: String) -> Date { 52 | makeDate(with: dateString) 53 | } 54 | 55 | func makeDate(with dateString: String) -> Date { 56 | let dateFormatter = DateFormatter() 57 | dateFormatter.dateFormat = "yyyy-MM-dd - HH:mm:ss" 58 | 59 | guard let date = dateFormatter.date(from: dateString) else { 60 | XCTFail("Expected to create date, but failed.") 61 | return Date() 62 | } 63 | return date 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Tests/YCalendarPickerTests/Extensions/CalendarMonthItem+appearance.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CalendarMonthItemAppearance.swift 3 | // YCalendarTest 4 | // 5 | // Created by Sahil Saini on 18/08/23. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import YCalendarPicker 11 | 12 | final class CalendarMonthItemAppearance: XCTestCase { 13 | func testDateTextAppearanceForSelectedDate() { 14 | let sut = makeSUT(isSelected: true) 15 | XCTAssertAppearanceEqual(appearance1: sut, appearance2: .Defaults.selected) 16 | } 17 | 18 | func testDateTextAppearanceForToday() { 19 | let sut = makeSUT(isToday: true) 20 | XCTAssertAppearanceEqual(appearance1: sut, appearance2: .Defaults.today) 21 | } 22 | 23 | func testDateTextAppearanceForBookedDate() { 24 | let sut = makeSUT(isBooked: true) 25 | XCTAssertAppearanceEqual(appearance1: sut, appearance2: .Defaults.booked) 26 | } 27 | 28 | func testDateTextAppearanceForGrayedDate() { 29 | let sut = makeSUT(isGrayedOut: true, dateToTest: Date().previousDate()) 30 | XCTAssertAppearanceEqual(appearance1: sut, appearance2: .Defaults.grayed) 31 | } 32 | 33 | func testDateTextAppearanceForDisabledGrayedDate() { 34 | let sut = makeSUT(isGrayedOut: true, isEnabled: false, dateToTest: Date().previousDate()) 35 | XCTAssertAppearanceEqual(appearance1: sut, appearance2: .Defaults.grayed) 36 | } 37 | 38 | func testDateTextAppearanceForDisabledDate() { 39 | let sut = makeSUT(isEnabled: false, dateToTest: Date().previousDate()) 40 | XCTAssertAppearanceEqual(appearance1: sut, appearance2: .Defaults.disabled) 41 | } 42 | } 43 | 44 | extension CalendarMonthItemAppearance { 45 | func makeSUT( 46 | isToday: Bool = false, 47 | isGrayedOut: Bool = false, 48 | isSelected: Bool = false, 49 | isEnabled: Bool = true, 50 | dateToTest: Date = Date(), 51 | isBooked: Bool = false 52 | ) -> CalendarPicker.Appearance.Day { 53 | let dateItem = dateToTest.toCalendarItem( 54 | isGrayedOut: isGrayedOut, 55 | isSelected: isSelected, 56 | isEnabled: isEnabled, 57 | isBooked: isBooked 58 | ) 59 | 60 | return dateItem.getDayAppearance(from: .default) 61 | } 62 | 63 | func XCTAssertAppearanceEqual( 64 | appearance1: CalendarPicker.Appearance.Day, 65 | appearance2: CalendarPicker.Appearance.Day 66 | ) { 67 | XCTAssertTypographyEqual(appearance1.typography, appearance2.typography) 68 | XCTAssertEqual(appearance1.borderWidth, appearance2.borderWidth) 69 | XCTAssertEqual(appearance1.borderColor, appearance2.borderColor) 70 | XCTAssertEqual(appearance1.backgroundColor, appearance2.backgroundColor) 71 | XCTAssertEqual(appearance1.foregroundColor, appearance2.foregroundColor) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Sources/YCalendarPicker/Model/CalendarMonthItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CalendarMonthItem.swift 3 | // YCalendarPicker 4 | // 5 | // Created by Parv Bhaskar on 20/06/22. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// Information about a single day in a month. 12 | /// Intended as an aid for rendering a month calendar view. 13 | public struct CalendarMonthItem: Equatable { 14 | /// The day of the month (e.g. "15") 15 | public let day: String 16 | 17 | /// The numeric month (e.g. "6") 18 | public let month: String 19 | 20 | /// The numeric year (e.g. "2022") 21 | public let year: String 22 | 23 | /// Date (without a time component) 24 | public let date: Date 25 | 26 | /// Whether `date` lies outside of the current month. 27 | /// (e.g. In June 2022, dates from 29th May to 31st May and 1st July to 9th july) 28 | public let isGrayedOut: Bool 29 | 30 | /// Whether `date` is today. 31 | /// (e.g. current date is 21st June 2022 then this value is true for that date only) 32 | public let isToday: Bool 33 | 34 | /// Optional additional notes 35 | public let note: String? 36 | 37 | /// Whether the date is selected 38 | public var isSelected: Bool 39 | 40 | /// Whether the date should be enabled. 41 | /// 42 | /// Set to `false` if `date` falls outside the range of valid dates (e.g. not allowing past dates). 43 | public var isEnabled: Bool 44 | 45 | /// Whether the date has already been booked. 46 | /// 47 | /// A booked date is not selectable in the month calendar. 48 | public var isBooked: Bool 49 | 50 | /// Initializes a calendar month item. 51 | /// - Parameters: 52 | /// - date: date (any time component will be removed) 53 | /// - isGrayedOut: whether `date` lies outside of the current month 54 | /// - note: optional additional notes 55 | /// - isSelected: whether the date is selected 56 | /// - isEnabled: whether the date should be enabled 57 | /// - isBooked: whether the date has already been booked 58 | public init( 59 | date: Date, 60 | isGrayedOut: Bool, 61 | note: String? = nil, 62 | isSelected: Bool = false, 63 | isEnabled: Bool = true, 64 | isBooked: Bool = false 65 | ) { 66 | self.day = date.get(.day) 67 | self.month = date.get(.month) 68 | self.year = date.get(.year) 69 | self.date = date.dateOnly 70 | self.isGrayedOut = isGrayedOut 71 | self.isToday = date.isToday 72 | self.note = note 73 | self.isSelected = isSelected 74 | self.isEnabled = isEnabled 75 | self.isBooked = isBooked 76 | } 77 | } 78 | 79 | extension CalendarMonthItem { 80 | /// Returns `true` if the date can be selected in a month calendar, otherwise `false`. 81 | public var isSelectable: Bool { isEnabled && !isBooked } 82 | } 83 | -------------------------------------------------------------------------------- /Sources/YCalendarPicker/Extensions/DateFormatterCache.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DateFormatterCache.swift 3 | // YCalendarPicker 4 | // 5 | // Created by Sanjib Chakraborty on 06/07/22. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// Date formatter cache 12 | final class DateFormatterCache { 13 | /// Singleton object for date formatter cache 14 | static let current = DateFormatterCache() 15 | 16 | /// Cached date format in a dictionary using key. 17 | /// The key is combination of hash value of format, timeZone and locale. 18 | internal var cachedDateFormatters = [String: DateFormatter]() 19 | 20 | /// Save a date formatter into the cache 21 | /// - Parameters: 22 | /// - dateFormatter: Date formatter to save into the cache 23 | /// - key: Key used to save the formatter 24 | private func register(dateFormatter: DateFormatter, key: String) { 25 | cachedDateFormatters[key] = dateFormatter 26 | } 27 | 28 | /// Retrieve a date formatter from cache 29 | /// - Parameter key: Formatter key used for saving into the cache 30 | /// - Returns: A cached date formatter 31 | private func retrieveDateFormatter(for key: String) -> DateFormatter? { 32 | cachedDateFormatters[key] 33 | } 34 | 35 | /// Create or retrieve a cached formatter based on the provided format. Formatters are cached in a dictionary. 36 | /// - Parameters: 37 | /// - format: The format for which we want 38 | /// - timeZone: The time zone to use. Pass `nil` (default) to use the current time zone. 39 | /// - Returns: a dateFormatter that is newly created or retrieve if cached 40 | func cachedDateFormatter( 41 | format: String, 42 | timeZone: TimeZone? = nil 43 | ) -> DateFormatter { 44 | let formatterKey = "\(format)\(timeZone?.hashValue ?? 0)" 45 | if let formatter = retrieveDateFormatter(for: formatterKey) { 46 | return formatter 47 | } 48 | 49 | let formatter = DateFormatter() 50 | formatter.dateFormat = format 51 | if let timeZone = timeZone { 52 | formatter.timeZone = timeZone 53 | } 54 | register(dateFormatter: formatter, key: formatterKey) 55 | return formatter 56 | } 57 | 58 | /// Create or retrieve a cached formatter based on the provided template. Formatters are cached in a dictionary. 59 | /// - Parameters: 60 | /// - template: The template string 61 | /// - locale: The locale to use. Pass `nil` (default) to use the current locale. 62 | /// - Returns: a dateFormatter that is newly created or retrieve if cached 63 | func cachedTemplateDateFormatter(template: String, locale: Locale? = nil) -> DateFormatter { 64 | let locale = locale ?? .current 65 | let formatterKey = "template\(template)\(locale.identifier)" 66 | if let formatter = retrieveDateFormatter(for: formatterKey) { 67 | return formatter 68 | } 69 | 70 | let formatter = DateFormatter() 71 | formatter.locale = locale 72 | formatter.setLocalizedDateFormatFromTemplate(template) 73 | register(dateFormatter: formatter, key: formatterKey) 74 | return formatter 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Sources/YCalendarPicker/UIKit/CalendarPicker+Appearance+Day.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CalendarPicker+Appearance+Day.swift 3 | // YCalendarPicker 4 | // 5 | // Created by Sahil Saini on 14/12/22. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import YMatterType 11 | 12 | extension CalendarPicker.Appearance { 13 | /// Appearance for Date 14 | public struct Day { 15 | /// Typography for day view 16 | public var typography: Typography 17 | /// Foreground color for day view 18 | public var foregroundColor: UIColor 19 | /// Background color for day view 20 | public var backgroundColor: UIColor 21 | /// Border color for day view 22 | public var borderColor: UIColor 23 | /// Border width for day view 24 | public var borderWidth: CGFloat 25 | /// Hides day view (if true) 26 | public var isHidden: Bool 27 | 28 | /// Initializes a calendar day appearance 29 | /// - Parameters: 30 | /// - typography: Typography for day view. Default is `Typography.day`. 31 | /// - foregroundColor: Foreground color for day view. Default is `.label`. 32 | /// - backgroundColor: Background color for day view. Default is `.clear`. 33 | /// - borderColor: Border color for day view. Default is `.clear`. 34 | /// - borderWidth: Border width for day view. Default is `1.0`. 35 | /// - isHidden: Hides day(s). Default is `false`. 36 | public init( 37 | typography: Typography = .day, 38 | foregroundColor: UIColor = .label, 39 | backgroundColor: UIColor = .clear, 40 | borderColor: UIColor = .clear, 41 | borderWidth: CGFloat = 1.0, 42 | isHidden: Bool = false 43 | ) { 44 | if isHidden { 45 | self = Defaults.hidden 46 | self.isHidden = isHidden 47 | } else { 48 | self.typography = typography 49 | self.foregroundColor = foregroundColor 50 | self.backgroundColor = backgroundColor 51 | self.borderColor = borderColor 52 | self.borderWidth = borderWidth 53 | self.isHidden = isHidden 54 | } 55 | } 56 | } 57 | } 58 | 59 | extension CalendarPicker.Appearance.Day { 60 | /// Default Day appearances 61 | public enum Defaults { 62 | /// Default appearance for days within the current month 63 | public static let normal = CalendarPicker.Appearance.Day() 64 | /// Default appearance for days outside of the current month 65 | public static let grayed = CalendarPicker.Appearance.Day( 66 | foregroundColor: CalendarPicker.Appearance.secondaryLabel 67 | ) 68 | /// Default appearance for days outside enabled range 69 | public static let disabled = CalendarPicker.Appearance.Day( 70 | foregroundColor: CalendarPicker.Appearance.quaternaryLabel 71 | ) 72 | /// Default appearance for already booked day 73 | public static let booked = CalendarPicker.Appearance.Day( 74 | foregroundColor: CalendarPicker.Appearance.onBookedColor, 75 | backgroundColor: CalendarPicker.Appearance.bookedColor 76 | ) 77 | /// Default appearance for today (unless selected) 78 | public static let today = CalendarPicker.Appearance.Day( 79 | foregroundColor: CalendarPicker.Appearance.tintColor, 80 | borderColor: CalendarPicker.Appearance.tintColor 81 | ) 82 | /// Default appearance for currently selected day 83 | public static let selected = CalendarPicker.Appearance.Day( 84 | foregroundColor: CalendarPicker.Appearance.onTintColor, 85 | backgroundColor: CalendarPicker.Appearance.tintColor 86 | ) 87 | 88 | /// Default appearance for hidden day 89 | public static let hidden = CalendarPicker.Appearance.Day( 90 | foregroundColor: .clear, 91 | backgroundColor: .clear 92 | ) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/YCalendarPicker.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 43 | 49 | 50 | 51 | 52 | 53 | 59 | 60 | 63 | 69 | 70 | 71 | 72 | 73 | 83 | 84 | 90 | 91 | 97 | 98 | 99 | 100 | 102 | 103 | 106 | 107 | 108 | -------------------------------------------------------------------------------- /Tests/YCalendarPickerTests/SwiftUI/MonthViewTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MonthViewTests.swift 3 | // YCalendarPicker 4 | // 5 | // Created by Sahil Saini on 29/11/22. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import SwiftUI 11 | @testable import YCalendarPicker 12 | 13 | final class MonthViewTests: XCTestCase { 14 | func testMonthAndYearisNotEmpty() { 15 | let sut = makeSUT(dateFormat: "") 16 | let monthAndYear = sut.getMonthAndYear() 17 | XCTAssertNotEqual(monthAndYear, "") 18 | } 19 | 20 | func testMonthAndYear() { 21 | let sut = makeSUT() 22 | let monthAndYear = sut.getMonthAndYear() 23 | XCTAssertEqual(monthAndYear, Date().toString(withTemplate: "MMMMyyyy")) 24 | } 25 | 26 | func testCurrentDateUpdateCorrectly() { 27 | let sut = makeSUT() 28 | let nextExpectedDate = Date().date(byAddingMonth: 1)?.dateOnly 29 | let prevExpectedDate2 = Date().date(byAddingMonth: -1)?.dateOnly 30 | sut.updateCurrentDate(byAddingMonth: 1) 31 | 32 | XCTAssertEqual(sut.currentDate, nextExpectedDate) 33 | sut.updateCurrentDate(byAddingMonth: -2) // -2 as we have already updated with +1 34 | XCTAssertEqual(sut.currentDate, prevExpectedDate2) 35 | } 36 | 37 | func testNextImageIsCorrect() { 38 | var sut = makeSUT() 39 | sut.appearance = CalendarPicker.Appearance(nextImage: nil) 40 | let nextImage = sut.getNextImage() 41 | XCTAssertEqual(nextImage, Image(systemName: "chevron.right").renderingMode(.template)) 42 | } 43 | 44 | func testPreviousImageIsCorrect() { 45 | var sut = makeSUT() 46 | sut.appearance = CalendarPicker.Appearance(previousImage: nil) 47 | let previousImage = sut.getPreviousImage() 48 | XCTAssertEqual(previousImage, Image(systemName: "chevron.left").renderingMode(.template)) 49 | } 50 | 51 | func testIsDisableForNextButtonWorkingCorrectly() { 52 | let sut = makeSUT( 53 | minimumDate: Date().date(byAddingMonth: -1), 54 | maximumDate: Date().date(byAddingMonth: 1) 55 | ) 56 | 57 | XCTAssertFalse(sut.isNextButtonDisabled) 58 | 59 | sut.updateCurrentDate(byAddingMonth: 2) 60 | XCTAssertTrue(sut.isNextButtonDisabled) 61 | } 62 | 63 | func testIsDisableForPreviousButtonWorkingCorrectly() { 64 | let sut = makeSUT( 65 | minimumDate: Date().date(byAddingMonth: -1), 66 | maximumDate: Date().date(byAddingMonth: 1) 67 | ) 68 | 69 | XCTAssertFalse(sut.isPreviousButtonDisabled) 70 | 71 | sut.updateCurrentDate(byAddingMonth: -2) 72 | XCTAssertTrue(sut.isPreviousButtonDisabled) 73 | } 74 | 75 | func testMonthAndYearTextWithDifferentLocale() { 76 | let sut = makeSUT(locale: Locale(identifier: "de_DE")) 77 | let monthAndYearText = sut.getMonthAndYear() 78 | 79 | let dateFormatter = DateFormatter() 80 | dateFormatter.dateFormat = "MMMM yyyy" 81 | dateFormatter.locale = Locale(identifier: "de_DE") 82 | 83 | XCTAssertEqual(monthAndYearText, dateFormatter.string(from: Date())) 84 | } 85 | 86 | func testMonthAndYearTextWithDefaultLocale() { 87 | let sut = makeSUT() 88 | let monthAndYearText = sut.getMonthAndYear() 89 | 90 | let dateFormatter = DateFormatter() 91 | dateFormatter.dateFormat = "MMMM yyyy" 92 | dateFormatter.locale = Locale.current 93 | 94 | XCTAssertEqual(monthAndYearText, dateFormatter.string(from: Date())) 95 | } 96 | 97 | func testPreviewIsNotNill() { 98 | XCTAssertNotNil(MonthView_Previews.previews) 99 | } 100 | } 101 | 102 | private extension MonthViewTests { 103 | func makeSUT( 104 | firstWeekday: Int? = nil, 105 | dateFormat: String? = nil, 106 | minimumDate: Date? = nil, 107 | maximumDate: Date? = nil, 108 | locale: Locale? = nil 109 | ) -> MonthView { 110 | var newDate = Date() 111 | let currentDate = Binding( 112 | get: { newDate }, 113 | set: { newDate = $0 } 114 | ) 115 | let sut = MonthView( 116 | currentDate: currentDate, 117 | appearance: .default, 118 | dateFormat: "MMMMyyyy", 119 | minimumDate: minimumDate?.dateOnly, 120 | maximumDate: maximumDate?.dateOnly, 121 | locale: locale ?? Locale.current 122 | ) 123 | XCTAssertNotNil(sut.body) 124 | return sut 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /Tests/YCalendarPickerTests/Extensions/StringToDateTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StringToDateTests.swift 3 | // YCalendarPicker 4 | // 5 | // Created by Sanjib Chakraborty on 20/06/22. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import YCalendarPicker 11 | 12 | final class StringToDateTests: XCTestCase { 13 | func test_toDateWithFormatType() { 14 | var sut: Date? 15 | var compareDate: Date? 16 | let calendar = Calendar.current 17 | let timeZone = TimeZone(abbreviation: "IST") 18 | 19 | // format string - "MM/dd/yyyy" 20 | compareDate = calendar.date(from: DateComponents(year: 2022, month: 06, day: 05)) 21 | sut = "06/05/2022".toDate(withFormatType: DateFormatType.MMddyyyy(separator: "/")) 22 | XCTAssertEqual(sut, compareDate) 23 | 24 | // format string - "dd-MM-yyyy" 25 | compareDate = calendar.date(from: DateComponents(year: 2022, month: 06, day: 05)) 26 | sut = "05-06-2022".toDate(withFormatType: DateFormatType.ddMMyyyy(separator: "-")) 27 | XCTAssertEqual(sut, compareDate) 28 | 29 | // format string - "yyyy" 30 | compareDate = calendar.date(from: DateComponents(year: 2022)) 31 | sut = "2022".toDate(withFormatType: DateFormatType.yyyy) 32 | XCTAssertEqual(sut, compareDate) 33 | 34 | // format string - "EEE, d MMM yyyy HH:mm:ss ZZZ". HTTP header date format 35 | compareDate = calendar.date( 36 | from: DateComponents(timeZone: timeZone, year: 2022, month: 06, day: 05, hour: 10, minute: 42, second: 52) 37 | ) 38 | sut = "Sun, 5 Jun 2022 10:42:52 +0530".toDate(withFormatType: DateFormatType.httpHeader) 39 | XCTAssertEqual(sut, compareDate) 40 | } 41 | 42 | func test_toDateWithFormatTypeCustom() { 43 | var sut: Date? 44 | var compareDate: Date? 45 | let calendar = Calendar.current 46 | let timeZone = TimeZone(abbreviation: "IST") 47 | 48 | // format string - "yyyy-MM-dd'T'HH:mmZ" 49 | compareDate = calendar.date( 50 | from: DateComponents(timeZone: timeZone, year: 2022, month: 06, day: 05, hour: 10, minute: 42) 51 | ) 52 | sut = "2022-06-05T10:42+0530".toDate(withFormatType: DateFormatType.custom(format: "yyyy-MM-dd'T'HH:mmZ")) 53 | XCTAssertEqual(sut, compareDate) 54 | 55 | // format string - "yyyy-MM-dd'T'HH:mm:ss.SSSZ" 56 | compareDate = calendar.date( 57 | from: DateComponents(timeZone: timeZone, year: 2022, month: 06, day: 05, hour: 10, minute: 42, second: 52) 58 | ) 59 | sut = "2022-06-05T10:42:52.000+0530".toDate( 60 | withFormatType: DateFormatType.custom(format: "yyyy-MM-dd'T'HH:mm:ss.SSSZ") 61 | ) 62 | XCTAssertEqual(sut, compareDate) 63 | 64 | // format string - "yyyy-MM-dd'T'HH:mm:ssZZZZZ" 65 | compareDate = calendar.date( 66 | from: DateComponents(timeZone: timeZone, year: 2022, month: 06, day: 05, hour: 10, minute: 42, second: 52) 67 | ) 68 | sut = "2022-06-05T10:42:52+05:30".toDate( 69 | withFormatType: DateFormatType.custom(format: "yyyy-MM-dd'T'HH:mm:ssZZZZZ") 70 | ) 71 | XCTAssertEqual(sut, compareDate) 72 | 73 | // format string - "yyyy-MM-dd'T'HH:mm:ssZZZZZ" 74 | compareDate = calendar.date( 75 | from: DateComponents(timeZone: timeZone, year: 2022, month: 06, day: 05, hour: 10, minute: 42, second: 52) 76 | ) 77 | sut = "2022-06-05T10:42:52+05:30".toDate( 78 | withFormatType: DateFormatType.custom(format: "yyyy_MM_dd'T'HH:mm:ssZZZZZ", separator: "-") 79 | ) 80 | XCTAssertEqual(sut, compareDate) 81 | 82 | // format string - "dd MMM yyyy, h:mm a" 83 | compareDate = calendar.date( 84 | from: DateComponents(timeZone: timeZone, year: 2022, month: 06, day: 05, hour: 10, minute: 42) 85 | ) 86 | sut = "05 Jun 2022, 10:42 AM".toDate( 87 | withFormatType: DateFormatType.custom(format: "dd MMM yyyy, h:mm a"), 88 | timeZone: timeZone 89 | ) 90 | XCTAssertEqual(sut, compareDate) 91 | 92 | // format string - "dd MMM yyyy, h:mm a" in PST timezone 93 | compareDate = calendar.date( 94 | from: DateComponents(timeZone: timeZone, year: 2022, month: 06, day: 05, hour: 10, minute: 42) 95 | ) 96 | sut = "04 Jun 2022, 10:12 PM".toDate( 97 | withFormatType: DateFormatType.custom(format: "dd MMM yyyy, h:mm a"), 98 | timeZone: TimeZone(abbreviation: "PST") 99 | ) 100 | XCTAssertEqual(sut, compareDate) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /Tests/YCalendarPickerTests/Extensions/DateFormatterCacheTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DateToStringTests.swift 3 | // YCalendarPicker 4 | // 5 | // Created by Sanjib Chakraborty on 06/07/22. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import YCalendarPicker 11 | 12 | final class DateFormatterCacheTests: XCTestCase { 13 | private var sut: DateFormatterCache! 14 | 15 | override func setUp() { 16 | super.setUp() 17 | 18 | sut = DateFormatterCache.current 19 | sut.removeAllCachedDateFormatters() 20 | } 21 | 22 | override func tearDown() { 23 | super.tearDown() 24 | 25 | sut.removeAllCachedDateFormatters() 26 | } 27 | 28 | func testDateFormatterCache() { 29 | XCTAssertEqual(sut.cachedDateFormattersCount, 0) 30 | 31 | _ = sut.cachedDateFormatter(format: "MM/dd/yyyy") 32 | XCTAssertEqual(sut.cachedDateFormattersCount, 1) 33 | 34 | // Use same format, so that count will not be increased 35 | _ = sut.cachedDateFormatter(format: "MM/dd/yyyy") 36 | XCTAssertEqual(sut.cachedDateFormattersCount, 1) 37 | 38 | // Use different format, so that count will be increased 39 | _ = sut.cachedDateFormatter(format: "dd-MM-yyyy") 40 | XCTAssertEqual(sut.cachedDateFormattersCount, 2) 41 | 42 | sut.removeAllCachedDateFormatters() 43 | XCTAssertEqual(sut.cachedDateFormattersCount, 0) 44 | } 45 | 46 | func testTemplateDateFormatterCache() { 47 | XCTAssertEqual(sut.cachedDateFormattersCount, 0) 48 | 49 | _ = sut.cachedTemplateDateFormatter(template: "ddMMMyyyy") 50 | XCTAssertEqual(sut.cachedDateFormattersCount, 1) 51 | 52 | // Use same template, so that count will not be increased 53 | _ = sut.cachedTemplateDateFormatter(template: "ddMMMyyyy") 54 | XCTAssertEqual(sut.cachedDateFormattersCount, 1) 55 | 56 | // Use different template, so that count will be increased 57 | _ = sut.cachedTemplateDateFormatter(template: "ddMMyyyyHHmmss") 58 | XCTAssertEqual(sut.cachedDateFormattersCount, 2) 59 | 60 | sut.removeAllCachedDateFormatters() 61 | XCTAssertEqual(sut.cachedDateFormattersCount, 0) 62 | } 63 | 64 | func testMixedDateFormatterCache() { 65 | XCTAssertEqual(sut.cachedDateFormattersCount, 0) 66 | 67 | _ = sut.cachedTemplateDateFormatter(template: "ddMMMyyyy") 68 | XCTAssertEqual(sut.cachedDateFormattersCount, 1) 69 | 70 | // Use same template, so that count will not be increased 71 | _ = sut.cachedTemplateDateFormatter(template: "ddMMMyyyy") 72 | XCTAssertEqual(sut.cachedDateFormattersCount, 1) 73 | 74 | // Use different template, so that count will be increased 75 | _ = sut.cachedTemplateDateFormatter(template: "ddMMyyyyHHmmss") 76 | XCTAssertEqual(sut.cachedDateFormattersCount, 2) 77 | 78 | // Use same template but as a format, so that count will be increased 79 | _ = sut.cachedDateFormatter(format: "ddMMyyyyHHmmss") 80 | XCTAssertEqual(sut.cachedDateFormattersCount, 3) 81 | 82 | let cachedTemplateFormatterCount = sut.cachedDateFormatters.keys.filter { $0.hasPrefix("template") }.count 83 | XCTAssertEqual(cachedTemplateFormatterCount, 2) 84 | 85 | sut.removeAllCachedDateFormatters() 86 | XCTAssertEqual(sut.cachedDateFormattersCount, 0) 87 | } 88 | 89 | func testTemplateFormatterCacheMixedLocale() { 90 | XCTAssertEqual(sut.cachedDateFormattersCount, 0) 91 | 92 | _ = sut.cachedTemplateDateFormatter(template: "ddMMMyyyy") 93 | XCTAssertEqual(sut.cachedDateFormattersCount, 1) 94 | 95 | // Use same template but passing current locale should not increase 96 | _ = sut.cachedTemplateDateFormatter(template: "ddMMMyyyy", locale: .current) 97 | XCTAssertEqual(sut.cachedDateFormattersCount, 1) 98 | 99 | // Use different locale, so that count will be increased 100 | let otherIdentifier = (Locale.current.identifier == "de_CH") ? "en_GB" : "de_CH" 101 | let otherLocale = Locale(identifier: otherIdentifier) 102 | _ = sut.cachedTemplateDateFormatter(template: "ddMMMyyyy", locale: otherLocale) 103 | XCTAssertEqual(sut.cachedDateFormattersCount, 2) 104 | 105 | sut.removeAllCachedDateFormatters() 106 | XCTAssertEqual(sut.cachedDateFormattersCount, 0) 107 | } 108 | } 109 | 110 | extension DateFormatterCache { 111 | var cachedDateFormattersCount: Int { 112 | cachedDateFormatters.keys.count 113 | } 114 | 115 | func removeAllCachedDateFormatters() { 116 | cachedDateFormatters.removeAll() 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /Sources/YCalendarPicker/SwiftUI/Views/MonthView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MonthView.swift 3 | // YCalendarPicker 4 | // 5 | // Created by Sahil Saini on 29/11/22. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import YMatterType 11 | 12 | /// Displays current month and year together with previous and next buttons 13 | internal struct MonthView { 14 | /// maximum scale factor of the month-year text label 15 | static let maximumScaleFactor: CGFloat = 1.5 16 | /// minimum size for previous and next buttons 17 | static let minimumButtonSize = CGSize(width: 44, height: 44) 18 | 19 | @Binding var currentDate: Date 20 | var appearance: CalendarPicker.Appearance 21 | let dateFormat: String 22 | let minimumDate: Date? 23 | let maximumDate: Date? 24 | let locale: Locale 25 | 26 | var isNextButtonDisabled: Bool { 27 | guard let expectedDate = currentDate.date(byAddingMonth: 1)?.dateOnly else { return true } 28 | if let maxDate = maximumDate, expectedDate > maxDate { 29 | return true 30 | } 31 | return false 32 | } 33 | 34 | var isPreviousButtonDisabled: Bool { 35 | if appearance.allowPrecedeMinimumDate { 36 | return false 37 | } 38 | // -7 as max days from previous month can be 7. 39 | // current date is first of every month 40 | guard let expectedDate = currentDate.date(byAddingDays: -7)?.dateOnly else { return true } 41 | 42 | if let minDate = minimumDate, expectedDate < minDate { 43 | return true 44 | } 45 | return false 46 | } 47 | } 48 | 49 | extension MonthView: View { 50 | var body: some View { 51 | getMonthView() 52 | } 53 | 54 | @ViewBuilder 55 | func getMonthView() -> some View { 56 | HStack { 57 | TextStyleLabel(getMonthAndYear(), typography: appearance.monthStyle.typography, configuration: { label in 58 | label.textColor = appearance.monthStyle.textColor 59 | label.maximumScaleFactor = MonthView.maximumScaleFactor 60 | }) 61 | 62 | Spacer() 63 | 64 | HStack(spacing: 0) { 65 | Button(action: { 66 | updateCurrentDate(byAddingMonth: -1) 67 | }, label: { 68 | getPreviousImage() 69 | .foregroundColor(Color(appearance.monthStyle.textColor)) 70 | .opacity(isPreviousButtonDisabled ? 0.5 : 1.0) 71 | }) 72 | .frame( 73 | minWidth: MonthView.minimumButtonSize.width, 74 | minHeight: MonthView.minimumButtonSize.height 75 | ) 76 | .disabled(isPreviousButtonDisabled) 77 | .accessibilityLabel(CalendarPicker.Strings.previousMonthA11yLabel.localized) 78 | Button(action: { 79 | updateCurrentDate(byAddingMonth: 1) 80 | }, label: { 81 | getNextImage() 82 | .foregroundColor(Color(appearance.monthStyle.textColor)) 83 | .opacity(isNextButtonDisabled ? 0.5 : 1.0) 84 | }) 85 | .frame( 86 | minWidth: MonthView.minimumButtonSize.width, 87 | minHeight: MonthView.minimumButtonSize.height 88 | ) 89 | .disabled(isNextButtonDisabled) 90 | .accessibilityLabel(CalendarPicker.Strings.nextMonthA11yLabel.localized) 91 | } 92 | } 93 | } 94 | 95 | func updateCurrentDate(byAddingMonth count: Int) { 96 | guard let newValue = currentDate.date(byAddingMonth: count)?.dateOnly else { return } 97 | currentDate = newValue 98 | } 99 | 100 | func getPreviousImage() -> Image { 101 | guard let image = appearance.previousImage else { 102 | return Image(systemName: "chevron.left").renderingMode(.template) 103 | } 104 | return Image(uiImage: image) 105 | } 106 | 107 | func getNextImage() -> Image { 108 | guard let image = appearance.nextImage else { 109 | return Image(systemName: "chevron.right").renderingMode(.template) 110 | } 111 | return Image(uiImage: image) 112 | } 113 | 114 | func getMonthAndYear() -> String { 115 | currentDate.toString(withTemplate: dateFormat, locale: locale) ?? "" 116 | } 117 | } 118 | 119 | struct MonthView_Previews: PreviewProvider { 120 | static var previews: some View { 121 | MonthView( 122 | currentDate: .constant(Date()), 123 | appearance: .default, 124 | dateFormat: "MMMMyyyy", 125 | minimumDate: nil, 126 | maximumDate: nil, 127 | locale: Locale(identifier: "pt_BR") 128 | ) 129 | .padding(.horizontal, 16) 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /Tests/YCalendarPickerTests/SwiftUI/DayViewTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DayViewTests.swift 3 | // YCalendarPicker 4 | // 5 | // Created by Sahil Saini on 02/12/22. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import YCalendarPicker 11 | import SwiftUI 12 | 13 | final class DayViewTests: XCTestCase { 14 | func testDateTextIsTodayDateText() { 15 | let sut = makeSUT() 16 | let dateText = sut.dateItem.day 17 | XCTAssertNotNil(dateText, Date().get(.day)) 18 | } 19 | 20 | func testDateTextAppearanceForSelectedDate() { 21 | let sut = makeSUT(isSelected: true, appearance: .Defaults.selected) 22 | XCTAssertAppearanceEqual(appearance1: sut.appearance, appearance2: .Defaults.selected) 23 | } 24 | 25 | func testDateTextAppearanceForToday() { 26 | let sut = makeSUT(appearance: .Defaults.today) 27 | XCTAssertAppearanceEqual(appearance1: sut.appearance, appearance2: .Defaults.today) 28 | } 29 | 30 | func testDateTextAppearanceForCurrentMonth() { 31 | let previousDate = Date().previousDate() 32 | let sut = makeSUT(dateToTest: previousDate, appearance: .Defaults.normal) 33 | XCTAssertAppearanceEqual(appearance1: sut.appearance, appearance2: .Defaults.normal) 34 | } 35 | 36 | func testDateTextAppearanceForGrayOutDate() throws { 37 | let previousMonthDate = try XCTUnwrap(Calendar.current.date(byAdding: .month, value: -1, to: Date())) 38 | let sut = makeSUT(isGrayedOut: true, dateToTest: previousMonthDate, appearance: .Defaults.grayed) 39 | XCTAssertAppearanceEqual(appearance1: sut.appearance, appearance2: .Defaults.grayed) 40 | } 41 | 42 | func testDateTextAppearanceForNotEnabledDate() throws { 43 | let previousMonthDate = try XCTUnwrap(Calendar.current.date(byAdding: .month, value: -1, to: Date())) 44 | let sut = makeSUT(isEnabled: false, dateToTest: previousMonthDate, appearance: .Defaults.disabled) 45 | XCTAssertAppearanceEqual(appearance1: sut.appearance, appearance2: .Defaults.disabled) 46 | } 47 | 48 | func testDateTextAppearanceForBookedDates() { 49 | let sut = makeSUT(isBooked: true, appearance: .Defaults.booked) 50 | XCTAssertAppearanceEqual(appearance1: sut.appearance, appearance2: .Defaults.booked) 51 | } 52 | 53 | func testDateBodyPreviewisNotNil() { 54 | let sutPreview = DayView_Previews.previews 55 | XCTAssertNotNil(sutPreview) 56 | } 57 | 58 | func testGetAccessibilityText() throws { 59 | let previousMonthDate = try XCTUnwrap(Calendar.current.date(byAdding: .month, value: -1, to: Date())) 60 | let today = CalendarPicker.Strings.todayDayDescriptor.localized 61 | 62 | XCTAssertTrue(makeSUT().getAccessibilityText().hasSuffix(today)) 63 | XCTAssertFalse(makeSUT(dateToTest: previousMonthDate).getAccessibilityText().hasSuffix(today)) 64 | } 65 | 66 | func testGetAccessibilityTextForDifferentLocale() { 67 | let dateToTest = Date().previousDate().dateOnly 68 | let sut = makeSUT(dateToTest: dateToTest, locale: Locale(identifier: "pt_BR")) 69 | 70 | XCTAssertEqual( 71 | sut.getAccessibilityText(), 72 | dateToTest.toString(withTemplate: "dEEEEMMMM", locale: Locale(identifier: "pt_BR")) 73 | ) 74 | } 75 | 76 | func testGetAccessibilityTraits() { 77 | XCTAssertEqual(makeSUT().getAccessibilityTraits(), .isButton) 78 | XCTAssertEqual(makeSUT(isSelected: true).getAccessibilityTraits(), .isSelected) 79 | } 80 | 81 | func testGetAccessibilityHint() { 82 | XCTAssertFalse(makeSUT().getAccessibilityHint().isEmpty) 83 | XCTAssertFalse(makeSUT(isGrayedOut: true).getAccessibilityHint().isEmpty) 84 | XCTAssertTrue(makeSUT(isSelected: true).getAccessibilityHint().isEmpty) 85 | } 86 | } 87 | 88 | private extension DayViewTests { 89 | func makeSUT( 90 | isGrayedOut: Bool = false, 91 | isSelected: Bool = false, 92 | isEnabled: Bool = true, 93 | dateToTest: Date = Date(), 94 | locale: Locale? = nil, 95 | isBooked: Bool = false, 96 | appearance: CalendarPicker.Appearance.Day = .Defaults.normal 97 | ) -> DayView { 98 | let dateItem = dateToTest.toCalendarItem( 99 | isGrayedOut: isGrayedOut, 100 | isSelected: isSelected, 101 | isEnabled: isEnabled, 102 | isBooked: isBooked 103 | ) 104 | let sut = DayView( 105 | appearance: appearance, 106 | dateItem: dateItem, 107 | locale: locale ?? Locale.current, 108 | selectedDate: .constant(Date()) 109 | ) 110 | XCTAssertNotNil(sut.body) 111 | return sut 112 | } 113 | 114 | func XCTAssertAppearanceEqual( 115 | appearance1: CalendarPicker.Appearance.Day, 116 | appearance2: CalendarPicker.Appearance.Day 117 | ) { 118 | XCTAssertTypographyEqual(appearance1.typography, appearance2.typography) 119 | XCTAssertEqual(appearance1.borderWidth, appearance2.borderWidth) 120 | XCTAssertEqual(appearance1.borderColor, appearance2.borderColor) 121 | XCTAssertEqual(appearance1.backgroundColor, appearance2.backgroundColor) 122 | XCTAssertEqual(appearance1.foregroundColor, appearance2.foregroundColor) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /Tests/YCalendarPickerTests/UIKit/CalendarPicker+AppearanceTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CalendarPicker+AppearanceTests.swift 3 | // YCalendarPicker 4 | // 5 | // Created by Mark Pospesel on 1/13/23. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import YCoreUI 11 | @testable import YCalendarPicker 12 | 13 | final class CalendarPickerAppearanceTests: XCTestCase { 14 | func testHeaderContrast() { 15 | let sut = makeSUT() 16 | _test(color1: sut.monthStyle.textColor, color2: sut.backgroundColor) 17 | } 18 | 19 | func testWeekdayContrast() { 20 | let sut = makeSUT() 21 | _test(color1: sut.weekdayStyle.textColor, color2: sut.backgroundColor) 22 | } 23 | 24 | func testGrayedContrast() { 25 | _testDayAppearance(makeSUT().grayedDayAppearance) 26 | } 27 | 28 | func testNormalDayContrast() { 29 | _testDayAppearance(makeSUT().normalDayAppearance) 30 | } 31 | 32 | func testTodayContrast() { 33 | _testDayAppearance(makeSUT().todayAppearance) 34 | } 35 | 36 | func testSelectedContrast() { 37 | _testDayAppearance(makeSUT().selectedDayAppearance) 38 | } 39 | 40 | func testBookedContrast() { 41 | _testDayAppearance(makeSUT().bookedDayAppearance) 42 | } 43 | 44 | func testSecondaryLabel() { 45 | // Should have 4.5 contrast in normal constrast mode 46 | // Should have 7.0 contrast in high constrast mode 47 | for traits in UITraitCollection.allColorSpaces { 48 | _test( 49 | traits: traits, 50 | color1: CalendarPicker.Appearance.secondaryLabel, 51 | color2: .systemBackground, 52 | level: traits.accessibilityContrast == .high ? .AAA: .AA 53 | ) 54 | } 55 | } 56 | 57 | func testQuaternaryLabel() { 58 | // Should have 3.0 contrast in high contrast mode 59 | for traits in UITraitCollection.highContrastColorSpaces { 60 | _test( 61 | traits: traits, 62 | color1: CalendarPicker.Appearance.quaternaryLabel, 63 | color2: .systemBackground, 64 | context: .largeText, 65 | level: .AA 66 | ) 67 | } 68 | } 69 | } 70 | 71 | private extension CalendarPickerAppearanceTests { 72 | func makeSUT() -> CalendarPicker.Appearance { 73 | CalendarPicker.Appearance.default 74 | } 75 | 76 | func _test( 77 | color1: UIColor, 78 | color2: UIColor, 79 | context: WCAGContext = .normalText, 80 | level: WCAGLevel = .AA 81 | ) { 82 | for traits in UITraitCollection.allColorSpaces { 83 | _test(traits: traits, color1: color1, color2: color2, context: context, level: level) 84 | } 85 | } 86 | 87 | func _test( 88 | traits: UITraitCollection, 89 | color1: UIColor, 90 | color2: UIColor, 91 | context: WCAGContext = .normalText, 92 | level: WCAGLevel 93 | ) { 94 | var color1 = color1.resolvedColor(with: traits) 95 | let color2 = color2.resolvedColor(with: traits) 96 | let alpha1 = color1.rgbaComponents.alpha 97 | XCTAssertGreaterThan(alpha1, 0.0, "Color 1 must not be clear.") 98 | XCTAssertEqual(color2.rgbaComponents.alpha, 1.0, "Color 2 must not be opaque.") 99 | 100 | if alpha1 < 1.0 { 101 | // if color1 is partially transparent, blend it with color2 before evaluating 102 | color1 = color2.blended(by: alpha1, with: color1) 103 | } 104 | 105 | XCTAssertTrue( 106 | color1.isSufficientContrast(to: color2, context: context, level: level), 107 | String( 108 | format: "#%@ vs #%@ ratio = %.02f under %@ Mode%@", 109 | color1.rgbDisplayString(), 110 | color2.rgbDisplayString(), 111 | color1.contrastRatio(to: color2), 112 | traits.userInterfaceStyle == .dark ? "Dark" : "Light", 113 | traits.accessibilityContrast == .high ? " Increased Contrast" : "" 114 | ) 115 | ) 116 | } 117 | 118 | private func _testDayAppearance(_ day: CalendarPicker.Appearance.Day, context: WCAGContext = .normalText) { 119 | let foreground = day.foregroundColor 120 | let background = day.backgroundColor.isClear ? makeSUT().backgroundColor : day.backgroundColor 121 | 122 | _test(color1: foreground, color2: background, context: context) 123 | if !day.borderColor.isClear { 124 | _test(color1: day.borderColor, color2: background) 125 | } 126 | } 127 | } 128 | 129 | extension UIColor { 130 | var isClear: Bool { 131 | self == UIColor.clear 132 | } 133 | } 134 | 135 | extension UITraitCollection { 136 | /// Trait collections with high contrast 137 | /// 1. Light Mode x High Contrast 138 | /// 2. Dark Mode x High Contrast 139 | static let highContrastColorSpaces: [UITraitCollection] = [ 140 | UITraitCollection(traitsFrom: [ 141 | UITraitCollection(userInterfaceStyle: .light), 142 | UITraitCollection(accessibilityContrast: .high) 143 | ]), 144 | UITraitCollection(traitsFrom: [ 145 | UITraitCollection(userInterfaceStyle: .dark), 146 | UITraitCollection(accessibilityContrast: .high) 147 | ]) 148 | ] 149 | } 150 | -------------------------------------------------------------------------------- /Sources/YCalendarPicker/SwiftUI/Views/DayView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DayView.swift 3 | // YCalendarPicker 4 | // 5 | // Created by Sahil Saini on 02/12/22. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | import YMatterType 11 | 12 | struct DayView { 13 | /// maximum scale factor of the month-year text label 14 | static let maximumScaleFactor: CGFloat = 1.5 15 | /// size of each day 16 | static let size = CGSize(width: 40, height: 40) 17 | /// horizontal and vertical padding around day view circles 18 | static let padding: CGFloat = 2 19 | 20 | let appearance: CalendarPicker.Appearance.Day 21 | let dateItem: CalendarMonthItem 22 | let locale: Locale 23 | @Binding var selectedDate: Date? 24 | } 25 | 26 | extension DayView: View { 27 | var body: some View { 28 | getDayView() 29 | } 30 | 31 | func getDayView() -> some View { 32 | ZStack { 33 | TextStyleLabel(dateItem.day, typography: appearance.typography, configuration: { label in 34 | label.isUserInteractionEnabled = true 35 | label.textAlignment = .center 36 | label.maximumScaleFactor = DayView.maximumScaleFactor 37 | label.textColor = appearance.foregroundColor 38 | }) 39 | Spacer().frame(minWidth: DayView.size.width, minHeight: DayView.size.height) 40 | } 41 | .background( 42 | Circle() 43 | .stroke(Color(appearance.borderColor), lineWidth: appearance.borderWidth) 44 | .background(Circle().foregroundColor(Color(appearance.backgroundColor))) 45 | ) 46 | .padding(.horizontal, DayView.padding) 47 | .padding(.vertical, DayView.padding) 48 | .onTapGesture { 49 | guard !appearance.isHidden else { return } 50 | guard dateItem.isEnabled else { return } 51 | selectedDate = dateItem.date 52 | } 53 | .accessibilityAddTraits(getAccessibilityTraits()) 54 | .accessibilityLabel(getAccessibilityText()) 55 | .accessibilityHint(getAccessibilityHint()) 56 | .disabled(!dateItem.isSelectable) 57 | } 58 | 59 | func getAccessibilityText() -> String { 60 | var accessibilityText = dateItem.date.toString(withTemplate: "dEEEEMMMM", locale: locale) ?? "" 61 | 62 | if dateItem.isToday { 63 | accessibilityText.append(CalendarPicker.Strings.todayDayDescriptor.localized) 64 | } 65 | 66 | if dateItem.isBooked { 67 | accessibilityText.append(CalendarPicker.Strings.bookedDayDescriptor.localized) 68 | } 69 | 70 | return accessibilityText 71 | } 72 | 73 | func getAccessibilityTraits() -> AccessibilityTraits { 74 | if dateItem.isSelected { 75 | return .isSelected 76 | } 77 | 78 | return .isButton 79 | } 80 | 81 | func getAccessibilityHint() -> String { 82 | guard dateItem.isSelectable, 83 | !dateItem.isSelected else { return "" } 84 | 85 | return CalendarPicker.Strings.dayButtonA11yHint.localized 86 | } 87 | } 88 | 89 | struct DayView_Previews: PreviewProvider { 90 | static var previews: some View { 91 | let locale = Locale(identifier: "de_DE") 92 | let today = Date().dateOnly 93 | let yesterday = today.previousDate() 94 | let earlier = yesterday.previousDate() 95 | let tomorrow = today.nextDate() 96 | let dayAfter = tomorrow.nextDate() 97 | let later = dayAfter.nextDate() 98 | 99 | let item1 = earlier.toCalendarItem(isGrayedOut: true) 100 | let item2 = yesterday.toCalendarItem() 101 | let item3 = today.toCalendarItem() 102 | let item4 = tomorrow.toCalendarItem(isSelected: true) 103 | let item5 = dayAfter.toCalendarItem(isBooked: true) 104 | let item6 = later.toCalendarItem(isEnabled: false) 105 | let appearance = CalendarPicker.Appearance() 106 | HStack { 107 | DayView( 108 | appearance: appearance.grayedDayAppearance, 109 | dateItem: item1, 110 | locale: locale, 111 | selectedDate: .constant(Date()) 112 | ) 113 | DayView( 114 | appearance: appearance.normalDayAppearance, 115 | dateItem: item2, 116 | locale: locale, 117 | selectedDate: .constant(Date()) 118 | ) 119 | DayView( 120 | appearance: appearance.todayAppearance, 121 | dateItem: item3, 122 | locale: locale, 123 | selectedDate: .constant(Date()) 124 | ) 125 | DayView( 126 | appearance: appearance.selectedDayAppearance, 127 | dateItem: item4, 128 | locale: locale, 129 | selectedDate: .constant(Date()) 130 | ) 131 | DayView( 132 | appearance: appearance.bookedDayAppearance, 133 | dateItem: item5, 134 | locale: locale, 135 | selectedDate: .constant(Date()) 136 | ) 137 | DayView( 138 | appearance: appearance.disabledDayAppearance, 139 | dateItem: item6, 140 | locale: locale, 141 | selectedDate: .constant(Date()) 142 | ) 143 | } 144 | .padding(.horizontal, 16) 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /Sources/YCalendarPicker/UIKit/CalendarPicker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CalendarPicker.swift 3 | // YCalendarPicker 4 | // 5 | // Created by Sahil on 18/11/22. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import SwiftUI 11 | import YCoreUI 12 | 13 | /// UIKit month calendar picker 14 | /// 15 | /// Renamed to `CalendarPicker`. 16 | @available(*, deprecated, renamed: "CalendarPicker") 17 | public typealias YCalendarPicker = CalendarPicker 18 | 19 | /// CalendarPicker for use with UIKit 20 | public class CalendarPicker: UIControl { 21 | var calendarView: CalendarView 22 | 23 | /// Delegate for month change 24 | weak public var delegate: CalendarPickerDelegate? 25 | 26 | /// Selected date (if any) 27 | public var date: Date? { 28 | get { calendarView.date } 29 | set { calendarView.date = newValue } 30 | } 31 | 32 | /// Calendar appearance 33 | public var appearance: Appearance { 34 | get { calendarView.appearance } 35 | set { calendarView.appearance = newValue } 36 | } 37 | 38 | /// Optional minimum date. Dates before the minimum cannot be selected. `nil` means no minimum (default). 39 | public var minimumDate: Date? { 40 | get { calendarView.minimumDate } 41 | set { calendarView.minimumDate = newValue } 42 | } 43 | /// Optional maximum date. Dates beyond the maximum cannot be selected. `nil` means no maximum (default). 44 | public var maximumDate: Date? { 45 | get { calendarView.maximumDate } 46 | set { calendarView.maximumDate = newValue } 47 | } 48 | 49 | /// Optional booked dates. These dates cannot be selected. 50 | public var bookedDates: [Date] { 51 | get { calendarView.bookedDates } 52 | set { calendarView.bookedDates = newValue } 53 | } 54 | 55 | /// Initializes a calendar picker control. 56 | /// - Parameters: 57 | /// - firstWeekday: first weekday. Default is `nil` to use current calendar's value. 58 | /// - appearance: appearance for the calendar. Default is `.default`. 59 | /// - minimumDate: minimum selectable date. Default is `nil`. 60 | /// - maximumDate: maximum selectable date. Default is `nil`. 61 | /// - startDate: start date of the calendar. Default is `nil`. 62 | /// - locale: locale for date formatting. Pass `nil` to use current locale. Default is `nil`. 63 | public required init( 64 | firstWeekday: Int? = nil, 65 | appearance: Appearance = .default, 66 | minimumDate: Date? = nil, 67 | maximumDate: Date? = nil, 68 | startDate: Date? = nil, 69 | locale: Locale? = nil 70 | ) { 71 | calendarView = CalendarView( 72 | firstWeekday: firstWeekday, 73 | appearance: appearance, 74 | minimumDate: minimumDate, 75 | maximumDate: maximumDate, 76 | startDate: startDate, 77 | locale: locale 78 | ) 79 | super.init(frame: .zero) 80 | addCalendarView() 81 | } 82 | 83 | required init?(coder: NSCoder) { 84 | calendarView = CalendarView( 85 | firstWeekday: nil, 86 | appearance: Appearance(), 87 | minimumDate: nil, 88 | maximumDate: nil, 89 | startDate: nil, 90 | locale: nil 91 | ) 92 | super.init(coder: coder) 93 | addCalendarView() 94 | } 95 | 96 | /// Calculates the intrinsic size of the calendar picker 97 | override public var intrinsicContentSize: CGSize { 98 | let monthLayout = appearance.monthStyle.typography.generateLayout( 99 | maximumScaleFactor: MonthView.maximumScaleFactor, 100 | compatibleWith: traitCollection 101 | ) 102 | let weekdayLayout = appearance.weekdayStyle.typography.generateLayout( 103 | maximumScaleFactor: WeekdayView.maximumScaleFactor, 104 | compatibleWith: traitCollection 105 | ) 106 | 107 | let daySize = DayView.size.outset(by: NSDirectionalEdgeInsets(all: DayView.padding)) 108 | 109 | let width = 7 * daySize.width 110 | 111 | let monthHeight = max(monthLayout.lineHeight, MonthView.minimumButtonSize.height) 112 | let weekdayHeight = weekdayLayout.lineHeight + (2 * WeekdayView.verticalPadding) 113 | let daysHeight = 6 * daySize.height 114 | let height = monthHeight + weekdayHeight + daysHeight 115 | 116 | return CGSize(width: width, height: height) 117 | } 118 | 119 | /// Adjusts the intrinsic content size on Dynamic Type changes 120 | override public func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { 121 | super.traitCollectionDidChange(previousTraitCollection) 122 | if traitCollection.hasDifferentFontAppearance(comparedTo: previousTraitCollection) { 123 | invalidateIntrinsicContentSize() 124 | } 125 | } 126 | } 127 | 128 | private extension CalendarPicker { 129 | func addCalendarView() { 130 | calendarView.delegate = self 131 | let hostController = UIHostingController(rootView: calendarView) 132 | addSubview(hostController.view) 133 | hostController.view.constrainEdges() 134 | } 135 | } 136 | 137 | extension CalendarPicker: CalendarViewDelegate { 138 | /// This method is used to inform when there is a change in selected date. 139 | /// - Parameter date: new selected date 140 | public func calendarViewDidSelectDate(_ date: Date?) { 141 | calendarView.date = date 142 | sendActions(for: .valueChanged) 143 | } 144 | /// This method is used to inform the change in month. 145 | /// - Parameter date: next/previous month(s) date 146 | public func calendarViewDidChangeMonth(to date: Date) { 147 | delegate?.calendarPicker(self, didChangeMonthTo: date) 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /Sources/YCalendarPicker/Extensions/Date+Compare.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Date+Compare.swift 3 | // YCalendarPicker 4 | // 5 | // Created by Parv Bhaskar on 15/06/22. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// A date extension with helper variables to check and compare. 12 | /// A good choice where we need to check if the selected date belongs to a particular 13 | /// segment (e.g. current month, next week, last year, etc.). 14 | public extension Date { 15 | /// Returns `true` if the given date is within yesterday, as defined by the calendar and calendar's locale. 16 | var isYesterday: Bool { 17 | Calendar.current.isDateInYesterday(self) 18 | } 19 | 20 | /// Returns `true` if the given date is within today, as defined by the calendar and calendar's locale. 21 | var isToday: Bool { 22 | Calendar.current.isDateInToday(self) 23 | } 24 | 25 | /// Returns `true` if the given date is within tomorrow, as defined by the calendar and calendar's locale. 26 | var isTomorrow: Bool { 27 | Calendar.current.isDateInTomorrow(self) 28 | } 29 | 30 | /// Returns `true` if the given date is within last week, as defined by the calendar and calendar's locale. 31 | var isLastWeek: Bool { 32 | guard let previousWeek = Calendar.current.date( 33 | byAdding: .weekOfYear, 34 | value: -1, 35 | to: Date() 36 | ) else { return false } 37 | 38 | return Calendar.current.isDate(self, equalTo: previousWeek, toGranularity: .weekOfYear) 39 | } 40 | 41 | /// Returns `true` if the given date is within current week, as defined by the calendar and calendar's locale. 42 | var isThisWeek: Bool { 43 | isSameWeek(as: Date()) 44 | } 45 | 46 | /// Returns `true` if the given date is within next week, as defined by the calendar and calendar's locale. 47 | var isNextWeek: Bool { 48 | guard let nextWeek = Calendar.current.date( 49 | byAdding: .weekOfYear, 50 | value: 1, 51 | to: Date() 52 | ) else { return false } 53 | 54 | return Calendar.current.isDate(self, equalTo: nextWeek, toGranularity: .weekOfYear) 55 | } 56 | 57 | /// Returns `true` if the given date is within last month, as defined by the calendar and calendar's locale. 58 | var isLastMonth: Bool { 59 | guard let previousMonth = Calendar.current.date( 60 | byAdding: .month, 61 | value: -1, 62 | to: Date() 63 | ) else { return false } 64 | 65 | return Calendar.current.isDate(self, equalTo: previousMonth, toGranularity: .month) 66 | } 67 | 68 | /// Returns `true` if the given date is within current month, as defined by the calendar and calendar's locale. 69 | var isThisMonth: Bool { 70 | isSameMonth(as: Date()) 71 | } 72 | 73 | /// Returns `true` if the given date is within next month, as defined by the calendar and calendar's locale. 74 | var isNextMonth: Bool { 75 | guard let nextMonth = Calendar.current.date( 76 | byAdding: .month, 77 | value: 1, 78 | to: Date() 79 | ) else { return false } 80 | 81 | return Calendar.current.isDate(self, equalTo: nextMonth, toGranularity: .month) 82 | } 83 | 84 | /// Returns `true` if the given date is within last year, as defined by the calendar and calendar's locale. 85 | var isLastYear: Bool { 86 | guard let lastYear = Calendar.current.date( 87 | byAdding: .year, 88 | value: -1, 89 | to: Date() 90 | ) else { return false } 91 | 92 | return Calendar.current.isDate(self, equalTo: lastYear, toGranularity: .year) 93 | } 94 | 95 | /// Returns `true` if the given date is within current year, as defined by the calendar and calendar's locale. 96 | var isThisYear: Bool { 97 | isSameYear(as: Date()) 98 | } 99 | 100 | /// Returns `true` if the given date is within next year, as defined by the calendar and calendar's locale. 101 | var isNextYear: Bool { 102 | guard let nextYear = Calendar.current.date( 103 | byAdding: .year, 104 | value: 1, 105 | to: Date() 106 | ) else { return false } 107 | 108 | return Calendar.current.isDate(self, equalTo: nextYear, toGranularity: .year) 109 | } 110 | 111 | /// Determines whether two dates both fall on the same day. 112 | /// - Parameter otherDate: the other date which need to be compared with selected date. 113 | /// - Returns: `true` when both dates fall on the same day. 114 | func isSameDay(as otherDate: Date) -> Bool { 115 | Calendar.current.isDate(self, equalTo: otherDate, toGranularity: .day) 116 | } 117 | 118 | /// Determines whether two dates both fall on the same week. 119 | /// - Parameter otherDate: the other date which need to be compared with selected date. 120 | /// - Returns: `true` when both dates fall on the same week.. 121 | func isSameWeek(as otherDate: Date) -> Bool { 122 | Calendar.current.isDate(self, equalTo: otherDate, toGranularity: .weekOfYear) 123 | } 124 | 125 | /// Determines whether two dates both fall on the same month. 126 | /// - Parameter otherDate: the other date which need to be compared with selected date. 127 | /// - Returns: `true` when both dates fall on the same month.. 128 | func isSameMonth(as otherDate: Date) -> Bool { 129 | Calendar.current.isDate(self, equalTo: otherDate, toGranularity: .month) 130 | } 131 | 132 | /// Determines whether two dates both fall on the same year. 133 | /// - Parameter otherDate: the other date which need to be compared with selected date. 134 | /// - Returns: `true` when both dates fall on the same year.. 135 | func isSameYear(as otherDate: Date) -> Bool { 136 | Calendar.current.isDate(self, equalTo: otherDate, toGranularity: .year) 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /Sources/YCalendarPicker/Extensions/DateFormatType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DateFormatType.swift 3 | // YCalendarPicker 4 | // 5 | // Created by Sanjib Chakraborty on 22/06/22. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// The date format type used for string conversion. 12 | public enum DateFormatType { 13 | /// The ISO8601 formatted year "yyyy" i.e. 2022 14 | case isoYear 15 | 16 | /// The ISO8601 formatted year and month "yyyy-MM" i.e. 2022-06 17 | case isoYearMonth 18 | 19 | /// The ISO8601 formatted date "yyyy-MM-dd" i.e. 2022-06-05 20 | case isoDate 21 | 22 | /// The ISO8601 formatted date, time and sec "yyyy-MM-dd'T'HH:mm:ssZ" i.e. 2022-06-05T19:20:30+01:00 23 | case isoDateTime 24 | 25 | /// The ISO8601 formatted date, time and millisec "yyyy-MM-dd'T'HH:mm:ss.SSSZ" i.e. 2022-06-05T19:20:30.45+01:00 26 | case isoDateTimeFull 27 | 28 | /// The http header formatted date "EEE, d MMM yyyy HH:mm:ss ZZZ" i.e. "Sun, 5 Jun 2022 19:20:30 +0530" 29 | case httpHeader 30 | 31 | /// Last two digits of year, two-digit month, two-digit day 32 | case yyMMdd(separator: String) 33 | 34 | /// Two-digit month, two-digit day, last two digits of year 35 | case MMddyy(separator: String) 36 | 37 | /// Four-digit year, two-digit month, two-digit day 38 | case yyyyMMdd(separator: String) 39 | 40 | /// Two-digit day, two-digit month, four-digit year 41 | case ddMMyyyy(separator: String) 42 | 43 | /// Two-digit month, two-digit day, four-digit year 44 | case MMddyyyy(separator: String) 45 | 46 | /// Two-digit day, two-digit month, last two digits of year 47 | case ddMMyy(separator: String) 48 | 49 | /// Last two digits of year, three-letter abbreviation of the month, two-digit day 50 | case yyMMMdd(separator: String) 51 | 52 | /// Two-digit day, three-letter abbreviation of the month, last two digits of year 53 | case ddMMMyy(separator: String) 54 | 55 | /// Three-letter abbreviation of the month, two-digit day, last two digits of year 56 | case MMMddyy(separator: String) 57 | 58 | /// Four-digit year, Three-letter abbreviation of the month, two-digit day 59 | case yyyyMMMdd(separator: String) 60 | 61 | /// Two-digit day, Three-letter abbreviation of the month, Four-digit year 62 | case ddMMMyyyy(separator: String) 63 | 64 | /// Three-letter abbreviation of the month, two-digit day, four-digit year 65 | case MMMddyyyy(separator: String) 66 | 67 | /// Last two digits of year, Three-digit Julian day 68 | case yyDDD(separator: String) 69 | 70 | /// Three-digit Julian day, last two digits of year 71 | case DDDyy(separator: String) 72 | 73 | /// Four-digits year, Three-digit Julian day 74 | case yyyyDDD(separator: String) 75 | 76 | /// Three-digit Julian day, Four digits of year 77 | case DDDyyyy(separator: String) 78 | 79 | /// Four-digits of year, Two-digit month 80 | case yyyyMM(separator: String) 81 | 82 | /// Full name of the Month (example: December) 83 | case MMMM 84 | 85 | /// Full name of the day (example: Sunday) 86 | case EEEE 87 | 88 | /// Four-digit year 89 | case yyyy 90 | 91 | /// Two-digit hour, two-digit minutes 92 | case HHmm(separator: String) 93 | 94 | /// Two-digit hour, two-digit minutes, two-digit seconds 95 | case HHmmss(separator: String) 96 | 97 | /// Four-digits year, two-digits month, two-digits day, zulu time indicator 98 | case yyyyMMddZ 99 | 100 | /// A custom date format string. In case of separator replacement, 101 | /// use '_' in the format string and this will be replaced with supplied separator. 102 | case custom(format: String, separator: String? = nil) 103 | } 104 | 105 | extension DateFormatType { 106 | var stringFormat: String { 107 | switch self { 108 | case .isoYear: return "yyyy" 109 | 110 | case .isoYearMonth: return "yyyy-MM" 111 | 112 | case .isoDate: return "yyyy-MM-dd" 113 | 114 | case .isoDateTime: return "yyyy-MM-dd'T'HH:mm:ssZ" 115 | 116 | case .isoDateTimeFull: return "yyyy-MM-dd'T'HH:mm:ss.SSSZ" 117 | 118 | case .httpHeader: return "EEE, d MMM yyyy HH:mm:ss ZZZ" 119 | 120 | case .yyMMdd(let separator): 121 | return "yy_MM_dd".replacingOccurrences(of: "_", with: separator) 122 | 123 | case .MMddyy(let separator): 124 | return "MM_dd_yy".replacingOccurrences(of: "_", with: separator) 125 | 126 | case .yyyyMMdd(let separator): 127 | return "yyyy_MM_dd".replacingOccurrences(of: "_", with: separator) 128 | 129 | case .ddMMyyyy(let separator): 130 | return "dd_MM_yyyy".replacingOccurrences(of: "_", with: separator) 131 | 132 | case .MMddyyyy(let separator): 133 | return "MM_dd_yyyy".replacingOccurrences(of: "_", with: separator) 134 | 135 | case .ddMMyy(let separator): 136 | return "dd_MM_yy".replacingOccurrences(of: "_", with: separator) 137 | 138 | case .yyMMMdd(let separator): 139 | return "yy_MMM_dd".replacingOccurrences(of: "_", with: separator) 140 | 141 | case .ddMMMyy(separator: let separator): 142 | return "dd_MMM_yy".replacingOccurrences(of: "_", with: separator) 143 | 144 | case .MMMddyy(separator: let separator): 145 | return "MMM_dd_yy".replacingOccurrences(of: "_", with: separator) 146 | 147 | case .yyyyMMMdd(separator: let separator): 148 | return "yyyy_MMM_dd".replacingOccurrences(of: "_", with: separator) 149 | 150 | case .ddMMMyyyy(separator: let separator): 151 | return "dd_MMM_yyyy".replacingOccurrences(of: "_", with: separator) 152 | 153 | case .MMMddyyyy(separator: let separator): 154 | return "MMM_dd_yyyy".replacingOccurrences(of: "_", with: separator) 155 | 156 | case .yyDDD(separator: let separator): 157 | return "yy_DDD".replacingOccurrences(of: "_", with: separator) 158 | 159 | case .DDDyy(separator: let separator): 160 | return "DDD_yy".replacingOccurrences(of: "_", with: separator) 161 | 162 | case .yyyyDDD(separator: let separator): 163 | return "yyyy_DDD".replacingOccurrences(of: "_", with: separator) 164 | 165 | case .DDDyyyy(separator: let separator): 166 | return "DDD_yyyy".replacingOccurrences(of: "_", with: separator) 167 | 168 | case .yyyyMM(separator: let separator): 169 | return "yyyy_MM".replacingOccurrences(of: "_", with: separator) 170 | 171 | case .MMMM: return "MMMM" 172 | 173 | case .EEEE: return "EEEE" 174 | 175 | case .yyyy: return "yyyy" 176 | 177 | case .HHmm(separator: let separator): 178 | return "HH_mm".replacingOccurrences(of: "_", with: separator) 179 | 180 | case .HHmmss(separator: let separator): 181 | return "HH_mm_ss".replacingOccurrences(of: "_", with: separator) 182 | 183 | case .yyyyMMddZ: 184 | return "yyyyMMddZ" 185 | 186 | case .custom(let format, let separator): 187 | if let separator = separator, 188 | !separator.isEmpty { 189 | return format.replacingOccurrences(of: "_", with: separator) 190 | } else { 191 | return format 192 | } 193 | } 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /Sources/YCalendarPicker/Extensions/Date+CalendarDates.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Date+CalendarDates.swift 3 | // YCalendarPicker 4 | // 5 | // Created by Parv Bhaskar on 20/06/22. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | /// A date extension with helper function to get all the dates for a selected month. 12 | /// A good choice where we need to show all the dates of a particular month on the calendar view. 13 | /// segment (e.g. getDatesForSelectedMonth). 14 | extension Date { 15 | /// Returns `42` total number of date tiles to be displayed for selected date. 16 | private static let numberOfDateTiles = 42 17 | 18 | /// Returns `[CalendarMonthItem]` gives all the dates for selected month dates that need to be 19 | /// shown in current month date calendar (eg. for June 2022, it will give dates from (29th May to 9th July)). 20 | public func getAllDatesForSelectedMonth(firstWeekIndex: Int) -> [CalendarMonthItem] { 21 | let previousMonthDates = getPreviousMonthDates(firstWeekIndex: firstWeekIndex) 22 | let selectedMonthDates = getSelectedMonthDates() 23 | let nextMonthDates = getNextMonthDates(firstWeekIndex: firstWeekIndex) 24 | 25 | return previousMonthDates + selectedMonthDates + nextMonthDates 26 | } 27 | 28 | /// Returns start `Date` of the given date. 29 | public func startDateOfMonth() -> Date { 30 | let calendar = Calendar.current 31 | let components = calendar.dateComponents([.year, .month], from: self) 32 | return calendar.date(from: components) ?? self 33 | } 34 | 35 | /// Returns end `Date` of the given date. 36 | public func endDateOfMonth() -> Date { 37 | var components = DateComponents() 38 | components.month = 1 39 | components.day = -1 40 | return Calendar.current.date(byAdding: components, to: startDateOfMonth()) ?? self 41 | } 42 | 43 | /// Returns a `Int` number of days for selected month. 44 | public func numberOfDaysInMonth() -> Int { 45 | guard let range = Calendar.current.range(of: .day, in: .month, for: self) else { return .zero } 46 | return range.count 47 | } 48 | 49 | /// Returns previous `Date` of the given date. 50 | public func previousDate() -> Date { 51 | date(byAddingDays: -1) ?? self 52 | } 53 | 54 | /// Returns next `Date` of the given date. 55 | public func nextDate() -> Date { 56 | date(byAddingDays: 1) ?? self 57 | } 58 | 59 | /// Returns a `Date` created by adding/removing the number of days. 60 | /// - Parameter count: The number of days. Provide negative value to subtract days. 61 | /// - Returns: A new `Date` by adding number of days. 62 | public func date(byAddingDays count: Int) -> Date? { 63 | Calendar.current.date(byAdding: .day, value: count, to: self) 64 | } 65 | 66 | /// Returns a `Date` created by adding/removing the number of months. 67 | /// - Parameter count: The number of months. Provide negative value to go back to previous month(s). 68 | /// - Returns: A new `Date` by adding number of months. 69 | public func date(byAddingMonth count: Int) -> Date? { 70 | Calendar.current.date(byAdding: .month, value: count, to: self) 71 | } 72 | 73 | /// Returns a `Date` created by adding/removing the number of years. 74 | /// - Parameter count: The number of years. Provide negative value to go back to previous year(s). 75 | /// - Returns: A new `Date` by adding number of years. 76 | public func date(byAddingYear count: Int) -> Date? { 77 | Calendar.current.date(byAdding: .year, value: count, to: self) 78 | } 79 | 80 | // MARK: - Helpers 81 | 82 | /// Returns `Int` it tells the weekday of selected day 83 | /// (eg. 5(Thursday.) for 2nd June 2022, 6(Friday) for July 2022). 84 | internal func indexOfWeekday() -> Int? { 85 | Calendar.current.dateComponents([.weekday], from: self).weekday 86 | } 87 | 88 | /// Returns `[CalendarMonthItem]` gives the previous month custom model 89 | /// dates that need to be shown in current month date calendar (eg. for June 2022, 90 | /// it will give dates from (29 May to 31st May)). 91 | internal func getPreviousMonthDates(firstWeekIndex: Int) -> [CalendarMonthItem] { 92 | let startDate = startDateOfMonth() 93 | guard var startDateWeekday = startDate.indexOfWeekday() else { return [] } 94 | startDateWeekday -= firstWeekIndex 95 | 96 | var previousMonthDates = [CalendarMonthItem]() 97 | var currentDate = startDate 98 | 99 | if startDateWeekday <= firstWeekIndex { 100 | startDateWeekday = 7+startDateWeekday 101 | } 102 | 103 | for _ in 1.. [CalendarMonthItem] { 116 | var nextMonthDates = [CalendarMonthItem]() 117 | var currentDate = endDateOfMonth() 118 | 119 | for _ in 1...getNumberOfFutureDatesRequired(firstWeekIndex: firstWeekIndex) { 120 | let nextDate = currentDate.nextDate() 121 | nextMonthDates.append(nextDate.toCalendarItem(isGrayedOut: true)) 122 | currentDate = nextDate 123 | } 124 | 125 | return nextMonthDates 126 | } 127 | 128 | /// Returns `[CalendarMonthItem]` gives the selected month custom model 129 | /// dates that need to be shown in current month date calendar (eg. for June 2022, 130 | /// it will give dates from (1st June to 30th June)). 131 | internal func getSelectedMonthDates() -> [CalendarMonthItem] { 132 | let startDate = startDateOfMonth() 133 | let totalDays = numberOfDaysInMonth() 134 | 135 | var selectedMonthDates = [startDate.toCalendarItem(isGrayedOut: false)] 136 | var currentDate = startDate 137 | 138 | for _ in 1.. String { 150 | "\(Calendar.current.component(component, from: self))" 151 | } 152 | 153 | /// Returns `CalendarMonthItem` gives the custom date model as per the selected date item. 154 | /// - Parameter `isGrayedOut`: tell us that the date is from the active month or not. 155 | internal func toCalendarItem( 156 | isGrayedOut: Bool = false, 157 | isSelected: Bool = false, 158 | isEnabled: Bool = true, 159 | isBooked: Bool = false 160 | ) -> CalendarMonthItem { 161 | CalendarMonthItem( 162 | date: self, 163 | isGrayedOut: isGrayedOut, 164 | isSelected: isSelected, 165 | isEnabled: isEnabled, 166 | isBooked: isBooked 167 | ) 168 | } 169 | 170 | /// Returns `Int` gives the number of future dates to fill current calendar view. 171 | private func getNumberOfFutureDatesRequired(firstWeekIndex: Int) -> Int { 172 | let numberOfDateTiles = Date.numberOfDateTiles 173 | let numberOfPreviousMonthDates = getPreviousMonthDates(firstWeekIndex: firstWeekIndex).count 174 | let numberOfSelectedMonthDates = numberOfDaysInMonth() 175 | 176 | return numberOfDateTiles - (numberOfPreviousMonthDates + numberOfSelectedMonthDates) 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /Tests/YCalendarPickerTests/SwiftUI/CalendarViewTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CalendarViewTests.swift 3 | // YCalendarPicker 4 | // 5 | // Created by Sahil Saini on 16/11/22. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import YCalendarPicker 11 | import SwiftUI 12 | 13 | final class CalendarViewTests: XCTestCase { 14 | func testDaysViewisNotNil() { 15 | let sut = makeSUT() 16 | let daysView = sut.getDaysView() 17 | XCTAssertNotNil(daysView) 18 | } 19 | 20 | func testMonthViewisNotNil() { 21 | let sut = makeSUT() 22 | let monthView = sut.getMonthView() 23 | XCTAssertNotNil(monthView) 24 | } 25 | 26 | func testDateBodyisNotNil() { 27 | let sut = makeSUT() 28 | XCTAssertNotNil(sut.body) 29 | } 30 | 31 | func testReuseIdentifierIsNotEmpty() { 32 | let sut = makeSUT() 33 | XCTAssertFalse(sut.reuseIdentifier.uuidString.isEmpty) 34 | } 35 | 36 | func testShouldEnableDateForNilMaxAndMinDate() { 37 | let sut = makeSUT() 38 | XCTAssertEqual(sut.shouldEnableDate(Date(), minimumDate: nil, maximumDate: nil), true) 39 | } 40 | 41 | func testUpdatedCurrentDateOnLeftSwipe() { 42 | let sut = makeSUT() 43 | let expectedDate = Date().startDateOfMonth().date(byAddingMonth: 1)?.dateOnly 44 | let updatedDate = sut.getCurrentDateAfterSwipe(swipeValue: CGSize(width: -10, height: 10)) 45 | XCTAssertEqual(expectedDate, updatedDate) 46 | } 47 | 48 | func testUpdatedCurrentDateOnRightSwipe() { 49 | let sut = makeSUT() 50 | let expectedDate = Date().startDateOfMonth().date(byAddingMonth: -1)?.dateOnly 51 | let updatedDate = sut.getCurrentDateAfterSwipe(swipeValue: CGSize(width: 10, height: 10)) 52 | XCTAssertEqual(expectedDate, updatedDate) 53 | } 54 | 55 | func testShouldEnableDateForDateLessThanMinimumDate() { 56 | let sut = makeSUT() 57 | let minimumDate = Date().nextDate() 58 | let maximumDate = Date().date(byAddingMonth: 1) 59 | let dateToTest = Date() 60 | let isEnable = sut.shouldEnableDate(dateToTest, minimumDate: minimumDate, maximumDate: maximumDate) 61 | XCTAssertFalse(isEnable) 62 | } 63 | 64 | func testShouldEnableDateForDateInRange() { 65 | let sut = makeSUT() 66 | let minimumDate = Date().previousDate() 67 | let maximumDate = Date().date(byAddingMonth: 1) 68 | let dateToTest = Date() 69 | let isEnable = sut.shouldEnableDate(dateToTest, minimumDate: minimumDate, maximumDate: maximumDate) 70 | XCTAssertTrue(isEnable) 71 | } 72 | 73 | func testShouldEnableDateForMaxDateOutOfRange() { 74 | let sut = makeSUT() 75 | let minimumDate = Date().date(byAddingMonth: -2) 76 | let maximumDate = Date().date(byAddingMonth: -1) 77 | let dateToTest = Date() 78 | let isEnable = sut.shouldEnableDate(dateToTest, minimumDate: minimumDate, maximumDate: maximumDate) 79 | XCTAssertFalse(isEnable) 80 | } 81 | 82 | func testSettingMinDate() { 83 | var minimumDate = Date().date(byAddingMonth: -1) 84 | // Initialize date with any value 85 | var sut = makeSUT(minimumDate: minimumDate) 86 | // test with initialized values 87 | XCTAssertEqual(sut.minimumDate, minimumDate?.dateOnly) 88 | // update with new value 89 | minimumDate = minimumDate?.date(byAddingMonth: -1) 90 | sut.minimumDate = minimumDate 91 | // test with updated value 92 | XCTAssertEqual(sut.minimumDate, minimumDate?.dateOnly) 93 | } 94 | 95 | func testSettingMinDateUpdateInitWithNilValue() { 96 | // Initialize date with any value 97 | var sut = makeSUT() 98 | // test with initialized values 99 | XCTAssertEqual(sut.minimumDate, nil) 100 | // update with new value 101 | let minimumDate = Date().date(byAddingMonth: -1) 102 | sut.minimumDate = minimumDate 103 | // test with updated value 104 | XCTAssertEqual(sut.minimumDate, minimumDate?.dateOnly) 105 | } 106 | 107 | func testMinDateWithSelectedDateLessThanMinDate() throws { 108 | let sut = makeSUT(minimumDate: Date()) 109 | let selectedDate = try XCTUnwrap(Date().date(byAddingMonth: -1)) 110 | XCTAssertTrue(sut.isDateBeforeMinimumDate(selectedDate)) 111 | } 112 | 113 | func testMinDateWithSelectedDateGreaterThanMinDate() throws { 114 | let sut = makeSUT(minimumDate: Date()) 115 | let selectedDate = try XCTUnwrap(Date().date(byAddingMonth: 1)) 116 | XCTAssertFalse(sut.isDateBeforeMinimumDate(selectedDate)) 117 | } 118 | 119 | func testMaxDateWithSelectedDateLessThanMaxDate() throws { 120 | let sut = makeSUT(maximumDate: Date()) 121 | let selectedDate = try XCTUnwrap(Date().date(byAddingMonth: -1)) 122 | XCTAssertFalse(sut.isDateAfterMaximumDate(selectedDate)) 123 | } 124 | 125 | func testMaxDateWithSelectedDateGreaterThanMaxDate() throws { 126 | let sut = makeSUT(maximumDate: Date()) 127 | let selectedDate = try XCTUnwrap(Date().date(byAddingMonth: 1)) 128 | XCTAssertTrue(sut.isDateAfterMaximumDate(selectedDate)) 129 | } 130 | 131 | func testBookedDateWithSelectedDate() throws { 132 | var sut = makeSUT() 133 | let selectedDate = try XCTUnwrap(Date().date(byAddingMonth: 1)?.dateOnly) 134 | sut.bookedDates = [selectedDate] 135 | XCTAssertTrue(sut.isBooked(selectedDate)) 136 | } 137 | 138 | func testSelectedDate() throws { 139 | var sut = makeSUT() 140 | XCTAssertNil(sut.date) 141 | let selectedDate = try XCTUnwrap(Date().date(byAddingMonth: 1)?.dateOnly) 142 | sut.date = selectedDate 143 | XCTAssertEqual(sut.date, selectedDate) 144 | } 145 | 146 | func testDateBodyPreviewisNotNil() { 147 | XCTAssertNotNil(CalendarView_Previews.previews) 148 | } 149 | 150 | func testStartOfTheWeekdayIndex() { 151 | let startWeekIndex = 1 152 | let sut = makeSUT(firstWeekday: startWeekIndex) 153 | XCTAssertEqual(startWeekIndex, sut.firstWeekday) 154 | } 155 | 156 | func testOnMaximumDateChangeForSelectedDateIsNil() { 157 | var sut = makeSUT(minimumDate: Date().previousDate()) 158 | sut.date = Date().date(byAddingDays: 3) 159 | XCTAssertNotNil(sut.date) 160 | sut.maximumDate = Date() 161 | XCTAssertNil(sut.date) 162 | } 163 | 164 | func testOnBookedDateChangeForSelectedDateIsNil() { 165 | var sut = makeSUT() 166 | sut.date = Date().date(byAddingDays: 3) 167 | XCTAssertNotNil(sut.date) 168 | sut.bookedDates = [Date().date(byAddingDays: 3) ?? Date()] 169 | XCTAssertNil(sut.date) 170 | } 171 | 172 | func testOnMinimumDateChangeForSelectedDateIsNil() { 173 | var sut = makeSUT() 174 | sut.date = Date().date(byAddingDays: -3) 175 | XCTAssertNotNil(sut.date) 176 | sut.minimumDate = Date() 177 | XCTAssertNil(sut.date) 178 | } 179 | 180 | func testOnStartDate() { 181 | let expectedDate = Date().date(byAddingMonth: 4) 182 | let sut = makeSUT(startDate: expectedDate) 183 | XCTAssertNotNil(sut.startDate) 184 | XCTAssertEqual(expectedDate?.startDateOfMonth(), sut.startDate) 185 | } 186 | 187 | func testCalendarViewPreviewIsNotNill() { 188 | XCTAssertNotNil(CalendarView_Previews.previews) 189 | } 190 | 191 | func testReuseIdentifierIsUnique() { 192 | XCTAssertNotEqual(makeSUT().reuseIdentifier, makeSUT().reuseIdentifier) 193 | } 194 | } 195 | 196 | private extension CalendarViewTests { 197 | func makeSUT( 198 | firstWeekday: Int? = nil, 199 | minimumDate: Date? = nil, 200 | maximumDate: Date? = nil, 201 | startDate: Date? = nil 202 | ) -> CalendarView { 203 | let sut = CalendarView( 204 | firstWeekday: firstWeekday ?? 0, 205 | minimumDate: minimumDate, 206 | maximumDate: maximumDate, 207 | startDate: startDate 208 | ) 209 | XCTAssertNotNil(sut.body) 210 | return sut 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /Sources/YCalendarPicker/UIKit/CalendarPicker+Appearance.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CalendarPicker+Appearance..swift 3 | // YCalendarPicker 4 | // 5 | // Created by Sahil Saini on 28/11/22. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import YMatterType 11 | 12 | /// Control theme i.e; color and typography 13 | extension CalendarPicker { 14 | /// Appearance for CalendarPicker that contains typography and color properties 15 | public struct Appearance { 16 | /// Appearance for days within current month 17 | public var normalDayAppearance: Day 18 | /// Appearance for days outside current month 19 | public var grayedDayAppearance: Day 20 | /// Appearance for today 21 | public var todayAppearance: Day 22 | /// Appearance for selected day 23 | public var selectedDayAppearance: Day 24 | /// Appearance for disabled day 25 | public var disabledDayAppearance: Day 26 | /// Appearance for booked day 27 | public var bookedDayAppearance: Day 28 | /// Foreground color and typography for weekdays 29 | public var weekdayStyle: (textColor: UIColor, typography: Typography) 30 | /// Image for previous month button 31 | /// 32 | /// Images with template rendering mode will be tinted to `monthForegroundColor`. 33 | public var previousImage: UIImage? 34 | /// Image for next month button 35 | /// 36 | /// Images with template rendering mode will be tinted to `monthForegroundColor`. 37 | public var nextImage: UIImage? 38 | /// Foreground color and typography for month (and year) 39 | public var monthStyle: (textColor: UIColor, typography: Typography) 40 | /// Background color for calendar view 41 | public var backgroundColor: UIColor 42 | /// Enable preceding to minimum date. 43 | public var allowPrecedeMinimumDate: Bool 44 | 45 | /// Initializes a calendar appearance. 46 | /// - Parameters: 47 | /// - normalDayAppearance: Appearance for days within current month. Default is `.Defaults.normal`. 48 | /// - grayedDayAppearance: Appearance for days outside current month. Default is `.Defaults.grayed`. 49 | /// - todayAppearance: Appearance for today. Default is `.Defaults.today`. 50 | /// - selectedDayAppearance: Appearance for selected day. Default is `.Defaults.selected`. 51 | /// - disabledDayAppearance: Appearance for disabled day. Default is `.Defaults.disabled`. 52 | /// - bookedDayAppearance: Appearance for already booked day. Default is `Defaults.booked`. 53 | /// - weekdayStyle: Typography and text color for weekday names. Default is `DefaultStyles.weekday`. 54 | /// - previousImage: Previous button image. Default is `Appearance.defaultPreviousImage`. 55 | /// - nextImage: Next button image. Default is `Appearance.defaultNextImage`. 56 | /// - monthStyle: Typography and text color for Month name. Default is `DefaultStyles.month`. 57 | /// - backgroundColor: Background color for calendar view. Default is `.systemBackground`. 58 | /// - allowPrecedeMinimumDate: Enable preceding to minimum date. Default is `false`. 59 | 60 | public init( 61 | normalDayAppearance: Day = .Defaults.normal, 62 | grayedDayAppearance: Day = .Defaults.grayed, 63 | todayAppearance: Day = .Defaults.today, 64 | selectedDayAppearance: Day = .Defaults.selected, 65 | disabledDayAppearance: Day = .Defaults.disabled, 66 | bookedDayAppearance: Day = .Defaults.booked, 67 | weekdayStyle: (textColor: UIColor, typography: Typography) = DefaultStyles.weekday, 68 | previousImage: UIImage? = Appearance.defaultPreviousImage, 69 | nextImage: UIImage? = Appearance.defaultNextImage, 70 | monthStyle: (textColor: UIColor, typography: Typography) = DefaultStyles.month, 71 | backgroundColor: UIColor = .systemBackground, 72 | allowPrecedeMinimumDate: Bool = false 73 | ) { 74 | self.normalDayAppearance = normalDayAppearance 75 | self.grayedDayAppearance = grayedDayAppearance 76 | self.todayAppearance = todayAppearance 77 | self.selectedDayAppearance = selectedDayAppearance 78 | self.disabledDayAppearance = disabledDayAppearance 79 | self.bookedDayAppearance = bookedDayAppearance 80 | self.weekdayStyle = weekdayStyle 81 | self.previousImage = previousImage 82 | self.nextImage = nextImage 83 | self.monthStyle = monthStyle 84 | self.backgroundColor = backgroundColor 85 | self.allowPrecedeMinimumDate = allowPrecedeMinimumDate 86 | } 87 | } 88 | } 89 | 90 | extension CalendarPicker.Appearance { 91 | /// Default Calendar appearance 92 | public static let `default` = CalendarPicker.Appearance() 93 | 94 | /// Default image for previous month button. Is a left chevron from SF Symbols in template rendering mode 95 | public static let defaultPreviousImage = UIImage(systemName: "chevron.left")?.withRenderingMode(.alwaysTemplate) 96 | /// Default image for next month button. Is a right chevron from SF Symbols in template rendering mode 97 | public static let defaultNextImage = UIImage(systemName: "chevron.right")?.withRenderingMode(.alwaysTemplate) 98 | 99 | /// Default tint color for Calendar Picker (a purple color that adjusts for dark mode). 100 | /// 101 | /// By default used for month/year text, previous/next buttons, today appearance, and selected date appearance. 102 | public static let tintColor = UIColor { (traitCollection: UITraitCollection) -> UIColor in 103 | switch traitCollection.userInterfaceStyle { 104 | case .dark: return UIColor(rgb: 0xEAE7FD) 105 | default: return UIColor(rgb: 0x1B0B99) 106 | } 107 | } 108 | 109 | /// Foreground color to use against tint color 110 | public static let onTintColor = UIColor { (traitCollection: UITraitCollection) -> UIColor in 111 | switch traitCollection.userInterfaceStyle { 112 | case .dark: return .black 113 | default: return .white 114 | } 115 | } 116 | 117 | /// Background color to use for booked dates 118 | public static let bookedColor = UIColor { (traitCollection: UITraitCollection) -> UIColor in 119 | switch traitCollection.userInterfaceStyle { 120 | case .dark: return UIColor(rgb: 0xBBB4F3) 121 | default: return UIColor(rgb: 0x0B053F) 122 | } 123 | } 124 | 125 | /// Foreground color to use against booked color 126 | public static let onBookedColor = UIColor { (traitCollection: UITraitCollection) -> UIColor in 127 | switch traitCollection.userInterfaceStyle { 128 | case .dark: return .black 129 | default: return .white 130 | } 131 | } 132 | 133 | /// A secondary label color that has .AA contrast vs `.systemBackground` in regular mode 134 | /// and .AAA contrast in high contrast mode 135 | public static let secondaryLabel = UIColor { (traitCollection: UITraitCollection) -> UIColor in 136 | switch (traitCollection.userInterfaceStyle, traitCollection.accessibilityContrast) { 137 | case (.dark, .high): return UIColor(rgb: 0x9B9B9B) 138 | case (_, .high): return UIColor(rgb: 0x545454) 139 | default: return UIColor(rgb: 0x757575) 140 | } 141 | } 142 | 143 | /// A quaternary label color that has 2.0 contrast vs `.systemBackground` in regular mode 144 | /// and 3.0 contrast in high contrast mode. 145 | /// 146 | /// Use it only for disabled elements that do not need to meet an WCAG contrast requirements. 147 | public static let quaternaryLabel = UIColor { (traitCollection: UITraitCollection) -> UIColor in 148 | switch (traitCollection.userInterfaceStyle, traitCollection.accessibilityContrast) { 149 | case (.dark, .high): return UIColor(rgb: 0x5a5a5a) 150 | case (.dark, _): return UIColor(rgb: 0x404040) 151 | case (_, .high): return UIColor(rgb: 0x949494) 152 | default: return UIColor(rgb: 0xB7B7B7) 153 | } 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /Tests/YCalendarPickerTests/UIKit/CalendarPickerTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CalendarPickerTests.swift 3 | // YCalendarPicker 4 | // 5 | // Created by YML on 16/11/22. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import YCalendarPicker 11 | 12 | final class CalendarPickerTests: XCTestCase { 13 | func testCalendarPickerIsNotNil() { 14 | let sut = makeSUT() 15 | XCTAssertNotNil(sut) 16 | } 17 | 18 | func testCalendarViewIsNotNil() { 19 | let sut = makeSUT() 20 | sut.appearance = .default 21 | XCTAssertNotNil(sut.calendarView) 22 | } 23 | 24 | func testCalendarViewAppearanceSetCorrectly() { 25 | let sut = makeSUT() 26 | sut.appearance = CalendarPicker.Appearance(weekdayStyle: (textColor: .red, typography: .weekday)) 27 | XCTAssertEqual(UIColor.red, sut.appearance.weekdayStyle.textColor) 28 | } 29 | 30 | func testCalendarViewMinDateSetCorrectly() { 31 | let sut = makeSUT() 32 | let minDate = Date().previousDate() 33 | sut.minimumDate = minDate 34 | XCTAssertEqual(sut.minimumDate, minDate.dateOnly) 35 | } 36 | 37 | func testCalendarViewMaxDateSetCorrectly() { 38 | let sut = makeSUT() 39 | let maxDate = Date().previousDate() 40 | sut.maximumDate = maxDate 41 | XCTAssertEqual(sut.maximumDate, maxDate.dateOnly) 42 | } 43 | 44 | func testCalendarPickerMaxMinDateSetCorrectly() { 45 | let maxDate = Date() 46 | let minDate = Date().previousDate() 47 | let sut = makeSUT(maxDate: maxDate, minDate: minDate) 48 | XCTAssertEqual(sut.calendarView.minimumDate, minDate.dateOnly) 49 | XCTAssertEqual(sut.calendarView.maximumDate, maxDate.dateOnly) 50 | } 51 | 52 | func testCalendarPickerBookedDatesSetCorrectly() { 53 | let bookedDates = [Date().dateOnly, Date().previousDate().dateOnly] 54 | let sut = makeSUT() 55 | sut.bookedDates = bookedDates 56 | XCTAssertEqual(sut.bookedDates, bookedDates) 57 | XCTAssertEqual(sut.calendarView.bookedDates, bookedDates) 58 | } 59 | 60 | func testSelectedDate() throws { 61 | let sut = makeSUT() 62 | XCTAssertNil(sut.date) 63 | let selectedDate = try XCTUnwrap(Date().date(byAddingMonth: 1)) 64 | sut.date = selectedDate 65 | XCTAssertEqual(sut.date, selectedDate.dateOnly) 66 | } 67 | 68 | func testCalendarPickerPrecedeMinDate() throws { 69 | let minDate = Date().previousDate() 70 | let sut = makeSUT(minDate: minDate) 71 | 72 | var monthView = try XCTUnwrap(sut.calendarView.getMonthView() as? MonthView) 73 | 74 | XCTAssertTrue(monthView.isPreviousButtonDisabled) 75 | 76 | XCTAssertEqual( 77 | sut.calendarView.currentDate, 78 | sut.calendarView.getCurrentDateAfterSwipe( 79 | swipeValue: CGSize(width: 10, height: 10) 80 | ) 81 | ) 82 | 83 | sut.appearance.allowPrecedeMinimumDate = true 84 | monthView = try XCTUnwrap(sut.calendarView.getMonthView() as? MonthView) 85 | XCTAssertFalse(monthView.isPreviousButtonDisabled) 86 | 87 | XCTAssertNotEqual( 88 | sut.calendarView.currentDate, 89 | sut.calendarView.getCurrentDateAfterSwipe( 90 | swipeValue: CGSize(width: 10, height: 10) 91 | ) 92 | ) 93 | } 94 | 95 | func testCalendarPickerIsNotNilForOptionalInit() { 96 | XCTAssertNotNil(makeSUTWithFailable()) 97 | } 98 | 99 | func testCalendarPickerUpdatesDate() throws { 100 | let sut = makeSUT() 101 | XCTAssertNil(sut.date) 102 | 103 | let newDate = try XCTUnwrap(Date().date(byAddingMonth: 1)) 104 | 105 | sut.calendarView.selectedDateDidChange(newDate) 106 | XCTAssertEqual(sut.date, newDate.dateOnly) 107 | 108 | sut.calendarView.selectedDateDidChange(nil) 109 | XCTAssertNil(sut.date) 110 | } 111 | 112 | func testCalendarPickerUpdatesDateOnlyFromOwnCalendarView() throws { 113 | let sut = makeSUT() 114 | let sut2 = makeSUT() 115 | XCTAssertNil(sut.date) 116 | XCTAssertNil(sut2.date) 117 | 118 | let date1 = try XCTUnwrap(Date().date(byAddingMonth: 1)?.dateOnly) 119 | let date2 = try XCTUnwrap(Date().date(byAddingMonth: -1)?.dateOnly) 120 | 121 | sut2.calendarView.selectedDateDidChange(date2) 122 | XCTAssertNil(sut.date) 123 | XCTAssertEqual(sut2.date, date2) 124 | 125 | sut.calendarView.selectedDateDidChange(date1) 126 | XCTAssertEqual(sut.date, date1) 127 | XCTAssertEqual(sut2.date, date2) 128 | 129 | sut.calendarView.monthDidChange(date2) 130 | XCTAssertNotEqual(sut.calendarView.currentDate, date2) 131 | 132 | sut2.calendarView.monthDidChange(date1) 133 | XCTAssertNotEqual(sut.calendarView.currentDate, date2) 134 | } 135 | 136 | func testIntrinsicContentSize() { 137 | let sut = makeSUT() 138 | 139 | let size = sut.intrinsicContentSize 140 | let daySize = DayView.size.outset(by: NSDirectionalEdgeInsets(all: DayView.padding)) 141 | let monthHeight = MonthView.minimumButtonSize.height 142 | let weekdayHeight = sut.appearance.weekdayStyle.typography.lineHeight + 2 * WeekdayView.verticalPadding 143 | let daysHeight = 6 * daySize.height 144 | 145 | XCTAssertEqual(size.width, 7 * daySize.width) 146 | XCTAssertEqual(size.height, monthHeight + weekdayHeight + daysHeight) 147 | } 148 | 149 | func testRespondsToDynamicTypeChanges() { 150 | let sut = makeSUT() 151 | 152 | let oldSize = sut.intrinsicContentSize 153 | 154 | // create some nested view controllers so that we can override traits 155 | let (parent, child) = makeNestedViewControllers(subview: sut) 156 | 157 | let traits = UITraitCollection(preferredContentSizeCategory: .accessibilityExtraExtraExtraLarge) // really large 158 | parent.setOverrideTraitCollection(traits, forChild: child) 159 | sut.traitCollectionDidChange(traits) 160 | 161 | let newSize = sut.intrinsicContentSize 162 | 163 | XCTAssertEqual(newSize.width, oldSize.width) 164 | XCTAssertGreaterThan(newSize.height, oldSize.height) 165 | } 166 | } 167 | 168 | private extension CalendarPickerTests { 169 | func makeSUT( 170 | firstWeekday: Int? = nil, 171 | maxDate: Date? = nil, 172 | minDate: Date? = nil, 173 | file: StaticString = #filePath, 174 | line: UInt = #line 175 | ) -> CalendarPicker { 176 | let sut = CalendarPicker(minimumDate: minDate, maximumDate: maxDate) 177 | trackForMemoryLeak(sut, file: file, line: line) 178 | return sut 179 | } 180 | 181 | func makeSUTWithFailable( 182 | firstWeekday: Int? = nil, 183 | file: StaticString = #filePath, 184 | line: UInt = #line 185 | ) -> CalendarPicker? { 186 | let sut = CalendarPicker() 187 | guard let data = try? NSKeyedArchiver.archivedData(withRootObject: sut, requiringSecureCoding: false) else { 188 | return nil 189 | } 190 | guard let coder = try? NSKeyedUnarchiver(forReadingFrom: data) else { return nil } 191 | trackForMemoryLeak(sut, file: file, line: line) 192 | return CalendarPicker(coder: coder) 193 | } 194 | 195 | /// Create nested view controllers containing the view to be tested so that we can override traits 196 | func makeNestedViewControllers( 197 | subview: UIView, 198 | file: StaticString = #filePath, 199 | line: UInt = #line 200 | ) -> (parent: UIViewController, child: UIViewController) { 201 | let parent = UIViewController() 202 | let child = UIViewController() 203 | parent.addChild(child) 204 | parent.view.addSubview(child.view) 205 | 206 | // constrain child controller view to parent 207 | child.view.constrainEdges() 208 | 209 | child.view.addSubview(subview) 210 | 211 | // constrain subview to child view center 212 | subview.constrainCenter() 213 | 214 | trackForMemoryLeak(parent, file: file, line: line) 215 | trackForMemoryLeak(child, file: file, line: line) 216 | 217 | return (parent, child) 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /Tests/YCalendarPickerTests/Extensions/CompareDateUseCaseTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CompareDateUseCaseTests.swift 3 | // YCalendarPicker 4 | // 5 | // Created by Parv Bhaskar on 15/06/22. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import YCalendarPicker 11 | 12 | final class CompareDateUseCaseTests: XCTestCase { 13 | func test_isToday_returnFalseForYesterdayDate() { 14 | XCTAssertFalse(getYesterdayDate().isToday) 15 | } 16 | 17 | func test_isToday_returnFalseForAnyDateOtherThanToday() { 18 | XCTAssertFalse(getTomorrowDate().isToday) 19 | XCTAssertFalse(getNextMonthDate().isToday) 20 | } 21 | 22 | func test_isToday_returnTrueForTodaysDate() { 23 | XCTAssertTrue(Date().isToday) 24 | } 25 | 26 | func test_isTomorrow_returnFalseForToday() { 27 | XCTAssertFalse(Date().isTomorrow) 28 | } 29 | 30 | func test_isTomorrow_returnFalseForAnyDateOtherThanTomorrow() { 31 | XCTAssertFalse(getYesterdayDate().isTomorrow) 32 | XCTAssertFalse(getNextMonthDate().isTomorrow) 33 | } 34 | 35 | func test_isTomorrow_returnTrueForTomorrow() { 36 | XCTAssertTrue(getTomorrowDate().isTomorrow) 37 | } 38 | 39 | func test_isYesterday_returnFalseForToday() { 40 | XCTAssertFalse(Date().isYesterday) 41 | } 42 | 43 | func test_isYesterday_returnFalseForAnyDateOtherThanYesterday() { 44 | XCTAssertFalse(getTomorrowDate().isYesterday) 45 | XCTAssertFalse(getNextMonthDate().isYesterday) 46 | } 47 | 48 | func test_isYesterday_returnTrueForYesterday() { 49 | XCTAssertTrue(getYesterdayDate().isYesterday) 50 | } 51 | 52 | func test_isThisWeek_returnFalseForNextWeekDate() { 53 | XCTAssertFalse(getNextWeekDate().isThisWeek) 54 | } 55 | 56 | func test_isThisWeek_returnFalseForAnyDateOtherThanThisWeek() { 57 | XCTAssertFalse(getNextMonthDate().isThisWeek) 58 | XCTAssertFalse(getlastWeekDate().isThisWeek) 59 | } 60 | 61 | func test_isThisWeek_returnTrueForCurrentWeekDate() { 62 | XCTAssertTrue(Date().isThisWeek) 63 | } 64 | 65 | func test_isNextWeek_returnFalseForCurrentWeekDate() { 66 | XCTAssertFalse(Date().isNextWeek) 67 | } 68 | 69 | func test_isNextWeek_returnFalseForAnyDateOtherThanNextWeekDate() { 70 | XCTAssertFalse(getlastWeekDate().isNextWeek) 71 | XCTAssertFalse(getNextMonthDate().isNextWeek) 72 | } 73 | 74 | func test_isNextWeek_returnTrueForNextWeekDate() { 75 | XCTAssertTrue(getNextWeekDate().isNextWeek) 76 | } 77 | 78 | func test_isLastWeek_returnFalseForCurrentWeekDate() { 79 | XCTAssertFalse(Date().isLastWeek) 80 | } 81 | 82 | func test_isLastWeek_returnFalseForAnyDateOtherThanLastWeekDate() { 83 | XCTAssertFalse(getNextWeekDate().isLastWeek) 84 | XCTAssertFalse(getNextMonthDate().isLastWeek) 85 | } 86 | 87 | func test_isLastWeek_returnTrueForLastWeekDate() { 88 | XCTAssertTrue(getlastWeekDate().isLastWeek) 89 | } 90 | 91 | func test_isThisYear_returnFalseForNextYearDate() { 92 | XCTAssertFalse(getNextYearDate().isThisYear) 93 | } 94 | 95 | func test_isThisYear_returnFalseForAnyDateOtherThanThisYearDate() { 96 | XCTAssertFalse(getLastYearDate().isThisYear) 97 | } 98 | 99 | func test_isThisYear_returnTrueForCurrentYearDate() { 100 | XCTAssertTrue(Date().isThisYear) 101 | } 102 | 103 | func test_isNextYear_returnFalseForCurrentYear() { 104 | XCTAssertFalse(Date().isNextYear) 105 | } 106 | 107 | func test_isNextYear_returnFalseForAnyDateOtherThanNextYear() { 108 | XCTAssertFalse(getLastYearDate().isNextYear) 109 | } 110 | 111 | func test_isNextYear_returnTrueForNextYear() { 112 | XCTAssertTrue(getNextYearDate().isNextYear) 113 | } 114 | 115 | func test_isLastYear_returnFalseForCurrentYear() { 116 | XCTAssertFalse(Date().isLastYear) 117 | } 118 | 119 | func test_isLastYear_returnFalseForAnyDateOtherThanLastYear() { 120 | XCTAssertFalse(getNextYearDate().isLastYear) 121 | } 122 | 123 | func test_isLastYear_returnTrueForLastYear() { 124 | XCTAssertTrue(getLastYearDate().isLastYear) 125 | } 126 | 127 | func test_isThisMonth_returnFalseForNextMonth() { 128 | XCTAssertFalse(getNextMonthDate().isThisMonth) 129 | } 130 | 131 | func test_isThisMonth_returnFalseForAnyDateOtherThanNextMonth() { 132 | XCTAssertFalse(getLastYearDate().isThisMonth) 133 | XCTAssertFalse(getNextYearDate().isThisMonth) 134 | } 135 | 136 | func test_isThisMonth_returnTrueForCurrentMonth() { 137 | XCTAssertTrue(Date().isThisMonth) 138 | } 139 | 140 | func test_isLastMonth_returnFalseForNextMonth() { 141 | XCTAssertFalse(getNextMonthDate().isLastMonth) 142 | } 143 | 144 | func test_isLastMonth_returnFalseForAnyDateOtherThanLastMonth() { 145 | XCTAssertFalse(getLastYearDate().isLastMonth) 146 | XCTAssertFalse(getNextYearDate().isLastMonth) 147 | } 148 | 149 | func test_isLastMonth_returnTrueForLastMonth() { 150 | XCTAssertTrue(getLastMonthDate().isLastMonth) 151 | } 152 | 153 | func test_isNextMonth_returnFalseForCurrentMonth() { 154 | XCTAssertFalse(Date().isNextMonth) 155 | } 156 | 157 | func test_isNextMonth_returnFalseForAnyDateOtherThanNextMonth() { 158 | XCTAssertFalse(getLastYearDate().isNextMonth) 159 | XCTAssertFalse(getNextYearDate().isNextMonth) 160 | } 161 | 162 | func test_isNextMonth_returnTrueForNextMonth() { 163 | XCTAssertTrue(getNextMonthDate().isNextMonth) 164 | } 165 | 166 | func test_isSameDay_returnFalseForDifferentDate() { 167 | XCTAssertFalse(Date().isSameDay(as: getYesterdayDate())) 168 | XCTAssertFalse(getTomorrowDate().isSameDay(as: getYesterdayDate())) 169 | } 170 | 171 | func test_isSameDay_returnTrueForSameDate() { 172 | XCTAssertTrue(Date().isSameDay(as: Date())) 173 | XCTAssertTrue(getYesterdayDate().isSameDay(as: getYesterdayDate())) 174 | } 175 | 176 | func test_isSameWeek_returnFalseForDifferentWeeks() { 177 | XCTAssertFalse(Date().isSameWeek(as: getlastWeekDate())) 178 | XCTAssertFalse(getNextWeekDate().isSameWeek(as: getlastWeekDate())) 179 | } 180 | 181 | func test_isSameWeek_returnTrueForSameWeeks() { 182 | XCTAssertTrue(Date().isSameWeek(as: Date())) 183 | XCTAssertTrue(getlastWeekDate().isSameWeek(as: getlastWeekDate())) 184 | XCTAssertTrue(getNextWeekDate().isSameWeek(as: getNextWeekDate())) 185 | } 186 | 187 | func test_isSameMonth_returnFalseForDifferentMonths() { 188 | XCTAssertFalse(Date().isSameMonth(as: getNextMonthDate())) 189 | } 190 | 191 | func test_isSameMonth_returnTrueForSameMonths() { 192 | XCTAssertTrue(Date().isSameMonth(as: Date())) 193 | XCTAssertTrue(getNextMonthDate().isSameMonth(as: getNextMonthDate())) 194 | } 195 | 196 | func test_isSameYear_returnFalseForDifferentYear() { 197 | XCTAssertFalse(Date().isSameYear(as: getNextYearDate())) 198 | XCTAssertFalse(getLastYearDate().isSameYear(as: getNextYearDate())) 199 | } 200 | 201 | func test_isSameYear_returnTrueForSameYear() { 202 | XCTAssertTrue(Date().isSameYear(as: Date())) 203 | XCTAssertTrue(getLastYearDate().isSameYear(as: getLastYearDate())) 204 | XCTAssertTrue(getNextYearDate().isSameYear(as: getNextYearDate())) 205 | } 206 | } 207 | 208 | private extension CompareDateUseCaseTests { 209 | // MARK: - Helpers 210 | func getYesterdayDate() -> Date! { 211 | Calendar.current.date(byAdding: .day, value: -1, to: Date()) 212 | } 213 | 214 | func getTomorrowDate() -> Date! { 215 | Calendar.current.date(byAdding: .day, value: 1, to: Date()) 216 | } 217 | 218 | func getNextWeekDate() -> Date! { 219 | Calendar.current.date(byAdding: .weekOfYear, value: 1, to: Date()) 220 | } 221 | 222 | func getlastWeekDate() -> Date! { 223 | Calendar.current.date(byAdding: .weekOfYear, value: -1, to: Date()) 224 | } 225 | 226 | func getNextYearDate() -> Date! { 227 | Calendar.current.date(byAdding: .year, value: 1, to: Date()) 228 | } 229 | 230 | func getLastYearDate() -> Date! { 231 | Calendar.current.date(byAdding: .year, value: -1, to: Date()) 232 | } 233 | 234 | func getNextMonthDate() -> Date! { 235 | Calendar.current.date(byAdding: .month, value: 1, to: Date()) 236 | } 237 | 238 | func getLastMonthDate() -> Date! { 239 | Calendar.current.date(byAdding: .month, value: -1, to: Date()) 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /Sources/YCalendarPicker/SwiftUI/Views/CalendarView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CalendarView.swift 3 | // YCalendarPicker 4 | // 5 | // Created by Sahil on 28/10/22. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import SwiftUI 10 | 11 | /// Swift UI month calendar picker 12 | /// 13 | /// Renamed to `CalendarView`. 14 | @available(*, deprecated, renamed: "CalendarView") 15 | public typealias YCalendarView = CalendarView 16 | 17 | /// Swift UI month calendar picker 18 | public struct CalendarView { 19 | /// Unique identifier 20 | /// 21 | /// This facilitates connection between SwiftUI & UIKit layers 22 | internal let reuseIdentifier = UUID() 23 | 24 | @State internal var currentDate = Date().startDateOfMonth() 25 | 26 | // Observes appearance changes 27 | @ObservedObject private var appearanceObserver = AppearanceObserver() 28 | @ObservedObject private var dateObserver = DateObserver() 29 | var headerDateFormat: String = "MMMMyyyy" 30 | var firstWeekday: Int = Locale.current.calendar.firstWeekday 31 | var locale: Locale = Locale.current 32 | /// Delegate for date/month change 33 | weak public var delegate: CalendarViewDelegate? 34 | 35 | /// Selected date (if any) 36 | public var date: Date? { 37 | get { 38 | self.dateObserver.date 39 | } 40 | set { 41 | self.dateObserver.date = newValue?.dateOnly 42 | } 43 | } 44 | 45 | /// Start date (if any) 46 | public var startDate: Date? 47 | 48 | /// Calendar appearance 49 | public var appearance: CalendarPicker.Appearance { 50 | get { 51 | self.appearanceObserver.appearance 52 | } 53 | set { 54 | self.appearanceObserver.appearance = newValue 55 | } 56 | } 57 | 58 | /// Optional minimum date. Dates before the minimum cannot be selected. `nil` means no minimum (default). 59 | public var minimumDate: Date? { 60 | get { 61 | dateObserver.minimumDate 62 | } 63 | set { 64 | dateObserver.minimumDate = newValue?.dateOnly 65 | onMinimumDateChanged() 66 | } 67 | } 68 | 69 | /// Optional maximum date. Dates beyond the maximum cannot be selected. `nil` means no maximum (default). 70 | public var maximumDate: Date? { 71 | get { 72 | self.dateObserver.maximumDate 73 | } 74 | set { 75 | dateObserver.maximumDate = newValue?.dateOnly 76 | onMaximumDateChanged() 77 | } 78 | } 79 | 80 | /// Optional booked dates. These dates cannot be selected. 81 | public var bookedDates: [Date] { 82 | get { 83 | dateObserver.bookedDates 84 | } 85 | set { 86 | dateObserver.bookedDates = newValue.map { $0.dateOnly } 87 | onBookedDateChanged() 88 | } 89 | } 90 | 91 | /// Initializes a SwiftUI Calendar picker 92 | /// - Parameters: 93 | /// - firstWeekday: first day of week. Default is `nil`. 94 | /// - appearance: appearance of calendar view. Default is `Appearance.default`. 95 | /// - minimumDate: minimum date to enable. Default is `nil`. 96 | /// - maximumDate: maximum date to enable. Default is `nil`. 97 | /// - startDate: start date of the calendar. Default is `nil`. 98 | /// - locale: locale for data formatting e.g Date format. Default is `nil`. 99 | public init( 100 | firstWeekday: Int? = nil, 101 | appearance: CalendarPicker.Appearance = .default, 102 | minimumDate: Date? = nil, 103 | maximumDate: Date? = nil, 104 | startDate: Date? = nil, 105 | locale: Locale? = nil 106 | ) { 107 | self.firstWeekday = firstWeekday ?? (Locale.current.calendar.firstWeekday - 1) 108 | self.appearance = appearance 109 | self.minimumDate = minimumDate?.dateOnly 110 | self.maximumDate = maximumDate?.dateOnly 111 | self.locale = locale ?? Locale.current 112 | self.startDate = startDate?.startDateOfMonth() 113 | } 114 | } 115 | 116 | extension CalendarView: View { 117 | /// :nodoc: 118 | public var body: some View { 119 | VStack(spacing: 0) { 120 | getMonthView() 121 | getWeekdayView() 122 | getDaysView() 123 | }.gesture( 124 | DragGesture().onEnded({ value in 125 | currentDate = getCurrentDateAfterSwipe(swipeValue: value.translation) 126 | }) 127 | ) 128 | .background(Color(self.appearance.backgroundColor)) 129 | .onAppear(perform: { 130 | if let getStartDate = self.startDate { 131 | currentDate = getStartDate 132 | } 133 | }) 134 | } 135 | 136 | @ViewBuilder 137 | func getMonthView() -> some View { 138 | MonthView( 139 | currentDate: $currentDate, 140 | appearance: appearance, 141 | dateFormat: headerDateFormat, 142 | minimumDate: minimumDate, 143 | maximumDate: maximumDate, 144 | locale: locale 145 | ) 146 | } 147 | 148 | @ViewBuilder 149 | func getWeekdayView() -> some View { 150 | WeekdayView(firstWeekday: firstWeekday, appearance: appearance, locale: locale) 151 | } 152 | 153 | func getDaysView() -> some View { 154 | var allDates = currentDate.getAllDatesForSelectedMonth(firstWeekIndex: firstWeekday.modulo(7)) 155 | allDates = allDates.map { dateItem -> CalendarMonthItem in 156 | var newItem = dateItem 157 | if isBooked(dateItem.date) { 158 | newItem.isBooked = true 159 | newItem.isEnabled = false 160 | } else { 161 | newItem.isEnabled = shouldEnableDate(dateItem.date, minimumDate: minimumDate, maximumDate: maximumDate) 162 | } 163 | return newItem 164 | } 165 | 166 | let selectedDate = Binding( 167 | get: { dateObserver.date }, 168 | set: { dateObserver.date = $0?.dateOnly } 169 | ) 170 | 171 | return DaysView( 172 | allDates: allDates, 173 | appearance: appearance, 174 | selectedDate: selectedDate, 175 | locale: locale, 176 | currentDate: currentDate 177 | ) 178 | .onChange(of: date) { newValue in 179 | selectedDateDidChange(newValue?.dateOnly) 180 | } 181 | .onChange(of: currentDate) { newValue in 182 | monthDidChange(newValue.dateOnly) 183 | } 184 | } 185 | } 186 | 187 | extension CalendarView { 188 | func isDateBeforeMinimumDate(_ date: Date?) -> Bool { 189 | guard let date = date, 190 | let minDate = minimumDate else { return false } 191 | 192 | return date < minDate 193 | } 194 | 195 | mutating func onMinimumDateChanged() { 196 | if isDateBeforeMinimumDate(date) { 197 | date = nil 198 | } 199 | } 200 | 201 | func isDateAfterMaximumDate(_ date: Date?) -> Bool { 202 | guard let date = date, 203 | let maxDate = maximumDate else { return false } 204 | 205 | return date > maxDate 206 | } 207 | 208 | mutating func onMaximumDateChanged() { 209 | if isDateAfterMaximumDate(date) { 210 | date = nil 211 | } 212 | } 213 | 214 | func isBooked(_ dateItem: Date?) -> Bool { 215 | guard let dateItem = dateItem else { return false } 216 | return bookedDates.contains(dateItem.dateOnly) 217 | } 218 | 219 | mutating func onBookedDateChanged() { 220 | if isBooked(date) { 221 | date = nil 222 | } 223 | } 224 | 225 | func getCurrentDateAfterSwipe(swipeValue: CGSize) -> Date { 226 | let monthCount = (swipeValue.width > 0) ? -1 : 1 227 | 228 | var isNextButtonDisabled: Bool { 229 | guard let expectedDate = currentDate.date(byAddingMonth: 1)?.dateOnly else { return true } 230 | if let maxDate = maximumDate, expectedDate > maxDate { 231 | return true 232 | } 233 | return false 234 | } 235 | 236 | var isPreviousButtonDisabled: Bool { 237 | if appearance.allowPrecedeMinimumDate { 238 | return false 239 | } 240 | // -7 as max days from previous month can be 7. 241 | // current date is first of every month 242 | guard let expectedDate = currentDate.date(byAddingDays: -7)?.dateOnly else { return true } 243 | 244 | if let minDate = minimumDate, expectedDate < minDate { 245 | return true 246 | } 247 | return false 248 | } 249 | 250 | if (monthCount == -1 && isPreviousButtonDisabled) || (monthCount == 1 && isNextButtonDisabled) { 251 | return currentDate 252 | } 253 | 254 | return currentDate.date(byAddingMonth: monthCount)?.dateOnly ?? currentDate 255 | } 256 | 257 | func shouldEnableDate(_ date: Date, minimumDate: Date?, maximumDate: Date?) -> Bool { 258 | guard minimumDate != nil || maximumDate != nil else { return true } 259 | 260 | let date = date.dateOnly 261 | if let minimumDate = minimumDate { 262 | if date < minimumDate { return false } 263 | } 264 | 265 | if let maximumDate = maximumDate { 266 | if date > maximumDate { return false } 267 | } 268 | return true 269 | } 270 | 271 | func selectedDateDidChange(_ newValue: Date?) { 272 | delegate?.calendarViewDidSelectDate(newValue) 273 | } 274 | 275 | func monthDidChange(_ newValue: Date) { 276 | delegate?.calendarViewDidChangeMonth(to: newValue) 277 | } 278 | } 279 | 280 | struct CalendarView_Previews: PreviewProvider { 281 | static var previews: some View { 282 | CalendarView( 283 | minimumDate: Date().startDateOfMonth().date(byAddingMonth: -1), 284 | maximumDate: Date().startDateOfMonth().date(byAddingMonth: 2) 285 | ) 286 | .padding(.horizontal, 16) 287 | } 288 | } 289 | -------------------------------------------------------------------------------- /Tests/YCalendarPickerTests/Extensions/CalendarMonthDateUseCaseTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CalendarMonthDateUseCaseTests.swift 3 | // YCalendarPicker 4 | // 5 | // Created by Parv Bhaskar on 17/06/22. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import XCTest 11 | @testable import YCalendarPicker 12 | 13 | final class CalendarMonthDateUseCaseTests: XCTestCase { 14 | func test_getPreviousMonthDates_deliversPreviousMonthDates() { 15 | let date1 = makeCalendarMonthItem(date: makeDate(for: "2022-05-29")) 16 | let date2 = makeCalendarMonthItem(date: makeDate(for: "2022-05-30")) 17 | let date3 = makeCalendarMonthItem(date: makeDate(for: "2022-05-31")) 18 | 19 | XCTAssertEqual(makeSUT(with: "2022-06-17").getPreviousMonthDates(firstWeekIndex: 0), [date1, date2, date3]) 20 | } 21 | 22 | func test_getNextMonthDates_deliversNextMonthDates() { 23 | let date1 = makeCalendarMonthItem(date: makeDate(for: "2022-05-01")) 24 | let date2 = makeCalendarMonthItem(date: makeDate(for: "2022-05-02")) 25 | let date3 = makeCalendarMonthItem(date: makeDate(for: "2022-05-03")) 26 | let date4 = makeCalendarMonthItem(date: makeDate(for: "2022-05-04")) 27 | let date5 = makeCalendarMonthItem(date: makeDate(for: "2022-05-05")) 28 | let date6 = makeCalendarMonthItem(date: makeDate(for: "2022-05-06")) 29 | let date7 = makeCalendarMonthItem(date: makeDate(for: "2022-05-07")) 30 | 31 | XCTAssertEqual( 32 | makeSUT(with: "2022-04-17").getNextMonthDates(firstWeekIndex: 0), 33 | [date1, date2, date3, date4, date5, date6, date7] 34 | ) 35 | } 36 | 37 | func test_getSelectedMonthDates() { 38 | XCTAssertEqual(makeSUT(with: "2022-06-17").getSelectedMonthDates().count, 30) 39 | XCTAssertEqual(makeSUT(with: "2022-07-17").getSelectedMonthDates().count, 31) 40 | XCTAssertEqual(makeSUT(with: "2023-01-01").getSelectedMonthDates().count, 31) 41 | } 42 | 43 | func test_getAllDatesForSelectedMonth() { 44 | let allDates = makeSUT(with: "2022-06-17").getAllDatesForSelectedMonth(firstWeekIndex: 0) 45 | XCTAssertEqual(allDates.count, 42) 46 | XCTAssertEqual( 47 | allDates.first, 48 | makeCalendarMonthItem(date: makeDate(for: "2022-05-29")) 49 | ) 50 | XCTAssertEqual( 51 | allDates.last, 52 | makeCalendarMonthItem(date: makeDate(for: "2022-07-09")) 53 | ) 54 | } 55 | 56 | func test_getAllDatesForSelectedMonth_weekStartsOnMonday() { 57 | let askedDate = makeSUT(with: "2022-07-17").getAllDatesForSelectedMonth(firstWeekIndex: 1)[0] 58 | XCTAssertEqual(askedDate.date.indexOfWeekday(), Weekday.monday.rawValue) 59 | } 60 | 61 | func test_get_dateConvertedToStringShouldBeExact() { 62 | let sut = makeSUT(with: "2022-06-17") 63 | 64 | XCTAssertEqual(sut.get(.day), "17") 65 | XCTAssertEqual(sut.get(.month), "6") 66 | XCTAssertEqual(sut.get(.year), "2022") 67 | } 68 | 69 | func test_indexOfWeekday() { 70 | XCTAssertEqual(makeSUT(with: "2022-06-08").indexOfWeekday(), Weekday.wednesday.rawValue) 71 | XCTAssertEqual(makeSUT(with: "2022-06-24").indexOfWeekday(), Weekday.friday.rawValue) 72 | } 73 | 74 | func test_toCalendarItem() { 75 | let sut = makeSUT(with: "2022-06-08") 76 | let item = sut.toCalendarItem() 77 | 78 | XCTAssertEqual(item.day, "8") 79 | XCTAssertEqual(item.month, "6") 80 | XCTAssertEqual(item.year, "2022") 81 | XCTAssertFalse(item.isToday) 82 | XCTAssertFalse(item.isGrayedOut) 83 | 84 | XCTAssertTrue(sut.toCalendarItem(isGrayedOut: true).isGrayedOut) 85 | } 86 | 87 | func test_startDateOfMonth_deliversStartDate() { 88 | let startDate = makeDate(for: "2022-06-01") 89 | 90 | XCTAssertEqual(makeSUT(with: "2022-06-01").startDateOfMonth(), startDate) 91 | XCTAssertEqual(makeSUT(with: "2022-06-15").startDateOfMonth(), startDate) 92 | XCTAssertEqual(makeSUT(with: "2022-06-30").startDateOfMonth(), startDate) 93 | } 94 | 95 | func test_endDateOfMonth_deliversEndDate() { 96 | let endDate = makeDate(for: "2022-06-30") 97 | 98 | XCTAssertEqual(makeSUT(with: "2022-06-01").endDateOfMonth(), endDate) 99 | XCTAssertEqual(makeSUT(with: "2022-06-15").endDateOfMonth(), endDate) 100 | XCTAssertEqual(makeSUT(with: "2022-06-30").endDateOfMonth(), endDate) 101 | } 102 | 103 | func test_previousDate_deliversPreviousDate() { 104 | XCTAssertEqual(makeSUT(with: "2022-06-01").previousDate(), makeDate(for: "2022-05-31")) 105 | XCTAssertEqual(makeSUT(with: "2022-06-15").previousDate(), makeDate(for: "2022-06-14")) 106 | XCTAssertEqual(makeSUT(with: "2022-06-30").previousDate(), makeDate(for: "2022-06-29")) 107 | } 108 | 109 | func test_nextDate_deliversNextDate() { 110 | XCTAssertEqual(makeSUT(with: "2022-06-01").nextDate(), makeDate(for: "2022-06-02")) 111 | XCTAssertEqual(makeSUT(with: "2022-06-15").nextDate(), makeDate(for: "2022-06-16")) 112 | XCTAssertEqual(makeSUT(with: "2022-06-30").nextDate(), makeDate(for: "2022-07-1")) 113 | } 114 | 115 | func test_dateByAddingDays_deliversNewDateWithAddedDays() { 116 | let sut1 = makeSUT(with: "2022-06-01") 117 | XCTAssertEqual(sut1.date(byAddingDays: 0), sut1) 118 | XCTAssertEqual(makeSUT(with: "2022-06-01").date(byAddingDays: 1), makeDate(for: "2022-06-02")) 119 | XCTAssertEqual(makeSUT(with: "2022-06-01").date(byAddingDays: 2), makeDate(for: "2022-06-03")) 120 | XCTAssertEqual(makeSUT(with: "2022-06-01").date(byAddingDays: -1), makeDate(for: "2022-05-31")) 121 | 122 | let sut2 = makeSUT(with: "2022-06-15") 123 | XCTAssertEqual(sut2.date(byAddingDays: 0), sut2) 124 | XCTAssertEqual(makeSUT(with: "2022-06-15").date(byAddingDays: 1), makeDate(for: "2022-06-16")) 125 | XCTAssertEqual(makeSUT(with: "2022-06-15").date(byAddingDays: 2), makeDate(for: "2022-06-17")) 126 | 127 | let sut3 = makeSUT(with: "2022-06-30") 128 | XCTAssertEqual(sut3.date(byAddingDays: 0), sut3) 129 | XCTAssertEqual(makeSUT(with: "2022-06-30").date(byAddingDays: 1), makeDate(for: "2022-07-01")) 130 | XCTAssertEqual(makeSUT(with: "2022-06-30").date(byAddingDays: 2), makeDate(for: "2022-07-02")) 131 | XCTAssertEqual(makeSUT(with: "2022-06-30").date(byAddingDays: -1), makeDate(for: "2022-06-29")) 132 | } 133 | 134 | func test_dateByAddingMonth_deliversNewDateWithAddedMonth() { 135 | let sut1 = makeSUT(with: "2022-06-01") 136 | XCTAssertEqual(sut1.date(byAddingMonth: 0), sut1) 137 | XCTAssertEqual(makeSUT(with: "2022-06-01").date(byAddingMonth: 1), makeDate(for: "2022-07-01")) 138 | XCTAssertEqual(makeSUT(with: "2022-06-01").date(byAddingMonth: 2), makeDate(for: "2022-08-01")) 139 | 140 | let sut2 = makeSUT(with: "2022-06-15") 141 | XCTAssertEqual(sut2.date(byAddingMonth: 0), sut2) 142 | XCTAssertEqual(makeSUT(with: "2022-06-15").date(byAddingMonth: 1), makeDate(for: "2022-07-15")) 143 | XCTAssertEqual(makeSUT(with: "2022-06-15").date(byAddingMonth: 2), makeDate(for: "2022-08-15")) 144 | 145 | let sut3 = makeSUT(with: "2022-06-30") 146 | XCTAssertEqual(sut3.date(byAddingMonth: 0), sut3) 147 | XCTAssertEqual(makeSUT(with: "2022-06-30").date(byAddingMonth: 1), makeDate(for: "2022-07-30")) 148 | XCTAssertEqual(makeSUT(with: "2022-06-30").date(byAddingMonth: 2), makeDate(for: "2022-08-30")) 149 | XCTAssertEqual(makeSUT(with: "2022-06-30").date(byAddingMonth: -1), makeDate(for: "2022-05-30")) 150 | } 151 | 152 | func test_dateByAddingYear_deliversNewDateWithAddedYear() { 153 | let sut1 = makeSUT(with: "2022-06-1") 154 | XCTAssertEqual(sut1.date(byAddingYear: 0), sut1) 155 | XCTAssertEqual(makeSUT(with: "2022-06-01").date(byAddingYear: 1), makeDate(for: "2023-06-01")) 156 | XCTAssertEqual(makeSUT(with: "2022-06-01").date(byAddingYear: 2), makeDate(for: "2024-06-01")) 157 | 158 | let sut2 = makeSUT(with: "2022-06-15") 159 | XCTAssertEqual(sut2.date(byAddingYear: 0), sut2) 160 | XCTAssertEqual(makeSUT(with: "2022-06-15").date(byAddingYear: 1), makeDate(for: "2023-06-15")) 161 | XCTAssertEqual(makeSUT(with: "2022-06-15").date(byAddingYear: 2), makeDate(for: "2024-06-15")) 162 | 163 | let sut3 = makeSUT(with: "2022-06-30") 164 | XCTAssertEqual(sut3.date(byAddingYear: 0), sut3) 165 | XCTAssertEqual(makeSUT(with: "2022-06-30").date(byAddingYear: 1), makeDate(for: "2023-06-30")) 166 | XCTAssertEqual(makeSUT(with: "2022-06-30").date(byAddingYear: 2), makeDate(for: "2024-06-30")) 167 | XCTAssertEqual(makeSUT(with: "2022-06-30").date(byAddingYear: -1), makeDate(for: "2021-06-30")) 168 | } 169 | 170 | func test_numberOfDaysInMonth_deliversNumberOfDaysInMonth() { 171 | XCTAssertEqual(makeSUT(with: "2022-02-15").numberOfDaysInMonth(), 28) 172 | XCTAssertEqual(makeSUT(with: "2022-06-15").numberOfDaysInMonth(), 30) 173 | XCTAssertEqual(makeSUT(with: "2022-07-15").numberOfDaysInMonth(), 31) 174 | } 175 | } 176 | 177 | private extension CalendarMonthDateUseCaseTests { 178 | // MARK: - Helpers 179 | 180 | enum Weekday: Int { 181 | case monday = 2 182 | case wednesday = 4 183 | case friday = 6 184 | } 185 | 186 | func makeSUT(with dateString: String) -> Date { 187 | let dateFormatter = DateFormatter() 188 | dateFormatter.dateFormat = "yyyy-MM-dd" 189 | 190 | guard let date = dateFormatter.date(from: dateString) else { 191 | XCTFail("Expected to create SUT, but failed.") 192 | return Date() 193 | } 194 | 195 | return date 196 | } 197 | 198 | func makeDate(for dateString: String) -> Date { 199 | let dateFormatter = DateFormatter() 200 | dateFormatter.dateFormat = "yyyy-MM-dd" 201 | 202 | guard let date = dateFormatter.date(from: dateString) else { 203 | XCTFail("Expected to create date, but failed.") 204 | return Date() 205 | } 206 | 207 | return date 208 | } 209 | 210 | func makeCalendarMonthItem( 211 | date: Date 212 | ) -> CalendarMonthItem { 213 | date.toCalendarItem(isGrayedOut: true) 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /Tests/YCalendarPickerTests/Extensions/DateToStringTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DateToStringTests.swift 3 | // YCalendarPicker 4 | // 5 | // Created by Sanjib Chakraborty on 20/06/22. 6 | // Copyright © 2023 Y Media Labs. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import YCalendarPicker 11 | 12 | final class DateToStringTests: XCTestCase { 13 | func test_toStringWithFormatType() { 14 | let timeZone = TimeZone(abbreviation: "IST") 15 | XCTAssertNotNil(timeZone) 16 | 17 | let sut: Date! = Calendar.current.date( 18 | from: DateComponents(timeZone: timeZone, year: 2022, month: 06, day: 05, hour: 10, minute: 42, second: 52) 19 | ) 20 | XCTAssertNotNil(sut) 21 | 22 | var string: String? 23 | 24 | // format string - "MM/dd/yyyy" 25 | string = sut.toString(withFormatType: DateFormatType.MMddyyyy(separator: "/")) 26 | XCTAssertEqual(string, "06/05/2022") 27 | 28 | // format string - "dd-MM-yyyy" 29 | string = sut.toString(withFormatType: DateFormatType.ddMMyyyy(separator: "-")) 30 | XCTAssertEqual(string, "05-06-2022") 31 | 32 | // format string - "yyyy" 33 | string = sut.toString(withFormatType: DateFormatType.yyyy) 34 | XCTAssertEqual(string, "2022") 35 | 36 | // format string - "MMMM" 37 | string = sut.toString(withFormatType: DateFormatType.MMMM) 38 | XCTAssertEqual(string, "June") 39 | 40 | // format string - "EEEE" 41 | string = sut.toString(withFormatType: DateFormatType.EEEE) 42 | XCTAssertEqual(string, "Sunday") 43 | 44 | // format string - "yyyyMMddZ" 45 | string = sut.toString(withFormatType: DateFormatType.yyyyMMddZ, timeZone: timeZone) 46 | XCTAssertEqual(string, "20220605+0530") 47 | 48 | // format string - "HH_mm_ss" 49 | string = sut.toString(withFormatType: DateFormatType.HHmmss(separator: ":"), timeZone: timeZone) 50 | XCTAssertEqual(string, "10:42:52") 51 | 52 | // format string - "HH_mm" 53 | string = sut.toString(withFormatType: DateFormatType.HHmm(separator: ":"), timeZone: timeZone) 54 | XCTAssertEqual(string, "10:42") 55 | 56 | // format string - yy_MM_dd 57 | string = sut.toString(withFormatType: DateFormatType.yyMMdd(separator: "-")) 58 | XCTAssertEqual(string, "22-06-05") 59 | 60 | // format string - MM_dd_yy 61 | string = sut.toString(withFormatType: DateFormatType.MMddyy(separator: "-")) 62 | XCTAssertEqual(string, "06-05-22") 63 | 64 | // format string - yyyy_MM_dd 65 | string = sut.toString(withFormatType: DateFormatType.yyyyMMdd(separator: "-")) 66 | XCTAssertEqual(string, "2022-06-05") 67 | 68 | // format string - dd_MM_yyyy 69 | string = sut.toString(withFormatType: DateFormatType.ddMMyyyy(separator: "-")) 70 | XCTAssertEqual(string, "05-06-2022") 71 | 72 | // format string - dd_MM_yy 73 | string = sut.toString(withFormatType: DateFormatType.ddMMyy(separator: "-")) 74 | XCTAssertEqual(string, "05-06-22") 75 | 76 | // format string - yyyy_MM 77 | string = sut.toString(withFormatType: DateFormatType.yyyyMM(separator: "-")) 78 | XCTAssertEqual(string, "2022-06") 79 | } 80 | 81 | func test_toStringWithFormatTypeCustom() { 82 | let timeZone = TimeZone(abbreviation: "IST") 83 | XCTAssertNotNil(timeZone) 84 | 85 | let sut: Date! = Calendar.current.date( 86 | from: DateComponents(timeZone: timeZone, year: 2022, month: 06, day: 05, hour: 10, minute: 42, second: 52) 87 | ) 88 | XCTAssertNotNil(sut) 89 | 90 | var string: String? 91 | 92 | // format string - "yyyy-MM-dd'T'HH:mmZ" 93 | string = sut.toString(withFormatType: DateFormatType.custom(format: "yyyy-MM-dd'T'HH:mmZ"), timeZone: timeZone) 94 | XCTAssertEqual(string, "2022-06-05T10:42+0530") 95 | 96 | // format string - "yyyy-MM-dd'T'HH:mm:ss.SSSZ" 97 | string = sut.toString( 98 | withFormatType: DateFormatType.custom(format: "yyyy-MM-dd'T'HH:mm:ss.SSSZ"), 99 | timeZone: timeZone 100 | ) 101 | XCTAssertEqual(string, "2022-06-05T10:42:52.000+0530") 102 | 103 | // format string - "EEE, d MMM yyyy HH:mm:ss ZZZ". HTTP header date format 104 | string = sut.toString(withFormatType: DateFormatType.httpHeader, timeZone: timeZone) 105 | XCTAssertEqual(string, "Sun, 5 Jun 2022 10:42:52 +0530") 106 | 107 | // format string - "yyyy-MM-dd'T'HH:mm:ssZZZZZ" 108 | string = sut.toString( 109 | withFormatType: DateFormatType.custom(format: "yyyy-MM-dd'T'HH:mm:ssZZZZZ"), 110 | timeZone: timeZone 111 | ) 112 | XCTAssertEqual(string, "2022-06-05T10:42:52+05:30") 113 | 114 | // format string - "yyyy-MM-dd'T'HH:mm:ssZZZZZ" 115 | string = sut.toString( 116 | withFormatType: DateFormatType.custom( 117 | format: "yyyy_MM_dd'T'HH:mm:ssZZZZZ", 118 | separator: "-" 119 | ), 120 | timeZone: timeZone 121 | ) 122 | XCTAssertEqual(string, "2022-06-05T10:42:52+05:30") 123 | 124 | // format string - "dd MMM yyyy, h:mm a" 125 | string = sut.toString(withFormatType: DateFormatType.custom(format: "dd MMM yyyy, h:mm a"), timeZone: timeZone) 126 | XCTAssertEqual(string?.lowercased(), "05 Jun 2022, 10:42 AM".lowercased()) 127 | 128 | // format string - "dd MMM yyyy, h:mm a" in PST timezone 129 | string = sut.toString( 130 | withFormatType: DateFormatType.custom(format: "dd MMM yyyy, h:mm a"), 131 | timeZone: TimeZone(abbreviation: "PST") 132 | ) 133 | XCTAssertEqual(string?.lowercased(), "04 Jun 2022, 10:12 PM".lowercased()) 134 | } 135 | 136 | func test_toStringWithFormatTypeISO() { 137 | let timeZone = TimeZone(abbreviation: "IST") 138 | XCTAssertNotNil(timeZone) 139 | 140 | let sut: Date! = Calendar.current.date( 141 | from: DateComponents(timeZone: timeZone, year: 2022, month: 06, day: 05, hour: 10, minute: 42, second: 52) 142 | ) 143 | XCTAssertNotNil(sut) 144 | 145 | var string: String? 146 | 147 | // format string - isoYear - "yyyy" 148 | string = sut.toString(withFormatType: DateFormatType.isoYear) 149 | XCTAssertEqual(string, "2022") 150 | 151 | // format string - isoYearMonth - "yyyy-MM" 152 | string = sut.toString(withFormatType: DateFormatType.isoYearMonth) 153 | XCTAssertEqual(string, "2022-06") 154 | 155 | // format string - isoDate - "yyyy-MM-dd" 156 | string = sut.toString(withFormatType: DateFormatType.isoDate) 157 | XCTAssertEqual(string, "2022-06-05") 158 | 159 | // format string - isoDateTime - "yyyy-MM-dd'T'HH:mm:ssZ" 160 | string = sut.toString(withFormatType: DateFormatType.isoDateTime, timeZone: timeZone) 161 | XCTAssertEqual(string, "2022-06-05T10:42:52+0530") 162 | 163 | // format string - isoDateTimeFull - "yyyy-MM-dd'T'HH:mm:ss.SSSZ" 164 | string = sut.toString(withFormatType: DateFormatType.isoDateTimeFull, timeZone: timeZone) 165 | XCTAssertEqual(string, "2022-06-05T10:42:52.000+0530") 166 | } 167 | 168 | func test_toStringWithFormatTypeJulianDay() { 169 | let timeZone = TimeZone(abbreviation: "IST") 170 | XCTAssertNotNil(timeZone) 171 | 172 | let sut: Date! = Calendar.current.date( 173 | from: DateComponents(timeZone: timeZone, year: 2022, month: 06, day: 05, hour: 10, minute: 42, second: 52) 174 | ) 175 | XCTAssertNotNil(sut) 176 | 177 | var string: String? 178 | 179 | // format string - yy_DDD 180 | string = sut.toString(withFormatType: DateFormatType.yyDDD(separator: "-")) 181 | XCTAssertEqual(string, "22-156") 182 | 183 | // format string - DDD_yy 184 | string = sut.toString(withFormatType: DateFormatType.DDDyy(separator: "-")) 185 | XCTAssertEqual(string, "156-22") 186 | 187 | // format string - yyyy_DDD 188 | string = sut.toString(withFormatType: DateFormatType.yyyyDDD(separator: "-")) 189 | XCTAssertEqual(string, "2022-156") 190 | 191 | // format string - DDD_yyyy 192 | string = sut.toString(withFormatType: DateFormatType.DDDyyyy(separator: "-")) 193 | XCTAssertEqual(string, "156-2022") 194 | } 195 | 196 | func test_toStringWithFormatTypeThreeLetterAbbreviationOfTheMonth() { 197 | let timeZone = TimeZone(abbreviation: "IST") 198 | XCTAssertNotNil(timeZone) 199 | 200 | let sut: Date! = Calendar.current.date( 201 | from: DateComponents(timeZone: timeZone, year: 2022, month: 06, day: 05, hour: 10, minute: 42, second: 52) 202 | ) 203 | XCTAssertNotNil(sut) 204 | 205 | var string: String? 206 | 207 | // format string - yy_MMM_dd 208 | string = sut.toString(withFormatType: DateFormatType.yyMMMdd(separator: "-")) 209 | XCTAssertEqual(string, "22-Jun-05") 210 | 211 | // format string - dd_MMM_yy 212 | string = sut.toString(withFormatType: DateFormatType.ddMMMyy(separator: "-")) 213 | XCTAssertEqual(string, "05-Jun-22") 214 | 215 | // format string - MMM_dd_yy 216 | string = sut.toString(withFormatType: DateFormatType.MMMddyy(separator: "-")) 217 | XCTAssertEqual(string, "Jun-05-22") 218 | 219 | // format string - yyyy_MMM_dd 220 | string = sut.toString(withFormatType: DateFormatType.yyyyMMMdd(separator: "-")) 221 | XCTAssertEqual(string, "2022-Jun-05") 222 | 223 | // format string - dd_MMM_yyyy 224 | string = sut.toString(withFormatType: DateFormatType.ddMMMyyyy(separator: "-")) 225 | XCTAssertEqual(string, "05-Jun-2022") 226 | 227 | // format string - MMM_dd_yyyy 228 | string = sut.toString(withFormatType: DateFormatType.MMMddyyyy(separator: "-")) 229 | XCTAssertEqual(string, "Jun-05-2022") 230 | } 231 | 232 | func test_toStringWithTemplate() { 233 | let locale = Locale(identifier: "en_GB") 234 | 235 | let sut: Date! = Calendar.current.date( 236 | from: DateComponents(year: 2022, month: 06, day: 05, hour: 10, minute: 42, second: 52) 237 | ) 238 | XCTAssertNotNil(sut) 239 | 240 | var string: String? 241 | 242 | // Template - "ddMMyyyyHHmmss" 243 | string = sut.toString(withTemplate: "ddMMyyyyHHmmss", locale: locale) 244 | XCTAssertEqual(string, "05/06/2022, 10:42:52") 245 | 246 | // Template - "ddMMMyyyy" 247 | string = sut.toString(withTemplate: "ddMMMyyyy", locale: locale) 248 | XCTAssertEqual(string, "05 Jun 2022") 249 | } 250 | } 251 | 252 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Y—Calendar Picker](https://user-images.githubusercontent.com/1037520/220841655-8784e89b-1836-4fc5-8bc9-40c758b6d6f5.jpeg) 2 | [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fyml-org%2Fycalendarpicker-ios%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/yml-org/ycalendarpicker-ios) [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fyml-org%2Fycalendarpicker-ios%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/yml-org/ycalendarpicker-ios) 3 | _An easy-to-use and highly customizable month calendar._ 4 | 5 | This frameworks provides a month calendar picker with both UIKit and SwiftUI variants. 6 | 7 | ![Calendar Picker demo animation](https://user-images.githubusercontent.com/1037520/220844728-b6bcf4bd-74a0-4618-b34d-7a1176b96876.gif) 8 | 9 | Licensing 10 | ---------- 11 | Y—CalendarPicker is licensed under the [Apache 2.0 license](LICENSE). 12 | 13 | Documentation 14 | ---------- 15 | 16 | Documentation is automatically generated from source code comments and rendered as a static website hosted via GitHub Pages at: https://yml-org.github.io/ycalendarpicker-ios/ 17 | 18 | Usage 19 | ---------- 20 | 21 | ### `CalendarPicker` (UIKit) 22 | `CalendarPicker` is a subclass of `UIControl` with an api similar to `UIDatePicker`. 23 | 24 | ### `CalendarView` (SwiftUI) 25 | `CalendarView` is a struct that conforms to the SwiftUI `View` protocol. 26 | 27 | ### Initializers 28 | 29 | Both `CalendarPicker` and `CalendarView` can be initialized with the same five parameters (`CalendarPicker` uses `CalendarView` internally): 30 | 31 | ```swift 32 | init( 33 | firstWeekday: Int? = nil, 34 | appearance: Appearance = .default, 35 | minimumDate: Date? = nil, 36 | maximumDate: Date? = nil, 37 | startDate: Date? = nil, 38 | locale: Locale? = nil 39 | ) 40 | ``` 41 | The standard initializer lets you specify the first day of the week, appearance, optional minimum and maximum dates, start date of the calendar and the locale, although it provides sensible defaults for all of these. 42 | 43 | `CalendarPicker` has an additional initializer: 44 | 45 | ```swift 46 | init?(coder: NSCoder) 47 | ``` 48 | For use in Interface Builder or Storyboards (although we recommend that you build your UI in code). 49 | 50 | A calendar picker created this way begins with the default appearance, but you can customize it at runtime by updating its `appearance` property. 51 | 52 | ### Customization 53 | 54 | `CalendarPicker` and `CalendarView` both have an `appearance` property of type `Appearance`. 55 | 56 | `Appearance` lets you customize the picker's appearance. You have full control over the colors, typographies, and images used. The default appearance is dark mode compatible and WCAG 2.0 AA compliant for color contrast. 57 | 58 | ```swift 59 | /// Appearance for CalendarPicker that contains typography and color properties 60 | public struct Appearance { 61 | /// Appearance for days within current month 62 | public var normalDayAppearance: Day 63 | /// Appearance for days outside current month 64 | public var grayedDayAppearance: Day 65 | /// Appearance for today 66 | public var todayAppearance: Day 67 | /// Appearance for selected day 68 | public var selectedDayAppearance: Day 69 | /// Appearance for disabled day 70 | public var disabledDayAppearance: Day 71 | /// Appearance for booked day 72 | public var bookedDayAppearance: Day 73 | /// Foreground color and typography for weekdays 74 | public var weekdayStyle: (textColor: UIColor, typography: Typography) 75 | /// Image for previous month button 76 | /// 77 | /// Images with template rendering mode will be tinted to `monthForegroundColor`. 78 | public var previousImage: UIImage? 79 | /// Image for next month button 80 | /// 81 | /// Images with template rendering mode will be tinted to `monthForegroundColor`. 82 | public var nextImage: UIImage? 83 | /// Foreground color and typography for month (and year) 84 | public var monthStyle: (textColor: UIColor, typography: Typography) 85 | /// Background color for calendar view 86 | public var backgroundColor: UIColor 87 | } 88 | ``` 89 | 90 | The calendar has six different appearances for drawing individual days: 91 | 92 | 1. **normal**: for unselected dates within the current month 93 | 2. **grayed**: for unselected dates that fall before or after the current month (because we always show 6 rows or 42 days) 94 | 3. **today**: for today's date when unselected 95 | 4. **selected**: for the currently selected date (if any) 96 | 5. **booked**: for any dates that are already booked. These days are not selectable. 97 | 6. **disabled**: for any dates before `minimumDate` or after `maximumDate`. These days are not selectable. 98 | 99 | The appearance of each of these types of days can be customized using the `Day` structure. 100 | ```swift 101 | /// Appearance for Date 102 | public struct Day { 103 | /// Typography for day view 104 | public var typography: Typography 105 | /// Foreground color for day view 106 | public var foregroundColor: UIColor 107 | /// Background color for day view 108 | public var backgroundColor: UIColor 109 | /// Border color for day view 110 | public var borderColor: UIColor 111 | /// Border width for day view 112 | public var borderWidth: CGFloat 113 | /// Hides day view (if true) 114 | public var isHidden: Bool 115 | } 116 | ``` 117 | 118 | ### Usage (UIKit) 119 | 120 | 1. **How to import?** 121 | 122 | ```swift 123 | import YCalendarPicker 124 | ``` 125 | 126 | 2. **Create a calendar picker** 127 | 128 | ```swift 129 | // Create calendar picker with default values 130 | let calendarPicker = CalendarPicker() 131 | 132 | // add calendar picker to any view 133 | view.addSubview(calendarPicker) 134 | ``` 135 | 136 | 3. **Customize and then update appearance** 137 | 138 | ```swift 139 | // Create a calendar picker with the weekday text color set to green 140 | var calendarPicker = CalendarPicker( 141 | appearance: CalendarPicker.Appearance(weekdayStyle: (textColor: .green, typography: .weekday) 142 | ) 143 | 144 | // Change the weekday text color to red 145 | calendarPicker.appearance.weekdayStyle.textColor = .red 146 | ``` 147 | 148 | 4. **Update Calendar properties** 149 | 150 | ```swift 151 | // set minimum date to yesterday and maximum date to tomorrow 152 | calendarPicker.minimumDate = Date().previousDate() 153 | calendarPicker.maximumDate = Date().nextDate() 154 | 155 | // select today's date 156 | calendarPicker.date = Date() 157 | ``` 158 | 159 | 5. **Receive change notifications** 160 | 161 | To be notified when the date changes, simply use the target-action mechanism exactly as you would for `UIDatePicker`. 162 | 163 | ```swift 164 | // Add target with action 165 | calendarPicker.addTarget(self, action: #selector(onDateChange), for: .valueChanged) 166 | ``` 167 | 168 | If you wish to know when the user has switched months (via the previous and next buttons), you can use the picker's `delegate` property and conform to the `CalendarPickerDelegate` protocol. 169 | 170 | ```swift 171 | // Create calendar picker 172 | let calendarPicker = CalendarPicker() 173 | 174 | // set the delegate to be notified when the month changes 175 | calendarPicker.delegate = self 176 | ``` 177 | 178 | ```swift 179 | // This will notify when the user presses the next/previous buttons 180 | extension DemoViewController: CalendarPickerDelegate { 181 | func calendarPicker(_ calendarPicker: CalendarPicker, didChangeMonthTo date: Date) { 182 | print("New month: \(date)") 183 | } 184 | } 185 | ``` 186 | 187 | ### Usage (SwiftUI) 188 | 189 | Our calendar picker also supports Swift UI! 190 | 191 | 1. **How to import?** 192 | 193 | ```swift 194 | import YCalendarPicker 195 | ``` 196 | 197 | 2. **Create a calendar view** 198 | `CalendarView` conforms to SwiftUI's `View` protocol so we can directly integrate `CalendarView` with any SwiftUI view. 199 | ```swift 200 | var body: some View { 201 | CalendarView() 202 | } 203 | ``` 204 | 205 | 3. **Customize and then update appearance** 206 | 207 | ```swift 208 | struct CustomCalendar { 209 | @State var calendar: CalendarView = { 210 | // Create a calendar picker with the weekday text color set to green 211 | var calendar = CalendarView() 212 | calendar.appearance.weekdayStyle.textColor = .green 213 | return calendar 214 | }() 215 | } 216 | 217 | extension CustomCalendar: View { 218 | public var body: some View { 219 | VStack { 220 | calendar 221 | Button("Go Red") { 222 | // Change the weekday text color to red 223 | calendar.appearance.weekdayStyle.textColor = .red 224 | } 225 | } 226 | } 227 | } 228 | ``` 229 | 230 | 4. **Update Calendar properties** 231 | 232 | ```swift 233 | struct CustomCalendar { 234 | @State var calendar = CalendarView() 235 | } 236 | 237 | extension CustomCalendar: View { 238 | var body: some View { 239 | VStack { 240 | calendar 241 | Button("Set Min/Max") { 242 | // set minimum date to yesterday and maximum date to tomorrow 243 | calendar.minimumDate = Date().previousDate() 244 | calendar.maximumDate = Date().nextDate() 245 | } 246 | Button("Select Today") { 247 | // select today's date 248 | calendar.date = Date() 249 | } 250 | } 251 | } 252 | } 253 | ``` 254 | 255 | 5. **Receive change notifications** 256 | To be notified when the user selects a date or changes the month, you can use the `delegate` property and conform to the `CalendarViewDelegate` protocol. 257 | 258 | ```swift 259 | extension DemoView: CalendarViewDelegate { 260 | // Date was selected 261 | func calendarViewDidSelectDate(_ date: Date?) { 262 | if let date { 263 | print("Selected: \(date)") 264 | } else { 265 | print("Selection cleared") 266 | } 267 | } 268 | 269 | // Month was changed 270 | func calendarViewDidChangeMonth(to date: Date) { 271 | print("New month: \(date)") 272 | } 273 | } 274 | ``` 275 | 276 | Dependencies 277 | ---------- 278 | 279 | Y—CalendarPicker depends upon our [Y—CoreUI](https://github.com/yml-org/ycoreui) and [Y—MatterType](https://github.com/yml-org/ymattertype) frameworks (both also open source and Apache 2.0 licensed). 280 | 281 | Installation 282 | ---------- 283 | 284 | You can add Y—CalendarPicker to an Xcode project by adding it as a package dependency. 285 | 286 | 1. From the **File** menu, select **Add Packages...** 287 | 2. Enter "[https://github.com/yml-org/ycalendarpicker-ios](https://github.com/yml-org/ycalendarpicker-ios)" into the package repository URL text field 288 | 3. Click **Add Package** 289 | 290 | Contributing to Y—CalendarPicker 291 | ---------- 292 | 293 | ### Requirements 294 | 295 | #### SwiftLint (linter) 296 | ``` 297 | brew install swiftlint 298 | ``` 299 | 300 | #### Jazzy (documentation) 301 | ``` 302 | sudo gem install jazzy 303 | ``` 304 | 305 | ### Setup 306 | 307 | Clone the repo and open `Package.swift` in Xcode. 308 | 309 | ### Versioning strategy 310 | 311 | We utilize [semantic versioning](https://semver.org). 312 | 313 | ``` 314 | {major}.{minor}.{patch} 315 | ``` 316 | 317 | e.g. 318 | 319 | ``` 320 | 1.0.5 321 | ``` 322 | 323 | ### Branching strategy 324 | 325 | We utilize a simplified branching strategy for our frameworks. 326 | 327 | * main (and development) branch is `main` 328 | * both feature (and bugfix) branches branch off of `main` 329 | * feature (and bugfix) branches are merged back into `main` as they are completed and approved. 330 | * `main` gets tagged with an updated version # for each release 331 | 332 | ### Branch naming conventions: 333 | 334 | ``` 335 | feature/{ticket-number}-{short-description} 336 | bugfix/{ticket-number}-{short-description} 337 | ``` 338 | e.g. 339 | ``` 340 | feature/CM-44-button 341 | bugfix/CM-236-textview-color 342 | ``` 343 | 344 | ### Pull Requests 345 | 346 | Prior to submitting a pull request you should: 347 | 348 | 1. Compile and ensure there are no warnings and no errors. 349 | 2. Run all unit tests and confirm that everything passes. 350 | 3. Check unit test coverage and confirm that all new / modified code is fully covered. 351 | 4. Run `swiftlint` from the command line and confirm that there are no violations. 352 | 5. Run `jazzy` from the command line and confirm that you have 100% documentation coverage. 353 | 6. Consider using `git rebase -i HEAD~{commit-count}` to squash your last {commit-count} commits together into functional chunks. 354 | 7. If HEAD of the parent branch (typically `main`) has been updated since you created your branch, use `git rebase main` to rebase your branch. 355 | * _Never_ merge the parent branch into your branch. 356 | * _Always_ rebase your branch off of the parent branch. 357 | 358 | When submitting a pull request: 359 | 360 | * Use the [provided pull request template](.github/pull_request_template.md) and populate the Introduction, Purpose, and Scope fields at a minimum. 361 | * If you're submitting before and after screenshots, movies, or GIF's, enter them in a two-column table so that they can be viewed side-by-side. 362 | 363 | When merging a pull request: 364 | 365 | * Make sure the branch is rebased (not merged) off of the latest HEAD from the parent branch. This keeps our git history easy to read and understand. 366 | * Make sure the branch is deleted upon merge (should be automatic). 367 | 368 | ### Releasing new versions 369 | * Tag the corresponding commit with the new version (e.g. `1.0.5`) 370 | * Push the local tag to remote 371 | 372 | Generating Documentation (via Jazzy) 373 | ---------- 374 | 375 | You can generate your own local set of documentation directly from the source code using the following command from Terminal: 376 | ``` 377 | jazzy 378 | ``` 379 | This generates a set of documentation under `/docs`. The default configuration is set in the default config file `.jazzy.yaml` file. 380 | 381 | To view additional documentation options type: 382 | ``` 383 | jazzy --help 384 | ``` 385 | A GitHub Action automatically runs each time a commit is pushed to `main` that runs Jazzy to generate the documentation for our GitHub page at: https://yml-org.github.io/ycalendarpicker-ios/ 386 | 387 | --------------------------------------------------------------------------------