diff --git a/VDS.xcodeproj/project.pbxproj b/VDS.xcodeproj/project.pbxproj index 33d4b421..10329d45 100644 --- a/VDS.xcodeproj/project.pbxproj +++ b/VDS.xcodeproj/project.pbxproj @@ -10,16 +10,23 @@ 1808BEBC2BA41C3200129230 /* CarouselScrollbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1808BEBB2BA41C3200129230 /* CarouselScrollbar.swift */; }; 1808BEC02BA456B700129230 /* CarouselScrollbarChangeLog.txt in Resources */ = {isa = PBXBuildFile; fileRef = 1808BEBF2BA456B700129230 /* CarouselScrollbarChangeLog.txt */; }; 1832AC572BA0791D008AE476 /* BreadcrumbCellItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1832AC562BA0791D008AE476 /* BreadcrumbCellItem.swift */; }; + 1842B1DF2BECE28B0021AFCA /* CalendarDateViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1842B1DE2BECE28B0021AFCA /* CalendarDateViewCell.swift */; }; + 1842B1E12BECE7B70021AFCA /* CalendarHeaderReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1842B1E02BECE7B70021AFCA /* CalendarHeaderReusableView.swift */; }; + 1842B1E32BECF0A20021AFCA /* CalendarFooterReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1842B1E22BECF0A10021AFCA /* CalendarFooterReusableView.swift */; }; 18450CF12BA1B19C009FDF2A /* BreadcrumbsChangeLog.txt in Resources */ = {isa = PBXBuildFile; fileRef = 18450CF02BA1B19C009FDF2A /* BreadcrumbsChangeLog.txt */; }; 1855EC662BAABF2A002ACAC2 /* BreadcrumbItemModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1855EC652BAABF2A002ACAC2 /* BreadcrumbItemModel.swift */; }; 186B2A8A2B88DA7F001AB71F /* TextAreaChangeLog.txt in Resources */ = {isa = PBXBuildFile; fileRef = 186B2A892B88DA7F001AB71F /* TextAreaChangeLog.txt */; }; 186D13CB2BBA8B1500986B53 /* DropdownSelect.swift in Sources */ = {isa = PBXBuildFile; fileRef = 186D13CA2BBA8B1500986B53 /* DropdownSelect.swift */; }; 186D13CF2BBC36EF00986B53 /* DropdownSelectChangeLog.txt in Resources */ = {isa = PBXBuildFile; fileRef = 186D13CE2BBC36EE00986B53 /* DropdownSelectChangeLog.txt */; }; 18792A902B7431F2008C0D29 /* ButtonIconBadgeIndicatorModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18792A8F2B7431F2008C0D29 /* ButtonIconBadgeIndicatorModel.swift */; }; + 18A3F12A2BD9298900498E4A /* Calendar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18A3F1292BD9298900498E4A /* Calendar.swift */; }; 18A65A022B96E848006602CC /* Breadcrumbs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18A65A012B96E848006602CC /* Breadcrumbs.swift */; }; 18A65A042B96F050006602CC /* BreadcrumbItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18A65A032B96F050006602CC /* BreadcrumbItem.swift */; }; 18B463A42BBD3C46005C4528 /* DropdownOptionModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18B463A32BBD3C46005C4528 /* DropdownOptionModel.swift */; }; 18BDEE822B75316E00452358 /* ButtonIconChangeLog.txt in Resources */ = {isa = PBXBuildFile; fileRef = 18BDEE812B75316E00452358 /* ButtonIconChangeLog.txt */; }; + 18FEA1AD2BDD137500A56439 /* CalendarIndicatorModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18FEA1AC2BDD137500A56439 /* CalendarIndicatorModel.swift */; }; + 18FEA1B52BE0E63600A56439 /* Date+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18FEA1B42BE0E63600A56439 /* Date+Extension.swift */; }; + 18FEA1B92BE1301700A56439 /* CalendarChangeLog.txt in Resources */ = {isa = PBXBuildFile; fileRef = 18FEA1B82BE1301700A56439 /* CalendarChangeLog.txt */; }; 445BA07829C07B3D0036A7C5 /* Notification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 445BA07729C07B3D0036A7C5 /* Notification.swift */; }; 44604AD429CE186A00E62B51 /* NotificationButtonModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44604AD329CE186A00E62B51 /* NotificationButtonModel.swift */; }; 44604AD729CE196600E62B51 /* Line.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44604AD629CE196600E62B51 /* Line.swift */; }; @@ -215,16 +222,23 @@ 1808BEBB2BA41C3200129230 /* CarouselScrollbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarouselScrollbar.swift; sourceTree = ""; }; 1808BEBF2BA456B700129230 /* CarouselScrollbarChangeLog.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = CarouselScrollbarChangeLog.txt; sourceTree = ""; }; 1832AC562BA0791D008AE476 /* BreadcrumbCellItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreadcrumbCellItem.swift; sourceTree = ""; }; + 1842B1DE2BECE28B0021AFCA /* CalendarDateViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarDateViewCell.swift; sourceTree = ""; }; + 1842B1E02BECE7B70021AFCA /* CalendarHeaderReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarHeaderReusableView.swift; sourceTree = ""; }; + 1842B1E22BECF0A10021AFCA /* CalendarFooterReusableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarFooterReusableView.swift; sourceTree = ""; }; 18450CF02BA1B19C009FDF2A /* BreadcrumbsChangeLog.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = BreadcrumbsChangeLog.txt; sourceTree = ""; }; 1855EC652BAABF2A002ACAC2 /* BreadcrumbItemModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreadcrumbItemModel.swift; sourceTree = ""; }; 186B2A892B88DA7F001AB71F /* TextAreaChangeLog.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = TextAreaChangeLog.txt; sourceTree = ""; }; 186D13CA2BBA8B1500986B53 /* DropdownSelect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DropdownSelect.swift; sourceTree = ""; }; 186D13CE2BBC36EE00986B53 /* DropdownSelectChangeLog.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = DropdownSelectChangeLog.txt; sourceTree = ""; }; 18792A8F2B7431F2008C0D29 /* ButtonIconBadgeIndicatorModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonIconBadgeIndicatorModel.swift; sourceTree = ""; }; + 18A3F1292BD9298900498E4A /* Calendar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Calendar.swift; sourceTree = ""; }; 18A65A012B96E848006602CC /* Breadcrumbs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Breadcrumbs.swift; sourceTree = ""; }; 18A65A032B96F050006602CC /* BreadcrumbItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreadcrumbItem.swift; sourceTree = ""; }; 18B463A32BBD3C46005C4528 /* DropdownOptionModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DropdownOptionModel.swift; sourceTree = ""; }; 18BDEE812B75316E00452358 /* ButtonIconChangeLog.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = ButtonIconChangeLog.txt; sourceTree = ""; }; + 18FEA1AC2BDD137500A56439 /* CalendarIndicatorModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarIndicatorModel.swift; sourceTree = ""; }; + 18FEA1B42BE0E63600A56439 /* Date+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Extension.swift"; sourceTree = ""; }; + 18FEA1B82BE1301700A56439 /* CalendarChangeLog.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = CalendarChangeLog.txt; sourceTree = ""; }; 445BA07729C07B3D0036A7C5 /* Notification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notification.swift; sourceTree = ""; }; 44604AD329CE186A00E62B51 /* NotificationButtonModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationButtonModel.swift; sourceTree = ""; }; 44604AD629CE196600E62B51 /* Line.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Line.swift; sourceTree = ""; }; @@ -447,6 +461,20 @@ path = DropdownSelect; sourceTree = ""; }; + 18A3F1202BD8F5DE00498E4A /* Calendar */ = { + isa = PBXGroup; + children = ( + 18A3F1292BD9298900498E4A /* Calendar.swift */, + 1842B1DE2BECE28B0021AFCA /* CalendarDateViewCell.swift */, + 1842B1E22BECF0A10021AFCA /* CalendarFooterReusableView.swift */, + 1842B1E02BECE7B70021AFCA /* CalendarHeaderReusableView.swift */, + 18FEA1AC2BDD137500A56439 /* CalendarIndicatorModel.swift */, + 18FEA1B42BE0E63600A56439 /* Date+Extension.swift */, + 18FEA1B82BE1301700A56439 /* CalendarChangeLog.txt */, + ); + path = Calendar; + sourceTree = ""; + }; 18A65A002B96E7E1006602CC /* Breadcrumbs */ = { isa = PBXGroup; children = ( @@ -614,6 +642,7 @@ EAD062AE2A3B87210015965D /* BadgeIndicator */, 18A65A002B96E7E1006602CC /* Breadcrumbs */, EA0FC2BE2912D18200DF80B4 /* Buttons */, + 18A3F1202BD8F5DE00498E4A /* Calendar */, 1808BEBA2BA41B1D00129230 /* CarouselScrollbar */, EAF7F092289985E200B287F5 /* Checkbox */, EAC58C1F2BF127F000BA39FA /* DatePicker */, @@ -1137,6 +1166,7 @@ EAA5EEB928ECD24B003B3210 /* Icons.xcassets in Resources */, EAEEECA92B1F969700531FC2 /* TooltipChangeLog.txt in Resources */, 186D13CF2BBC36EF00986B53 /* DropdownSelectChangeLog.txt in Resources */, + 18FEA1B92BE1301700A56439 /* CalendarChangeLog.txt in Resources */, EAEEEC9C2B1F8F0700531FC2 /* TextLinkCaretChangeLog.txt in Resources */, EAA5EEE428F5B855003B3210 /* VerizonNHGDS-Light.otf in Resources */, EAC58C252BF2A7FB00BA39FA /* DatePickerChangeLog.txt in Resources */, @@ -1166,6 +1196,7 @@ EA985C2D296F03FE00F2FF2E /* TileletIconModels.swift in Sources */, EA89200428AECF4B006B9984 /* UITextField+Publisher.swift in Sources */, 18A65A022B96E848006602CC /* Breadcrumbs.swift in Sources */, + 1842B1E12BECE7B70021AFCA /* CalendarHeaderReusableView.swift in Sources */, EA0D1C3F2A6AD5E200E5C127 /* Typography+ContentSizeCategory.swift in Sources */, EA5F86C82A1BD99100BC83E4 /* TabModel.swift in Sources */, EA297A5729FB0A360031ED56 /* AppleGuidelinesTouchable.swift in Sources */, @@ -1180,6 +1211,7 @@ EAB2376229E9880400AABE9A /* TrailingTooltipLabel.swift in Sources */, EAACB8982B92706F006A3869 /* DefaultValuing.swift in Sources */, EAB2376A29E9E59100AABE9A /* TooltipLaunchable.swift in Sources */, + 18A3F12A2BD9298900498E4A /* Calendar.swift in Sources */, 18A65A042B96F050006602CC /* BreadcrumbItem.swift in Sources */, EAB2375D29E8789100AABE9A /* Tooltip.swift in Sources */, 71BFA70A2B7F70E6000DCE33 /* DropShadowable.swift in Sources */, @@ -1290,12 +1322,15 @@ EA0B18062A9E2D2D00F2D0CD /* SelectorItemBase.swift in Sources */, EAB5FF0129424ACB00998C17 /* UIControl.swift in Sources */, EA985BF5296C60C000F2FF2E /* Icon.swift in Sources */, + 1842B1E32BECF0A20021AFCA /* CalendarFooterReusableView.swift in Sources */, EA3361AA288B25E40071C351 /* Disabling.swift in Sources */, EA3361B6288B2A410071C351 /* Control.swift in Sources */, EAC58C122BED0DDD00BA39FA /* Text.swift in Sources */, 5F21D7BF28DCEB3D003E7CD6 /* Useable.swift in Sources */, EAF7F0B7289C12A600B287F5 /* UITapGestureRecognizer.swift in Sources */, + 1842B1DF2BECE28B0021AFCA /* CalendarDateViewCell.swift in Sources */, EA0D1C392A6AD4DF00E5C127 /* Typography+SpacingConfig.swift in Sources */, + 18FEA1B52BE0E63600A56439 /* Date+Extension.swift in Sources */, EAB2376629E9952D00AABE9A /* UIApplication.swift in Sources */, EAB5FED429267EB300998C17 /* UIView+NSLayoutConstraint.swift in Sources */, EAB2376829E9992800AABE9A /* TooltipAlertViewController.swift in Sources */, @@ -1315,6 +1350,7 @@ EA596ABF2A16B4F500300C4B /* Tabs.swift in Sources */, EAD062A72A3B67770015965D /* UIView+CALayer.swift in Sources */, EAD068942A560C13002E3A2D /* LoaderLaunchable.swift in Sources */, + 18FEA1AD2BDD137500A56439 /* CalendarIndicatorModel.swift in Sources */, EA985BEC2968A91200F2FF2E /* TitleLockupTitleModel.swift in Sources */, 5FC35BE328D51405004EBEAC /* Button.swift in Sources */, ); diff --git a/VDS/Components/Calendar/Calendar.swift b/VDS/Components/Calendar/Calendar.swift new file mode 100644 index 00000000..9ec8e6fc --- /dev/null +++ b/VDS/Components/Calendar/Calendar.swift @@ -0,0 +1,339 @@ +// +// Calendar.swift +// VDS +// +// Created by Kanamarlapudi, Vasavi on 19/04/24. +// + +import Foundation +import UIKit +import VDSTokens +import Combine + +/// A calendar is a monthly view that lets customers select a single date. +@objc(VDSCalendar) +open class CalendarBase: View { + + //-------------------------------------------------- + // MARK: - Initializers + //-------------------------------------------------- + required public init() { + super.init(frame: .zero) + } + + public override init(frame: CGRect) { + super.init(frame: .zero) + } + + public required init?(coder: NSCoder) { + super.init(coder: coder) + } + + //-------------------------------------------------- + // MARK: - Public Properties + //-------------------------------------------------- + /// If set to true, the calendar will not have a border. + open var hideContainerBorder: Bool = false { didSet { setNeedsUpdate() } } + + /// If set to true, the calendar will not have current date indication. + open var hideCurrentDateIndicator: Bool = false { didSet { setNeedsUpdate() } } + + /// Enable specific days. Pass an array of string value in date format e.g. ['07/21/2024', '07/24/2024', 07/28/2024']. + /// All other dates will be inactive. + open var activeDates: [Date] = [] { didSet { setNeedsUpdate() } } + + /// Disable specific days. Pass an array of string value in date format e.g. ['07/21/2024', '07/24/2024', 07/28/2024']. + /// All other dates will be active. + open var inactiveDates: [Date] = [] { didSet { setNeedsUpdate() } } + + /// If provided, the calendar will allow a selection to be made from this date forward. Defaults to today. + open var minDate: Date = Date() { didSet { setNeedsUpdate() } } + + /// If provided, the calendar will allow a selection to be made up to this date. + open var maxDate: Date = Date() { didSet { setNeedsUpdate() } } + + /// If provided, this is the date that will show as selected by the Calendar. + /// If no value is provided, the current date will be used. If null is provided, no date will be selected. + open var selectedDate: Date = Date() { didSet { setNeedsUpdate() } } + + /// If provided, the calendar will be rendered with transparent background. + open var transparentBackground: Bool = false { didSet { setNeedsUpdate() } } + + /// Array of ``CalendarIndicatorModel`` you are wanting to show on legend. + open var indicators: [CalendarIndicatorModel] = [] { didSet { setNeedsUpdate() } } + + /// A callback when the date changes. Passes parameters (selectedDate). + public var onChangeSelectedDate: ((Date) -> Void)? + + //-------------------------------------------------- + // MARK: - Private Properties + //-------------------------------------------------- + internal var containerSize: CGSize { CGSize(width: 328, height: 376) } + + private let cellItemSize = CGSize(width: 40, height: 40) + private let headerHeight = 88.0 + private let footerHeight = 40.0 + private let calendarWidth = 304.0 + + private var heightConstraint: NSLayoutConstraint? + private var containerHeightConstraint: NSLayoutConstraint? + private var selectedIndexPath : IndexPath? + private var dates: [Date] = [] + private var days: [String] = [] + private var displayDate: Date = Date() + + internal var containerView = View().with { + $0.clipsToBounds = true + } + + /// Collectionview to load calendar month view + private lazy var collectionView: UICollectionView = { + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout()) + collectionView.isScrollEnabled = false + collectionView.translatesAutoresizingMaskIntoConstraints = false + collectionView.delegate = self + collectionView.dataSource = self + collectionView.showsHorizontalScrollIndicator = false + collectionView.showsVerticalScrollIndicator = false + collectionView.backgroundColor = .clear + collectionView.register(CalendarDateViewCell.self, forCellWithReuseIdentifier: CalendarDateViewCell.identifier) + collectionView.register(CalendarHeaderReusableView.self, + forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, + withReuseIdentifier: CalendarHeaderReusableView.identifier) + collectionView.register(CalendarFooterReusableView.self, + forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter, + withReuseIdentifier: CalendarFooterReusableView.identifier) + return collectionView + }() + + //-------------------------------------------------- + // MARK: - Configuration + //-------------------------------------------------- + internal var containerBorderColorConfiguration = SurfaceColorConfiguration(VDSColor.elementsPrimaryOnlight , VDSColor.elementsPrimaryOndark) + internal var backgroundColorConfiguration = SurfaceColorConfiguration(VDSFormControlsColor.backgroundOnlight, VDSFormControlsColor.backgroundOndark) + + //-------------------------------------------------- + // MARK: - Overrides + //-------------------------------------------------- + open override func initialSetup() { + super.initialSetup() + } + + /// Called once when a view is initialized and is used to Setup additional UI or other constants and configurations. + open override func setup() { + super.setup() + isAccessibilityElement = true + accessibilityLabel = "Calendar" + addSubview(containerView) + containerView + .pinTop() + .pinBottom() + .pinLeadingGreaterThanOrEqualTo() + .pinTrailingLessThanOrEqualTo() + .width(containerSize.width) + .heightGreaterThanEqualTo(containerSize.height) + containerView.centerXAnchor.constraint(equalTo: centerXAnchor).activate() + + // Calendar View + containerView.addSubview(collectionView) + let calendarHeight = containerSize.height - (2 * VDSLayout.space4X) + let spacing = (containerSize.width - calendarWidth) / 2 + collectionView + .pinTop(VDSLayout.space4X) + .pinBottom(VDSLayout.space4X) + .pinLeading(spacing) + .pinTrailing(spacing) + .width(calendarWidth) + .heightGreaterThanEqualTo(calendarHeight) + collectionView.centerXAnchor.constraint(equalTo: containerView.centerXAnchor).activate() + } + + open override func updateView() { + super.updateView() + // range check between min & max dates + if (minDate <= maxDate) { + // Check if current date falls between min & max dates. + let fallsBetween = displayDate.isBetweeen(date: minDate, andDate: maxDate) + displayDate = fallsBetween ? displayDate : minDate + self.fetchDates(with: displayDate) + } + + layer.backgroundColor = backgroundColorConfiguration.getColor(self).cgColor + if hideContainerBorder { + layer.borderColor = nil + layer.borderWidth = 0 + layer.cornerRadius = 0 + } else { + layer.borderColor = containerBorderColorConfiguration.getColor(self).cgColor + layer.borderWidth = VDSFormControls.borderWidth + layer.cornerRadius = VDSFormControls.borderRadius + } + } + + override open func layoutSubviews() { + super.layoutSubviews() + } + + /// Resets to default settings. + open override func reset() { + super.reset() + hideContainerBorder = false + hideCurrentDateIndicator = false + transparentBackground = false + activeDates = [] + inactiveDates = [] + indicators = [] + } + + //-------------------------------------------------- + // MARK: - Private Methods + //-------------------------------------------------- + func fetchDates(with aDate:Date) { + heightConstraint?.isActive = false + containerHeightConstraint?.isActive = false + days.removeAll() + self.dates = aDate.calendarDisplayDays + for date in dates { + // code to be executed + if date.monthInt != aDate.monthInt { + days.append("") + } else { + days.append(getDay(with: date)) + } + } + self.collectionView.reloadData() + var height = self.collectionView.collectionViewLayout.collectionViewContentSize.height + height = (height > 0) ? height : containerSize.height + heightConstraint = collectionView.heightAnchor.constraint(equalToConstant: height) + containerHeightConstraint = containerView.heightAnchor.constraint(equalToConstant: (height + (2 * VDSLayout.space4X))) + heightConstraint?.isActive = true + containerHeightConstraint?.isActive = true + self.layoutIfNeeded() + } + + func getDay(with date:Date) -> String { + if #available(iOS 15.0, *) { + return date.formatted(.dateTime.day()) + } else { + // Fallback on earlier versions + let dateFormatter: DateFormatter = DateFormatter() + dateFormatter.dateFormat = "d" + let day: String = dateFormatter.string(from: date) + return day + } + } + +} + +extension CalendarBase: UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { + //-------------------------------------------------- + // MARK: - UICollectionView Delegate & Datasource + //-------------------------------------------------- + public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + days.count + } + + public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: CalendarDateViewCell.identifier, for: indexPath) as? CalendarDateViewCell else { return UICollectionViewCell() } + var indicatorCount = 0 + if self.indicators.count > 0 { + for x in (0...(self.indicators.count-1)) { + if (self.days[indexPath.row] == self.getDay(with: self.indicators[x].date)) { + indicatorCount += 1 + } + } + } + cell.update(with: surface, indicators: indicators, text: days[indexPath.row], indicatorCount: indicatorCount, selectedDate: selectedDate, displayDate: displayDate, hideDate: hideCurrentDateIndicator, minDate: minDate, maxDate: maxDate, activeDates: activeDates, inactiveDates: inactiveDates) + if (self.days[indexPath.row] == self.getDay(with: selectedDate)) { selectedIndexPath = indexPath } + return cell + } + + public func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView { + if kind == UICollectionView.elementKindSectionHeader { + // Header + guard let header = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: CalendarHeaderReusableView.identifier, for: indexPath) as? CalendarHeaderReusableView else { + return UICollectionReusableView() + } + var nextEnabled = false + var prevEnabled = false + + // check the interval between min date, max date.. set enable/disable flag for next / previous buttons. + if ((displayDate.monthInt < maxDate.monthInt) && (displayDate.yearInt == maxDate.yearInt)) || (displayDate.yearInt < maxDate.yearInt) { + nextEnabled = true + } + if ((minDate.monthInt < displayDate.monthInt) && (minDate.yearInt == displayDate.yearInt)) || (minDate.yearInt < displayDate.yearInt) { + prevEnabled = true + } + + header.nextClicked = { [weak self] in + guard let self = self else { return } + let aDate = Calendar.current.date(byAdding: .month, value: 1, to:self.displayDate)! + if ((aDate.monthInt <= maxDate.monthInt) && (aDate.yearInt == maxDate.yearInt)) || (aDate.yearInt < maxDate.yearInt) { + displayDate = aDate + self.fetchDates(with: displayDate) + } + } + header.previousClicked = { [weak self] in + guard let self = self else { return } + let aDate = Calendar.current.date(byAdding: .month, value: -1, to:self.displayDate)! + if ((minDate.monthInt <= aDate.monthInt) && (minDate.yearInt == aDate.yearInt)) || (minDate.yearInt < aDate.yearInt) { + displayDate = aDate + self.fetchDates(with: displayDate) + } + } + header.update(with: surface, aDate: displayDate, nextEnabled: nextEnabled, previousEnabled: prevEnabled) + return header + } else { + // Footer + if kind == UICollectionView.elementKindSectionFooter { + guard let footer = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: CalendarFooterReusableView.identifier, for: indexPath) as? CalendarFooterReusableView else { + return UICollectionReusableView() + } + footer.update(with: surface, indicators: indicators) + return footer + } + } + return UICollectionReusableView() + } + + public func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + // reload selected index, if it is in enabled state. + if let cell = collectionView.cellForItem(at: indexPath) as? CalendarDateViewCell { + let isEnabled: Bool = cell.isDateEnabled() + if isEnabled { + // Callback to pass selected date if it is enabled only. + let selectedItem = Calendar.current.date(byAdding: .day, value: 1, to: self.dates[indexPath.row])! + onChangeSelectedDate?(selectedItem) + + selectedDate = self.dates[indexPath.row] + displayDate = selectedDate + var reloadIndexPaths = [indexPath] + + // If an cell is already selected, then it needs to be deselected. + // Add its index path to the array of index paths to be reloaded. + if let deselectIndexPath = selectedIndexPath { reloadIndexPaths.append(deselectIndexPath) } + self.collectionView.reloadItems(at: reloadIndexPaths) + } + } + } + + public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForHeaderInSection section: Int) -> CGSize { + return CGSize(width: collectionView.frame.size.width, height: headerHeight) + } + + public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, referenceSizeForFooterInSection section: Int) -> CGSize { + return CGSize(width: collectionView.frame.size.width, height: footerHeight) + } + + public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat { + return VDSLayout.space1X + } + + public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat { + return VDSLayout.space1X + } + + public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + return cellItemSize + } +} diff --git a/VDS/Components/Calendar/CalendarChangeLog.txt b/VDS/Components/Calendar/CalendarChangeLog.txt new file mode 100644 index 00000000..ee6928a3 --- /dev/null +++ b/VDS/Components/Calendar/CalendarChangeLog.txt @@ -0,0 +1,84 @@ +MM/DD/YYYY +---------------- +- Initial Brand 3.0 handoff + +12/24/2021 +---------------- +- Replaced focusring colors (previously interactive/onlight/ondark) with accessibility/onlight/ondark colors +- Updated focus border name (previously interactive.focusring.onlight) with focusring.onlight/ondark +- Updated the SPECS with FormControl tokens and corner radius tokens + +02/24/2022 +---------------- +- Replaced Caret Left and Right icons with bold assets. + +02/28/2022 +---------------- +- Removed dev note from Hover state and added a dev note to Active state. + +03/11/2022 +---------------- +- Update Hover and Active states triggers for carets. Icon swap and color change for mouse-only, and color change for touch. + +05/25/2022 +---------------- +- Added date indicator feature (including legend), today’s date indicator, and size/spacing adjustments to support this. + +07/20/2022 +---------------- +- Added configuration page. +- To configuration added transparent background and border suppression. + +08/10/2022 +---------------- +- Updated inverted and default to light and dark surface. Also, updated dark to selected. + +08/16/2022 +---------------- +- Updated default date background to be transparent. Updated border configuration prop name to hideContainerBorder. +- Moved Width from Configurations to Layout and Spacing, and Calendar Indicator specs under Elements. + +11/30/2022 +---------------- +- Added "(web only)" to any instance of "keyboard focus" + +12/13/2022 +---------------- +- Replaced form border and focus border pixel values, styles & spacing with tokens. + +01/09/2023 +---------------- +- Updated Specs to use new SPEC Templates and SPEC DOC Components. + +01/10/2023 +---------------- +- Updated Anatomy item #8 to “Current Date” to match design doc (originally “Today’s Date) + +04/12/2023 +---------------- +- Updated palette colors for Current date. + +05/01/2023 +---------------- +- Updated Day Header Text Style from Bold to Regular +- Updated Date Disabled Text Style from Bold to Regular +- Updated Date Text Style from Bold to Regular +- Updated Current Date font color from blue to black on light and white on dark. +- Updated all frames to reflect new designs. + +05/09/2023 +---------------- +- Replaced Previous and Next carets with Button Icon in: +Anatomy +Configurations +States + +11/09/2023 +---------------- +- Added component tokens +- Applied component tokens to selected states on light and dark surfaces +- Removed redundant color specifications from other sections + +11/30/2023 +---------------- +- Revised selected container background inverse tokens from onlight/ondark to light/dark diff --git a/VDS/Components/Calendar/CalendarDateViewCell.swift b/VDS/Components/Calendar/CalendarDateViewCell.swift new file mode 100644 index 00000000..26886d0a --- /dev/null +++ b/VDS/Components/Calendar/CalendarDateViewCell.swift @@ -0,0 +1,311 @@ +// +// CalendarDateViewCell.swift +// VDS +// +// Created by Kanamarlapudi, Vasavi on 24/04/24. +// + +import UIKit +import VDSTokens + +final class CalendarDateViewCell: UICollectionViewCell { + + ///Identifier for the Calendar Date Cell. + static let identifier: String = String(describing: CalendarDateViewCell.self) + + //-------------------------------------------------- + // MARK: - Private Properties + //-------------------------------------------------- + internal var containerSize: CGSize { CGSize(width: 40, height: 40) } + + internal var containerView = View().with { + $0.clipsToBounds = true + } + + private lazy var stackView: UIStackView = { + return UIStackView().with { + $0.translatesAutoresizingMaskIntoConstraints = false + $0.axis = .horizontal + $0.distribution = .fill + $0.alignment = .center + $0.spacing = VDSLayout.space1X + $0.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) + $0.setContentHuggingPriority(.defaultHigh, for: .horizontal) + $0.backgroundColor = .clear + } + }() + + private var numberLabel = Label().with { + $0.translatesAutoresizingMaskIntoConstraints = false + $0.textAlignment = .center + $0.textStyle = .bodySmall + } + + private lazy var shapeLayer = CAShapeLayer() + + private let selectedTextColorConfiguration = SurfaceColorConfiguration(VDSColor.elementsPrimaryInverseOnlight, VDSColor.elementsPrimaryInverseOndark) + private let selectedBackgroundColor = SurfaceColorConfiguration(VDSColor.backgroundPrimaryInverseLight, VDSColor.backgroundPrimaryInverseDark) + private let selectedCellIndicatorColorConfiguration = SurfaceColorConfiguration(VDSColor.paletteGray65, VDSColor.paletteGray44) + private let unselectedTextColorConfiguration = SurfaceColorConfiguration(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark) + private let unselectedCellIndicatorColorConfiguration = SurfaceColorConfiguration(VDSColor.elementsSecondaryOnlight, VDSColor.elementsSecondaryOndark) + private let disabledTextColorConfiguration = SurfaceColorConfiguration(VDSColor.interactiveDisabledOnlight, VDSColor.interactiveDisabledOndark) + private let disabledBackgroundColor = SurfaceColorConfiguration(VDSFormControlsColor.backgroundOnlight, VDSFormControlsColor.backgroundOndark) + + private let currentDate = Date() + + //-------------------------------------------------- + // MARK: - Initializers + //-------------------------------------------------- + override init(frame: CGRect) { + super.init(frame: frame) + setUp() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setUp() + } + + //-------------------------------------------------- + // MARK: - Private Methods + //-------------------------------------------------- + /// Configuring the cell with default setup. + private func setUp() { + isAccessibilityElement = false + contentView.addSubview(containerView) + containerView + .pinTop() + .pinBottom() + .pinLeadingGreaterThanOrEqualTo() + .pinTrailingLessThanOrEqualTo() + .height(containerSize.height) + .width(containerSize.width) + containerView.centerXAnchor.constraint(equalTo: centerXAnchor).activate() + + // Number label + containerView.addSubview(numberLabel) + numberLabel.pinToSuperView() + + // Indicators + containerView.addSubview(stackView) + let topPos = containerSize.height * 0.7 + stackView.pinTop(topPos).pinBottom().pinTopGreaterThanOrEqualTo().pinTrailingLessThanOrEqualTo() + stackView.centerXAnchor.constraint(equalTo: centerXAnchor).activate() + } + + /// Updating UI based on selected date, modified indicators data along with surface. + /// Enable/disable cell based on min date, max date, active dates, inactive dates. + func update(with surface: Surface, indicators: [CalendarBase.CalendarIndicatorModel], text: String, indicatorCount: Int, selectedDate: Date, displayDate: Date, hideDate: Bool, minDate: Date, maxDate: Date, activeDates: [Date], inactiveDates: [Date]) { + + stackView.arrangedSubviews.forEach { $0.removeFromSuperview() } + numberLabel.surface = surface + numberLabel.text = text + + // enable/disable cells based on min date, max date and active/inactive dates. + self.updateLabel(with:surface, displayDate: displayDate, minDate: minDate, maxDate: maxDate, activeDates: activeDates, inactiveDates: inactiveDates) + + // handling inactive dates. + if inactiveDates.count > 0 { + for x in (0...(inactiveDates.count-1)) { + if (inactiveDates[x].monthInt == displayDate.monthInt) && (inactiveDates[x].yearInt == displayDate.yearInt) { + if let day:Int = Int(numberLabel.text), day == inactiveDates[x].dayInt { + disableLabel(with: surface) + } + } + } + } + + // update text color, bg color, corner radius. + if (numberLabel.text == self.getDay(with: selectedDate)) && (selectedDate.monthInt == displayDate.monthInt) && (selectedDate.yearInt == displayDate.yearInt) && numberLabel.isEnabled { + numberLabel.textColor = selectedTextColorConfiguration.getColor(surface) + layer.backgroundColor = selectedBackgroundColor.getColor(surface).cgColor + layer.cornerRadius = VDSFormControls.borderRadius + } else { + numberLabel.textColor = unselectedTextColorConfiguration.getColor(surface) + layer.backgroundColor = nil + layer.cornerRadius = 0 + } + + // add indicators. + if indicatorCount > 0 { + for x in (0...(indicators.count-1)) { + // irrespective of month and year, if it needs to show indicators on every month - comment below first if condition, else uncomment it. + // if (indicators[x].date.monthInt == displayDate.monthInt) && (indicators[x].date.yearInt == displayDate.yearInt) { + if (self.numberLabel.text == self.getDay(with: indicators[x].date)) { + let color = (numberLabel.text == self.getDay(with: selectedDate)) ? selectedCellIndicatorColorConfiguration.getColor(surface) : unselectedCellIndicatorColorConfiguration.getColor(surface) + addIndicator(with: color, surface: surface, clearFullCircle: (x == 1), drawSemiCircle: (x == 2)) + } + // } + } + } + + // update text style for current date. + if (numberLabel.text == self.getDay(with: currentDate)) && (currentDate.monthInt == displayDate.monthInt) && (currentDate.yearInt == displayDate.yearInt) { + numberLabel.textStyle = hideDate ? .bodySmall : .boldBodySmall + } else { + numberLabel.textStyle = .bodySmall + } + } + + // returns cell enabled state. + func isDateEnabled() -> Bool { + return numberLabel.isEnabled + } + + func disableLabel(with surface: Surface) { + numberLabel.isEnabled = false + numberLabel.textColor = disabledTextColorConfiguration.getColor(surface) + layer.backgroundColor = disabledBackgroundColor.getColor(surface).cgColor + } + + func showActiveDates(with displayDate: Date, activeDates: [Date], inactiveDates: [Date]) { + for x in (0...(activeDates.count-1)) { + if (activeDates[x].monthInt == displayDate.monthInt) && (activeDates[x].yearInt == displayDate.yearInt ) { + if let day:Int = Int(numberLabel.text), day == activeDates[x].dayInt { + numberLabel.isEnabled = true + } + } + } + } + + // handing active dates if exist, else enable numberLabel to display day. + func handleActiveDates(with displayDate: Date, activeDates: [Date], inactiveDates: [Date]) { + if activeDates.count > 0 && inactiveDates.count == 0 { + showActiveDates(with: displayDate, activeDates: activeDates, inactiveDates: inactiveDates) + } else { + numberLabel.isEnabled = true + } + } + + // enable all days if no active dates, handing active dates if exist. + func enableAllDaysAndCheckActiveDates(with surface:Surface, displayDate: Date, activeDates: [Date], inactiveDates: [Date]) { + if activeDates.count > 0 && inactiveDates.count == 0 { + disableLabel(with: surface) + showActiveDates(with: displayDate, activeDates: activeDates, inactiveDates: inactiveDates) + } else { + numberLabel.isEnabled = true + } + } + + func minDateValidation(with surface:Surface, minDate: Date, displayDate: Date, activeDates: [Date], inactiveDates: [Date]) { + // validate days to enable/disable with min date only. + if let day:Int = Int(numberLabel.text), day < minDate.dayInt { + disableLabel(with: surface) + } else { + numberLabel.isEnabled = false + handleActiveDates(with: displayDate, activeDates: activeDates, inactiveDates: inactiveDates) + } + } + + func maxDateValidation(with surface:Surface, maxDate: Date, displayDate: Date, activeDates: [Date], inactiveDates: [Date]) { + // validate days to enable/disable with max date only. + if let day:Int = Int(numberLabel.text), day > maxDate.dayInt { + disableLabel(with: surface) + } else { + numberLabel.isEnabled = false + handleActiveDates(with: displayDate, activeDates: activeDates, inactiveDates: inactiveDates) + } + } + + func minAndMaxDateValidation(with surface:Surface, minDate: Date, maxDate: Date, displayDate: Date, activeDates: [Date], inactiveDates: [Date]) { + // validate days to enable/disable with min and max date. + if let day:Int = Int(numberLabel.text), day < minDate.dayInt || day > maxDate.dayInt { + disableLabel(with: surface) + } else { + numberLabel.isEnabled = false + handleActiveDates(with: displayDate, activeDates: activeDates, inactiveDates: inactiveDates) + } + } + + // enable/disable cells based on min date, max date and active/inactive dates. + func updateLabel(with surface: Surface, displayDate: Date, minDate: Date, maxDate: Date, activeDates: [Date], inactiveDates: [Date]) { + + if (minDate.yearInt == displayDate.yearInt) && !(maxDate.yearInt == displayDate.yearInt) { + // min year and max year are different, and matched to min year. + if (minDate.monthInt == displayDate.monthInt) { + // min year and max year are different, and matched to min year and min month. + minDateValidation(with: surface, minDate: minDate, displayDate: displayDate, activeDates: activeDates, inactiveDates: inactiveDates) + } else { + // handing active dates - enable all days if no active dates. + enableAllDaysAndCheckActiveDates(with: surface, displayDate: displayDate, activeDates: activeDates, inactiveDates: inactiveDates) + } + } else if (maxDate.yearInt == displayDate.yearInt) && !((minDate.yearInt == displayDate.yearInt)) { + // min year and max year are different, and matched to max year. + if (maxDate.monthInt == displayDate.monthInt) { + // min year and max year are different, and matched to max year and max month. + maxDateValidation(with: surface, maxDate: maxDate, displayDate: displayDate, activeDates: activeDates, inactiveDates: inactiveDates) + } else { + // handing active dates - enable all days if no active dates. + enableAllDaysAndCheckActiveDates(with: surface, displayDate: displayDate, activeDates: activeDates, inactiveDates: inactiveDates) + } + } else if (minDate.yearInt == displayDate.yearInt) && (maxDate.yearInt == displayDate.yearInt) { + // min year and max year same + if (minDate.monthInt == displayDate.monthInt) && (maxDate.monthInt == displayDate.monthInt) { + // min year and max year same, when choose dates in same month. + minAndMaxDateValidation(with: surface, minDate: minDate, maxDate: maxDate, displayDate: displayDate, activeDates: activeDates, inactiveDates: inactiveDates) + + } else if (minDate.monthInt == displayDate.monthInt) || (maxDate.monthInt == displayDate.monthInt) { + // min year and max year same, and choose dates in different months. + if (minDate.monthInt == displayDate.monthInt) { + // min year and max year same, and matched to min month. + minDateValidation(with: surface, minDate: minDate, displayDate: displayDate, activeDates: activeDates, inactiveDates: inactiveDates) + } else if (maxDate.monthInt == displayDate.monthInt) { + // min year and max year same, and matched to max month. + maxDateValidation(with: surface, maxDate: maxDate, displayDate: displayDate, activeDates: activeDates, inactiveDates: inactiveDates) + } + } else { + // min year and max year same, and not matched to min or max month. + // handing active dates - enable all days if no active dates. + enableAllDaysAndCheckActiveDates(with: surface, displayDate: displayDate, activeDates: activeDates, inactiveDates: inactiveDates) + } + } else { + // min year and max year are different, and not matched to min or max year. + // handing active dates - enable all days if no active dates. + enableAllDaysAndCheckActiveDates(with: surface, displayDate: displayDate, activeDates: activeDates, inactiveDates: inactiveDates) + } + } + + func addIndicator(with color: UIColor, surface: Surface, clearFullCircle: Bool, drawSemiCircle: Bool) { + // add indicator + let indicatorView: View = View().with { + $0.translatesAutoresizingMaskIntoConstraints = false + $0.backgroundColor = .clear + $0.layer.borderWidth = 1.0 + } + indicatorView.pinLeading().pinTrailing().width(8).height(8).pinCenterY() + stackView.addArrangedSubview(indicatorView) + + // update indicator + indicatorView.backgroundColor = drawSemiCircle ? .clear : (clearFullCircle ? .clear : color) + indicatorView.layer.borderColor = color.cgColor + + self.layoutIfNeeded() + + indicatorView.layer.cornerRadius = indicatorView.frame.size.height / 2.0 + + guard drawSemiCircle else { return } + + let center = CGPoint(x: indicatorView.frame.size.width/2, y: indicatorView.frame.size.height/2) + let path = UIBezierPath() + path.move(to: center) + path.addArc(withCenter: center, radius: center.x, startAngle: 2 * .pi, endAngle: .pi, clockwise: true) + path.close() + shapeLayer.path = path.cgPath + shapeLayer.fillColor = color.cgColor + + guard indicatorView.layer.sublayers?.contains(shapeLayer) ?? true else { return } + indicatorView.layer.addSublayer(shapeLayer) + } + + func getDay(with date: Date) -> String { + if #available(iOS 15.0, *) { + return date.formatted(.dateTime.day()) + } else { + // Fallback on earlier versions + let dateFormatter: DateFormatter = DateFormatter() + dateFormatter.dateFormat = "d" + let day: String = dateFormatter.string(from: date) + return day + } + } +} diff --git a/VDS/Components/Calendar/CalendarFooterReusableView.swift b/VDS/Components/Calendar/CalendarFooterReusableView.swift new file mode 100644 index 00000000..3e85a6ec --- /dev/null +++ b/VDS/Components/Calendar/CalendarFooterReusableView.swift @@ -0,0 +1,193 @@ +// +// CalendarFooterReusableView.swift +// VDS +// +// Created by Kanamarlapudi, Vasavi on 29/04/24. +// + +import UIKit +import VDSTokens + +/// Footer view to show indicators data. +class CalendarFooterReusableView: UICollectionReusableView { + + ///Identifier for the Calendar Footer Reusable View. + static let identifier: String = String(describing: CalendarFooterReusableView.self) + + //-------------------------------------------------- + // MARK: - Private Properties + //-------------------------------------------------- + private var surface: Surface = .light + private var items: [CalendarBase.CalendarIndicatorModel] = [] + internal var containerSize: CGSize { CGSize(width: 304, height: 40) } + + internal var containerView = View().with { + $0.clipsToBounds = true + } + + private let flowLayout = UICollectionViewFlowLayout().with { + $0.estimatedItemSize = UICollectionViewFlowLayout.automaticSize + $0.minimumLineSpacing = VDSLayout.space1X + $0.minimumInteritemSpacing = VDSLayout.space4X + $0.scrollDirection = .vertical + } + open lazy var legendCollectionView = UICollectionView(frame: .zero, collectionViewLayout: flowLayout).with { + $0.isScrollEnabled = false + $0.translatesAutoresizingMaskIntoConstraints = false + $0.showsVerticalScrollIndicator = false + $0.showsHorizontalScrollIndicator = false + $0.isAccessibilityElement = true + $0.backgroundColor = .clear + $0.delegate = self + $0.dataSource = self + $0.register(LegendCollectionViewCell.self, forCellWithReuseIdentifier: LegendCollectionViewCell.identifier) + } + + //-------------------------------------------------- + // MARK: - Initializers + //-------------------------------------------------- + override init(frame: CGRect) { + super.init(frame: frame) + setUp() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setUp() + } + + //-------------------------------------------------- + // MARK: - Private Methods + //-------------------------------------------------- + /// Configuring the cell with default setup. + private func setUp() { + isAccessibilityElement = false + + addSubview(containerView) + containerView +// .pinTop(VDSLayout.space6X) + .pinTopLessThanOrEqualTo(topAnchor, VDSLayout.space6X, .defaultLow) + .pinBottom() + .pinLeadingGreaterThanOrEqualTo(leadingAnchor, VDSLayout.space3X, .defaultHigh) + .pinTrailingLessThanOrEqualTo(trailingAnchor, VDSLayout.space3X, .defaultHigh) + .width(containerSize.width - (2*VDSLayout.space3X)) + .heightLessThanEqualTo(containerSize.height) + + // legend Collection View + containerView.addSubview(legendCollectionView) + legendCollectionView.pinTop().pinBottom().pinLeading().pinTrailing().pinCenterY().pinCenterX() + + } + + /// Updating UI to show legend with titles. + func update(with surface: Surface, indicators: [CalendarBase.CalendarIndicatorModel]) { + self.items = indicators + self.surface = surface + legendCollectionView.reloadData() + } + +} + +extension CalendarFooterReusableView: UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { + + public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return items.count + } + + public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard collectionView == legendCollectionView, + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: LegendCollectionViewCell.identifier, for: indexPath) as? LegendCollectionViewCell, + indexPath.row <= items.count else { return UICollectionViewCell() } + let text = items[indexPath.row].label + cell.updateTitle(text: text, color: VDSColor.elementsSecondaryOnlight, surface: self.surface, clearFullcircle: indexPath.row == 1, drawSemiCircle: indexPath.row == 2) + return cell + } +} + +private class LegendCollectionViewCell: UICollectionViewCell { + + static let identifier: String = String(describing: LegendCollectionViewCell.self) + + private let textColorConfiguration = SurfaceColorConfiguration(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark) + + private let indicatorColorConfiguration = SurfaceColorConfiguration(VDSColor.elementsSecondaryOnlight, VDSColor.elementsSecondaryOndark) + + private var title: Label = Label().with { + $0.translatesAutoresizingMaskIntoConstraints = false + $0.textAlignment = .left + $0.numberOfLines = 1 + $0.textStyle = .bodySmall + $0.isAccessibilityElement = false + $0.backgroundColor = .clear + } + + private var legendIndicatorWrapper: View = View().with { + $0.translatesAutoresizingMaskIntoConstraints = false + $0.backgroundColor = .clear + } + private var legendIndicator: View = View().with { + $0.translatesAutoresizingMaskIntoConstraints = false + $0.backgroundColor = .clear + $0.layer.borderWidth = 1.0 + } + + private lazy var stackView = UIStackView().with { + $0.translatesAutoresizingMaskIntoConstraints = false + $0.distribution = .equalSpacing + $0.spacing = VDSLayout.space2X + $0.axis = .horizontal + $0.backgroundColor = .clear + } + + private lazy var shapeLayer = CAShapeLayer() + + //-------------------------------------------------- + // MARK: - Initializers + //-------------------------------------------------- + override init(frame: CGRect) { + super.init(frame: frame) + setupCell() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupCell() + } + + func setupCell() { + addSubview(stackView) + stackView.pinToSuperView() + + legendIndicatorWrapper.addSubview(legendIndicator) + legendIndicator.pinLeading().pinTrailing().width(8).height(8).pinCenterY() + + stackView.addArrangedSubview(legendIndicatorWrapper) + stackView.addArrangedSubview(title) + } + + func updateTitle(text: String, color: UIColor, surface: Surface, clearFullcircle: Bool, drawSemiCircle: Bool) { + title.surface = surface + title.text = text + title.textColor = textColorConfiguration.getColor(surface) + + legendIndicator.backgroundColor = drawSemiCircle ? .clear : (clearFullcircle ? .clear : color) + legendIndicator.layer.borderColor = indicatorColorConfiguration.getColor(surface).cgColor + + self.layoutIfNeeded() + + legendIndicator.layer.cornerRadius = legendIndicator.frame.size.height / 2.0 + + guard drawSemiCircle else { return } + + let center = CGPoint(x: legendIndicator.frame.size.width/2, y: legendIndicator.frame.size.height/2) + let path = UIBezierPath() + path.move(to: center) + path.addArc(withCenter: center, radius: center.x, startAngle: 2 * .pi, endAngle: .pi, clockwise: true) + path.close() + shapeLayer.path = path.cgPath + shapeLayer.fillColor = color.cgColor + + guard legendIndicator.layer.sublayers?.contains(shapeLayer) ?? true else { return } + legendIndicator.layer.addSublayer(shapeLayer) + } +} diff --git a/VDS/Components/Calendar/CalendarHeaderReusableView.swift b/VDS/Components/Calendar/CalendarHeaderReusableView.swift new file mode 100644 index 00000000..b1d18810 --- /dev/null +++ b/VDS/Components/Calendar/CalendarHeaderReusableView.swift @@ -0,0 +1,241 @@ +// +// CalendarHeaderReusableView.swift +// VDS +// +// Created by Kanamarlapudi, Vasavi on 30/04/24. +// + +import UIKit +import VDSTokens + +/// Header view to display month and year along with days of week. +class CalendarHeaderReusableView: UICollectionReusableView { + + ///Identifier for the Calendar Header Reusable View. + static let identifier: String = String(describing: CalendarHeaderReusableView.self) + + //-------------------------------------------------- + // MARK: - Public Properties + //-------------------------------------------------- + /// A callback when the next button clicked. + public var nextClicked: (() -> (Void))? + + /// A callback when the previous button clicked. + public var previousClicked: (() -> (Void))? + + //-------------------------------------------------- + // MARK: - Private Properties + //-------------------------------------------------- + internal var containerSize: CGSize { CGSize(width: 304, height: 88) } + + private lazy var stackView = UIStackView().with { + $0.translatesAutoresizingMaskIntoConstraints = false + $0.distribution = .fill + $0.spacing = VDSLayout.space1X + $0.axis = .vertical + $0.backgroundColor = .clear + } + + private lazy var topHeaderView = UIStackView().with { + $0.translatesAutoresizingMaskIntoConstraints = false + $0.distribution = .fill + $0.spacing = VDSLayout.space1X + $0.axis = .horizontal + $0.backgroundColor = .clear + } + + private lazy var daysCollectionView: UICollectionView = { + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout()) + collectionView.isScrollEnabled = false + collectionView.translatesAutoresizingMaskIntoConstraints = false + collectionView.delegate = self + collectionView.dataSource = self + collectionView.showsHorizontalScrollIndicator = false + collectionView.showsVerticalScrollIndicator = false + collectionView.backgroundColor = .clear + collectionView.register(collectionViewCell.self, forCellWithReuseIdentifier: collectionViewCell.identifier) + return collectionView + }() + + private var surface: Surface = .light + private var displayDate: Date = Date() + + internal var previousMonthView = View() + internal var nextMonthView = View() + let viewSize = 40.0 + + internal var previousButton = ButtonIcon().with { + $0.kind = .ghost + $0.iconName = .leftCaret + $0.iconOffset = .init(x: -2, y: 0) + $0.icon.size = .small + $0.size = .small + } + + internal var nextButton = ButtonIcon().with { + $0.kind = .ghost + $0.iconName = .rightCaret + $0.iconOffset = .init(x: 2, y: 0) + $0.icon.size = .small + $0.size = .small + } + + internal var headerTitle = Label().with { + $0.translatesAutoresizingMaskIntoConstraints = false + $0.textAlignment = .center + $0.numberOfLines = 1 + $0.textStyle = .boldBodySmall + $0.backgroundColor = .clear + $0.isAccessibilityElement = false + } + + internal let daysOfWeek = Date.capitalizedFirstLettersOfWeekdays + internal let headerTitleTextColorConfiguration = SurfaceColorConfiguration(VDSColor.elementsPrimaryOnlight, VDSColor.elementsPrimaryOndark) + + //-------------------------------------------------- + // MARK: - Initializers + //-------------------------------------------------- + override init(frame: CGRect) { + super.init(frame: frame) + setUp() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setUp() + } + + //-------------------------------------------------- + // MARK: - Private Methods + //-------------------------------------------------- + /// Configuring the cell with default setup. + private func setUp() { + isAccessibilityElement = false + + // stackview + addSubview(stackView) + stackView + .pinTop() + .pinBottom(VDSLayout.space1X) + .pinLeading() + .pinTrailing() + .height(containerSize.height - VDSLayout.space1X) + .width(containerSize.width) + stackView.centerXAnchor.constraint(equalTo: centerXAnchor).activate() + + // top header stack view + stackView.addArrangedSubview(topHeaderView) + topHeaderView.heightAnchor.constraint(equalToConstant: viewSize).isActive = true + topHeaderView.centerXAnchor.constraint(equalTo: centerXAnchor).activate() + + // previous button + topHeaderView.addArrangedSubview(previousMonthView) + previousMonthView.widthAnchor.constraint(equalToConstant: viewSize).activate() + previousMonthView.addSubview(previousButton) + previousButton.pinCenterY().pinCenterX() + previousButton.onClick = { _ in self.previousButtonClick() } + + // month year label + topHeaderView.addArrangedSubview(headerTitle) + + // next button + topHeaderView.addArrangedSubview(nextMonthView) + nextMonthView.widthAnchor.constraint(equalToConstant: viewSize).activate() + nextMonthView.addSubview(nextButton) + nextButton.pinCenterY().pinCenterX() + nextButton.onClick = { _ in self.nextButtonClick() } + + // days Collection View + stackView.addArrangedSubview(daysCollectionView) + topHeaderView.heightAnchor.constraint(equalToConstant: viewSize).isActive = true + daysCollectionView.centerXAnchor.constraint(equalTo: centerXAnchor).activate() + } + + /// Updating UI based on next/previous clicks along with surface. + /// Updating UI to enable/disable the next & previous buttons, updating header title. + func update(with surface: Surface, aDate: Date, nextEnabled: Bool, previousEnabled: Bool) { + self.surface = surface + headerTitle.surface = surface + previousButton.surface = surface + nextButton.surface = surface + nextButton.isEnabled = nextEnabled + previousButton.isEnabled = previousEnabled + daysCollectionView.reloadData() + let labelText = aDate.getMonthName(date: aDate) + " \(aDate.yearInt)" + headerTitle.text = labelText + headerTitle.textColor = headerTitleTextColorConfiguration.getColor(surface) + } + + func nextButtonClick() { + nextClicked?() + } + + func previousButtonClick() { + previousClicked?() + } +} + +extension CalendarHeaderReusableView: UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { + + public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return daysOfWeek.count + } + + public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: collectionViewCell.identifier, for: indexPath) as? collectionViewCell else { return UICollectionViewCell() } + cell.updateTitle(text: daysOfWeek[indexPath.row], surface: self.surface) + return cell + } + + public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + return CGSize(width: viewSize, height: viewSize) + } + + public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumLineSpacingForSectionAt section: Int) -> CGFloat { + return VDSLayout.space1X + } + + public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, minimumInteritemSpacingForSectionAt section: Int) -> CGFloat { + return VDSLayout.space1X + } +} + +private class collectionViewCell: UICollectionViewCell { + + static let identifier: String = String(describing: collectionViewCell.self) + + private let textColorConfiguration = SurfaceColorConfiguration(VDSColor.elementsSecondaryOnlight, VDSColor.elementsSecondaryOndark) + + private var title = Label().with { + $0.translatesAutoresizingMaskIntoConstraints = false + $0.textAlignment = .center + $0.numberOfLines = 1 + $0.textStyle = .bodySmall + $0.backgroundColor = .clear + $0.isAccessibilityElement = false + } + + //-------------------------------------------------- + // MARK: - Initializers + //-------------------------------------------------- + override init(frame: CGRect) { + super.init(frame: frame) + setupCell() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupCell() + } + + func setupCell() { + addSubview(title) + title.pinToSuperView() + } + + func updateTitle(text: String, surface: Surface) { + title.surface = surface + title.text = text + title.textColor = textColorConfiguration.getColor(surface) + } +} diff --git a/VDS/Components/Calendar/CalendarIndicatorModel.swift b/VDS/Components/Calendar/CalendarIndicatorModel.swift new file mode 100644 index 00000000..04ad9714 --- /dev/null +++ b/VDS/Components/Calendar/CalendarIndicatorModel.swift @@ -0,0 +1,25 @@ +// +// CalendarIndicatorModel.swift +// VDS +// +// Created by Kanamarlapudi, Vasavi on 26/04/24. +// + +import Foundation + +/// Custom data type for indicators prop +extension CalendarBase { + public struct CalendarIndicatorModel { + + /// Text that shown to an indicator for legend + public var label: String + + /// Date to an indicator + public var date: Date + + public init(label: String, date: Date) { + self.label = label + self.date = date + } + } +} diff --git a/VDS/Components/Calendar/Date+Extension.swift b/VDS/Components/Calendar/Date+Extension.swift new file mode 100644 index 00000000..8d697049 --- /dev/null +++ b/VDS/Components/Calendar/Date+Extension.swift @@ -0,0 +1,106 @@ +// +// Date+Extension.swift +// VDS +// +// Created by Kanamarlapudi, Vasavi on 26/04/24. +// + +import Foundation + +extension Date { + + static var firstDayOfWeek = Calendar.current.firstWeekday + + /// Capitalizes the first letter of the day of the week + static var capitalizedFirstLettersOfWeekdays: [String] { + let calendar = Calendar.current + let weekdays = calendar.shortWeekdaySymbols + + return weekdays.map { weekday in + guard let firstLetter = weekday.first else { return "" } + return String(firstLetter).capitalized + } + } + + /// Returns all month names + static var fullMonthNames: [String] { + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale.current + + return (1...12).compactMap { month in + dateFormatter.setLocalizedDateFormatFromTemplate("MMMM") + let date = Calendar.current.date(from: DateComponents(year: 2000, month: month, day: 1)) + return date.map { dateFormatter.string(from: $0) } + } + } + + var startOfMonth: Date { + Calendar.current.dateInterval(of: .month, for: self)!.start + } + + var endOfMonth: Date { + let lastDay = Calendar.current.dateInterval(of: .month, for: self)!.end + return Calendar.current.date(byAdding: .day, value: -1, to: lastDay)! + } + + /// Get the number of days of the month + var numberOfDaysInMonth: Int { + Calendar.current.component(.day, from: endOfMonth) + } + + var firstWeekDayBeforeStart: Date { + let startOfMonthWeekday = Calendar.current.component(.weekday, from: startOfMonth) + var numberFromPreviousMonth = startOfMonthWeekday - Self.firstDayOfWeek + if numberFromPreviousMonth < 0 { + numberFromPreviousMonth += 7 // Adjust to a 0-6 range if negative + } + return Calendar.current.date(byAdding: .day, value: -numberFromPreviousMonth, to: startOfMonth)! + } + + /// Get the days of the month to display + var calendarDisplayDays: [Date] { + var days: [Date] = [] + // Start with days from the previous month to fill the grid + let firstDisplayDay = firstWeekDayBeforeStart + var day = firstDisplayDay + while day < startOfMonth { + days.append(day) + day = Calendar.current.date(byAdding: .day, value: 1, to: day)! + } + // Add days of the current month + for dayOffset in 0.. Bool { + let from = Calendar.current.date(byAdding: .day, value: -1, to: date1)! + let to = Calendar.current.date(byAdding: .day, value: 1, to: date2)! + return from.compare(self) == self.compare(to) + } + + /// Returns the month name of the given date + func getMonthName(date: Date) -> String { + let names = Date.fullMonthNames + return names[date.monthInt - 1] + } + +} diff --git a/VDS/VDS.docc/VDS.md b/VDS/VDS.docc/VDS.md index 607e09f5..d145ea0a 100755 --- a/VDS/VDS.docc/VDS.md +++ b/VDS/VDS.docc/VDS.md @@ -25,6 +25,7 @@ Using the system allows designers and developers to collaborate more easily and - ``Button`` - ``ButtonIcon`` - ``ButtonGroup`` +- ``CalendarBase`` - ``CarouselScrollbar`` - ``Checkbox`` - ``CheckboxItem``