diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index d52896f50..0b4400836 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -14,8 +14,10 @@ 021D924828DC860C00ACC565 /* Data_UserProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 021D924728DC860C00ACC565 /* Data_UserProfile.swift */; }; 021D925028DC89D100ACC565 /* UserProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 021D924F28DC89D100ACC565 /* UserProfile.swift */; }; 021D925728DCF12900ACC565 /* AlertView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 021D925628DCF12900ACC565 /* AlertView.swift */; }; + 022020462C11BB2200D15795 /* Data_CourseDates.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022020452C11BB2200D15795 /* Data_CourseDates.swift */; }; 02280F5B294B4E6F0032823A /* Connectivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02280F5A294B4E6F0032823A /* Connectivity.swift */; }; 02284C182A3B1AE00007117F /* UIApplicationExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02284C172A3B1AE00007117F /* UIApplicationExtension.swift */; }; + 02286D162C106393005EEC8D /* CourseDates.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02286D152C106393005EEC8D /* CourseDates.swift */; }; 022C64E429AE0191000F532B /* TextWithUrls.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022C64E329AE0191000F532B /* TextWithUrls.swift */; }; 0231CDBE2922422D00032416 /* CSSInjector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0231CDBD2922422D00032416 /* CSSInjector.swift */; }; 0233D56F2AF13EB200BAC8BD /* StarRatingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0233D56E2AF13EB200BAC8BD /* StarRatingView.swift */; }; @@ -74,6 +76,7 @@ 02A4833829B8A8F900D33F33 /* CoreDataModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 02A4833629B8A8F800D33F33 /* CoreDataModel.xcdatamodeld */; }; 02A4833A29B8A9AB00D33F33 /* DownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A4833929B8A9AB00D33F33 /* DownloadManager.swift */; }; 02A4833C29B8C57800D33F33 /* DownloadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A4833B29B8C57800D33F33 /* DownloadView.swift */; }; + 02A8C5812C05DBB4004B91FF /* Data_EnrollmentsStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A8C5802C05DBB4004B91FF /* Data_EnrollmentsStatus.swift */; }; 02AFCC182AEFDB24000360F0 /* ThirdPartyMailClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02AFCC172AEFDB24000360F0 /* ThirdPartyMailClient.swift */; }; 02AFCC1A2AEFDC18000360F0 /* ThirdPartyMailer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02AFCC192AEFDC18000360F0 /* ThirdPartyMailer.swift */; }; 02B2B594295C5C7A00914876 /* Thread.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B2B593295C5C7A00914876 /* Thread.swift */; }; @@ -87,6 +90,9 @@ 02E224DB2BB76B3E00EF1ADB /* DynamicOffsetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02E224DA2BB76B3E00EF1ADB /* DynamicOffsetView.swift */; }; 02E225B0291D29EB0067769A /* UrlExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02E225AF291D29EB0067769A /* UrlExtension.swift */; }; 02E93F852AEBAEBC006C4750 /* AppReviewViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02E93F842AEBAEBC006C4750 /* AppReviewViewModel.swift */; }; + 02EBC7572C19DCDB00BE182C /* SyncStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02EBC7562C19DCDB00BE182C /* SyncStatus.swift */; }; + 02EBC7592C19DE1100BE182C /* CalendarManagerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02EBC7582C19DE1100BE182C /* CalendarManagerProtocol.swift */; }; + 02EBC75B2C19DE3D00BE182C /* CourseForSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02EBC75A2C19DE3D00BE182C /* CourseForSync.swift */; }; 02F164372902A9EB0090DDEF /* StringExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F164362902A9EB0090DDEF /* StringExtension.swift */; }; 02F6EF3B28D9B8EC00835477 /* CourseCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F6EF3A28D9B8EC00835477 /* CourseCellView.swift */; }; 02F6EF4A28D9F0A700835477 /* DateExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F6EF4928D9F0A700835477 /* DateExtension.swift */; }; @@ -205,8 +211,10 @@ 021D924728DC860C00ACC565 /* Data_UserProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_UserProfile.swift; sourceTree = ""; }; 021D924F28DC89D100ACC565 /* UserProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfile.swift; sourceTree = ""; }; 021D925628DCF12900ACC565 /* AlertView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertView.swift; sourceTree = ""; }; + 022020452C11BB2200D15795 /* Data_CourseDates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_CourseDates.swift; sourceTree = ""; }; 02280F5A294B4E6F0032823A /* Connectivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Connectivity.swift; sourceTree = ""; }; 02284C172A3B1AE00007117F /* UIApplicationExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIApplicationExtension.swift; sourceTree = ""; }; + 02286D152C106393005EEC8D /* CourseDates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseDates.swift; sourceTree = ""; }; 022C64E329AE0191000F532B /* TextWithUrls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextWithUrls.swift; sourceTree = ""; }; 0231CDBD2922422D00032416 /* CSSInjector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CSSInjector.swift; sourceTree = ""; }; 0233D56E2AF13EB200BAC8BD /* StarRatingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StarRatingView.swift; sourceTree = ""; }; @@ -264,6 +272,7 @@ 02A4833729B8A8F800D33F33 /* CoreDataModel.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = CoreDataModel.xcdatamodel; sourceTree = ""; }; 02A4833929B8A9AB00D33F33 /* DownloadManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadManager.swift; sourceTree = ""; }; 02A4833B29B8C57800D33F33 /* DownloadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadView.swift; sourceTree = ""; }; + 02A8C5802C05DBB4004B91FF /* Data_EnrollmentsStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_EnrollmentsStatus.swift; sourceTree = ""; }; 02AFCC172AEFDB24000360F0 /* ThirdPartyMailClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThirdPartyMailClient.swift; sourceTree = ""; }; 02AFCC192AEFDC18000360F0 /* ThirdPartyMailer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThirdPartyMailer.swift; sourceTree = ""; }; 02B2B593295C5C7A00914876 /* Thread.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thread.swift; sourceTree = ""; }; @@ -277,6 +286,9 @@ 02E224DA2BB76B3E00EF1ADB /* DynamicOffsetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicOffsetView.swift; sourceTree = ""; }; 02E225AF291D29EB0067769A /* UrlExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UrlExtension.swift; sourceTree = ""; }; 02E93F842AEBAEBC006C4750 /* AppReviewViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppReviewViewModel.swift; sourceTree = ""; }; + 02EBC7562C19DCDB00BE182C /* SyncStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncStatus.swift; sourceTree = ""; }; + 02EBC7582C19DE1100BE182C /* CalendarManagerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarManagerProtocol.swift; sourceTree = ""; }; + 02EBC75A2C19DE3D00BE182C /* CourseForSync.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseForSync.swift; sourceTree = ""; }; 02ED50CB29A64B84008341CD /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = ""; }; 02F164362902A9EB0090DDEF /* StringExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StringExtension.swift; sourceTree = ""; }; 02F6EF3A28D9B8EC00835477 /* CourseCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseCellView.swift; sourceTree = ""; }; @@ -611,6 +623,8 @@ 027BD3912907D88F00392132 /* Data_RegistrationFields.swift */, 028F9F36293A44C700DE65D0 /* Data_ResetPassword.swift */, 07E0939E2B308D2800F1E4B2 /* Data_Certificate.swift */, + 02A8C5802C05DBB4004B91FF /* Data_EnrollmentsStatus.swift */, + 022020452C11BB2200D15795 /* Data_CourseDates.swift */, ); path = Model; sourceTree = ""; @@ -637,7 +651,10 @@ 027BD39B2908810C00392132 /* RegisterUser.swift */, 028F9F38293A452B00DE65D0 /* ResetPassword.swift */, 020C31C8290AC3F700D6DEA2 /* PickerFields.swift */, + 02EBC75A2C19DE3D00BE182C /* CourseForSync.swift */, 076F297E2A1F80C800967E7D /* Pagination.swift */, + 02286D152C106393005EEC8D /* CourseDates.swift */, + 02EBC7562C19DCDB00BE182C /* SyncStatus.swift */, ); path = Model; sourceTree = ""; @@ -735,6 +752,7 @@ 025A204F2C071EB0003EA08D /* ErrorAlertView.swift */, 0727878028D25EFD002E9142 /* SnackBarView.swift */, 02F6EF3A28D9B8EC00835477 /* CourseCellView.swift */, + 02EBC7582C19DE1100BE182C /* CalendarManagerProtocol.swift */, 024FCCFF28EF1CD300232339 /* WebBrowser.swift */, 028CE96829858ECC00B6B1C3 /* FlexibleKeyboardInputView.swift */, 023A4DD3299E66BD006C0E48 /* OfflineSnackBarView.swift */, @@ -1089,6 +1107,7 @@ 0727878528D31657002E9142 /* Data_User.swift in Sources */, 064987942B4D69FF0071642A /* WebviewInjection.swift in Sources */, 02F6EF4A28D9F0A700835477 /* DateExtension.swift in Sources */, + 02EBC7572C19DCDB00BE182C /* SyncStatus.swift in Sources */, DBF6F2462B01DAFE0098414B /* AgreementConfig.swift in Sources */, 027BD3AF2909475000392132 /* DismissKeyboardTapHandler.swift in Sources */, 027F1BF72C071C820001A24C /* NavigationTitle.swift in Sources */, @@ -1102,6 +1121,7 @@ 027BD3B92909476200392132 /* KeyboardAvoidingModifier.swift in Sources */, BA4AFB422B5A7A0900A21367 /* VideoDownloadQualityView.swift in Sources */, 0770DE2C28D092B3006D8A5D /* NetworkLogger.swift in Sources */, + 02A8C5812C05DBB4004B91FF /* Data_EnrollmentsStatus.swift in Sources */, 06BEEA0E2B6A55C500D25A97 /* ColorInversionInjection.swift in Sources */, 064987972B4D69FF0071642A /* WebView.swift in Sources */, 0770DE2A28D0929E006D8A5D /* HTTPTask.swift in Sources */, @@ -1159,8 +1179,10 @@ 02C917F029CDA99E00DBB8BD /* Data_Enrollments.swift in Sources */, 024FCD0028EF1CD300232339 /* WebBrowser.swift in Sources */, 027BD3B52909475900392132 /* KeyboardStateObserver.swift in Sources */, + 022020462C11BB2200D15795 /* Data_CourseDates.swift in Sources */, 0283347D28D4D3DE00C828FC /* Data_Discovery.swift in Sources */, 064987962B4D69FF0071642A /* WebviewMessage.swift in Sources */, + 02EBC75B2C19DE3D00BE182C /* CourseForSync.swift in Sources */, 07DDFCBD29A780BB00572595 /* UINavigationController+Animation.swift in Sources */, 023A4DD4299E66BD006C0E48 /* OfflineSnackBarView.swift in Sources */, 021D925728DCF12900ACC565 /* AlertView.swift in Sources */, @@ -1190,6 +1212,7 @@ 071009D028D1E3A600344290 /* Constants.swift in Sources */, 0770DE1928D0847D006D8A5D /* BaseRouter.swift in Sources */, BA30427F2B20B320009B64B7 /* SocialAuthError.swift in Sources */, + 02286D162C106393005EEC8D /* CourseDates.swift in Sources */, 0284DBFE28D48C5300830893 /* CourseItem.swift in Sources */, 0248C92329C075EF00DC8402 /* CourseBlockModel.swift in Sources */, E0D5861A2B2FF74C009B4BA7 /* DiscoveryConfig.swift in Sources */, @@ -1203,6 +1226,7 @@ BAFB99842B0E282E007D09F9 /* MicrosoftConfig.swift in Sources */, 02B2B594295C5C7A00914876 /* Thread.swift in Sources */, E0D5861C2B2FF85B009B4BA7 /* RawStringExtactable.swift in Sources */, + 02EBC7592C19DE1100BE182C /* CalendarManagerProtocol.swift in Sources */, 027BD3BD2909478B00392132 /* UIView+EnclosingScrollView.swift in Sources */, BA8FA6682AD59A5700EA029A /* SocialAuthButton.swift in Sources */, 02D400612B0678190029D168 /* SKStoreReviewControllerExtension.swift in Sources */, diff --git a/Course/Course/Data/Model/Data_CourseDates.swift b/Core/Core/Data/Model/Data_CourseDates.swift similarity index 87% rename from Course/Course/Data/Model/Data_CourseDates.swift rename to Core/Core/Data/Model/Data_CourseDates.swift index 2ef5d339b..35616b020 100644 --- a/Course/Course/Data/Model/Data_CourseDates.swift +++ b/Core/Core/Data/Model/Data_CourseDates.swift @@ -1,12 +1,11 @@ // // Data_CourseDates.swift -// Course +// Core // -// Created by Muhammad Umer on 10/18/23. +// Created by  Stepanok Ivan on 05.06.2024. // import Foundation -import Core public extension DataLayer { struct CourseDates: Codable { @@ -100,46 +99,46 @@ public extension DataLayer { case upgradeToResetBanner case resetDatesBanner - var header: String { + public var header: String { switch self { case .datesTabInfoBanner: - CourseLocalization.CourseDates.ResetDate.TabInfoBanner.header + CoreLocalization.CourseDates.ResetDate.TabInfoBanner.header case .upgradeToCompleteGradedBanner: - CourseLocalization.CourseDates.ResetDate.UpgradeToCompleteGradedBanner.header + CoreLocalization.CourseDates.ResetDate.UpgradeToCompleteGradedBanner.header case .upgradeToResetBanner: - CourseLocalization.CourseDates.ResetDate.UpgradeToResetBanner.header + CoreLocalization.CourseDates.ResetDate.UpgradeToResetBanner.header case .resetDatesBanner: - CourseLocalization.CourseDates.ResetDate.ResetDateBanner.header + CoreLocalization.CourseDates.ResetDate.ResetDateBanner.header } } - var body: String { + public var body: String { switch self { case .datesTabInfoBanner: - CourseLocalization.CourseDates.ResetDate.TabInfoBanner.body + CoreLocalization.CourseDates.ResetDate.TabInfoBanner.body case .upgradeToCompleteGradedBanner: - CourseLocalization.CourseDates.ResetDate.UpgradeToCompleteGradedBanner.body + CoreLocalization.CourseDates.ResetDate.UpgradeToCompleteGradedBanner.body case .upgradeToResetBanner: - CourseLocalization.CourseDates.ResetDate.UpgradeToResetBanner.body + CoreLocalization.CourseDates.ResetDate.UpgradeToResetBanner.body case .resetDatesBanner: - CourseLocalization.CourseDates.ResetDate.ResetDateBanner.body + CoreLocalization.CourseDates.ResetDate.ResetDateBanner.body } } - var buttonTitle: String { + public var buttonTitle: String { switch self { case .upgradeToCompleteGradedBanner, .upgradeToResetBanner: // Mobile payments are not implemented yet and to avoid breaking appstore guidelines, // upgrade button is hidden, which leads user to payments "" case .resetDatesBanner: - CourseLocalization.CourseDates.ResetDate.ResetDateBanner.button + CoreLocalization.CourseDates.ResetDate.ResetDateBanner.button default: "" } } - var analyticsBannerType: String { + public var analyticsBannerType: String { switch self { case .datesTabInfoBanner: "info" diff --git a/Core/Core/Data/Model/Data_EnrollmentsStatus.swift b/Core/Core/Data/Model/Data_EnrollmentsStatus.swift new file mode 100644 index 000000000..df5acf0d2 --- /dev/null +++ b/Core/Core/Data/Model/Data_EnrollmentsStatus.swift @@ -0,0 +1,31 @@ +// +// Data_EnrollmentsStatus.swift +// Core +// +// Created by  Stepanok Ivan on 28.05.2024. +// + +import Foundation + +extension DataLayer { + // MARK: - EnrollmentsStatusElement + public struct EnrollmentsStatusElement: Codable { + public let courseID: String? + public let courseName: String? + public let isActive: Bool? + + public enum CodingKeys: String, CodingKey { + case courseID = "course_id" + case courseName = "course_name" + case isActive = "is_active" + } + + public init(courseID: String?, courseName: String?, isActive: Bool?) { + self.courseID = courseID + self.courseName = courseName + self.isActive = isActive + } + } + + public typealias EnrollmentsStatus = [EnrollmentsStatusElement] +} diff --git a/Course/Course/Domain/Model/CourseDates.swift b/Core/Core/Domain/Model/CourseDates.swift similarity index 70% rename from Course/Course/Domain/Model/CourseDates.swift rename to Core/Core/Domain/Model/CourseDates.swift index 966899cb9..5b1c6436c 100644 --- a/Course/Course/Domain/Model/CourseDates.swift +++ b/Core/Core/Domain/Model/CourseDates.swift @@ -1,20 +1,20 @@ // // CourseDates.swift -// Course +// Core // -// Created by Muhammad Umer on 10/18/23. +// Created by  Stepanok Ivan on 05.06.2024. // import Foundation -import Core +import CryptoKit public struct CourseDates { - let datesBannerInfo: DatesBannerInfo - let courseDateBlocks: [CourseDateBlock] - let hasEnded, learnerIsFullAccess: Bool - let userTimezone: String? + public let datesBannerInfo: DatesBannerInfo + public let courseDateBlocks: [CourseDateBlock] + public let hasEnded, learnerIsFullAccess: Bool + public let userTimezone: String? - var statusDatesBlocks: [CompletionStatus: [Date: [CourseDateBlock]]] { + public var statusDatesBlocks: [CompletionStatus: [Date: [CourseDateBlock]]] { var statusDatesBlocks: [CompletionStatus: [Date: [CourseDateBlock]]] = [:] var statusBlocks: [CompletionStatus: [CourseDateBlock]] = [:] @@ -56,15 +56,41 @@ public struct CourseDates { return statusDatesBlocks } - var dateBlocks: [Date: [CourseDateBlock]] { + public var dateBlocks: [Date: [CourseDateBlock]] { return courseDateBlocks.reduce(into: [:]) { result, block in let date = block.date result[date, default: []].append(block) } } + + public init( + datesBannerInfo: DatesBannerInfo, + courseDateBlocks: [CourseDateBlock], + hasEnded: Bool, + learnerIsFullAccess: Bool, + userTimezone: String? + ) { + self.datesBannerInfo = datesBannerInfo + self.courseDateBlocks = courseDateBlocks + self.hasEnded = hasEnded + self.learnerIsFullAccess = learnerIsFullAccess + self.userTimezone = userTimezone + } + + public var checksum: String { + var combinedString = "" + for block in self.courseDateBlocks { + let assignmentType = block.assignmentType ?? "" + combinedString += assignmentType + block.firstComponentBlockID + block.date.description + } + + let checksumData = SHA256.hash(data: Data(combinedString.utf8)) + let checksumString = checksumData.map { String(format: "%02hhx", $0) }.joined() + return checksumString + } } -extension Date { +public extension Date { static var today: Date { return Calendar.current.startOfDay(for: Date()) } @@ -120,26 +146,26 @@ extension Date { public struct CourseDateBlock: Identifiable { public let id: UUID = UUID() - let assignmentType: String? - let complete: Bool? - let date: Date - let dateType, description: String - let learnerHasAccess: Bool - let link: String - let linkText: String? - let title: String - let extraInfo: String? - let firstComponentBlockID: String - - var formattedDate: String { + public let assignmentType: String? + public let complete: Bool? + public let date: Date + public let dateType, description: String + public let learnerHasAccess: Bool + public let link: String + public let linkText: String? + public let title: String + public let extraInfo: String? + public let firstComponentBlockID: String + + public var formattedDate: String { return date.dateToString(style: .shortWeekdayMonthDayYear) } - var isInPast: Bool { + public var isInPast: Bool { return date.isInPast } - var isToday: Bool { + public var isToday: Bool { if dateType.isEmpty { return true } else { @@ -147,55 +173,55 @@ public struct CourseDateBlock: Identifiable { } } - var isInFuture: Bool { + public var isInFuture: Bool { return date.isInFuture } - var isThisWeek: Bool { + public var isThisWeek: Bool { return date.isThisWeek } - var isNextWeek: Bool { + public var isNextWeek: Bool { return date.isNextWeek } - var isUpcoming: Bool { + public var isUpcoming: Bool { return date.isUpcoming } - var isAssignment: Bool { + public var isAssignment: Bool { return BlockStatus.status(of: dateType) == .assignment } - var isVerifiedOnly: Bool { + public var isVerifiedOnly: Bool { return !learnerHasAccess } - var isComplete: Bool { + public var isComplete: Bool { return complete ?? false } - var isLearnerAssignment: Bool { + public var isLearnerAssignment: Bool { return learnerHasAccess && isAssignment } - var isPastDue: Bool { + public var isPastDue: Bool { return !isComplete && (date < .today) } - var isUnreleased: Bool { + public var isUnreleased: Bool { return link.isEmpty } - var canShowLink: Bool { + public var canShowLink: Bool { return !isUnreleased && isLearnerAssignment } - var isAvailable: Bool { + public var isAvailable: Bool { return learnerHasAccess && (!isUnreleased || !isLearnerAssignment) } - var blockStatus: BlockStatus { + public var blockStatus: BlockStatus { if isComplete { return .completed } @@ -215,7 +241,7 @@ public struct CourseDateBlock: Identifiable { return BlockStatus.status(of: dateType) } - var blockImage: ImageAsset? { + public var blockImage: ImageAsset? { if !learnerHasAccess { return CoreAssets.lockIcon } @@ -240,14 +266,33 @@ public struct CourseDateBlock: Identifiable { } public struct DatesBannerInfo { - let missedDeadlines, contentTypeGatingEnabled, missedGatedContent: Bool - let verifiedUpgradeLink: String? - let status: DataLayer.BannerInfoStatus? + public let missedDeadlines, contentTypeGatingEnabled, missedGatedContent: Bool + public let verifiedUpgradeLink: String? + public let status: DataLayer.BannerInfoStatus? + + public init( + missedDeadlines: Bool, + contentTypeGatingEnabled: Bool, + missedGatedContent: Bool, + verifiedUpgradeLink: String?, + status: DataLayer.BannerInfoStatus? + ) { + self.missedDeadlines = missedDeadlines + self.contentTypeGatingEnabled = contentTypeGatingEnabled + self.missedGatedContent = missedGatedContent + self.verifiedUpgradeLink = verifiedUpgradeLink + self.status = status + } } public struct CourseDateBanner { - let datesBannerInfo: DatesBannerInfo - let hasEnded: Bool + public let datesBannerInfo: DatesBannerInfo + public let hasEnded: Bool + + public init(datesBannerInfo: DatesBannerInfo, hasEnded: Bool) { + self.datesBannerInfo = datesBannerInfo + self.hasEnded = hasEnded + } } public enum BlockStatus { @@ -288,7 +333,7 @@ public enum CompletionStatus: String { case upcoming = "Upcoming" } -extension Array { +public extension Array { mutating func modifyForEach(_ body: (_ element: inout Element) -> Void) { for index in indices { modifyElement(atIndex: index) { body(&$0) } diff --git a/Core/Core/Domain/Model/CourseForSync.swift b/Core/Core/Domain/Model/CourseForSync.swift new file mode 100644 index 000000000..938c6efa2 --- /dev/null +++ b/Core/Core/Domain/Model/CourseForSync.swift @@ -0,0 +1,43 @@ +// +// CourseForSync.swift +// Core +// +// Created by  Stepanok Ivan on 12.06.2024. +// + +import Foundation + +// MARK: - CourseForSync +public struct CourseForSync: Identifiable { + public let id: UUID + public let courseID: String + public let name: String + public var synced: Bool + public var active: Bool + + public init(id: UUID = UUID(), courseID: String, name: String, synced: Bool, active: Bool) { + self.id = id + self.courseID = courseID + self.name = name + self.synced = synced + self.active = active + } +} + +extension DataLayer.EnrollmentsStatus { + public var domain: [CourseForSync] { + self.compactMap { + guard let courseID = $0.courseID, + let courseName = $0.courseName, + let isActive = $0.isActive else { return nil } + return CourseForSync( + id: UUID(), + courseID: courseID, + name: courseName, + synced: false, + active: isActive + ) + } + } +} + diff --git a/Core/Core/Domain/Model/SyncStatus.swift b/Core/Core/Domain/Model/SyncStatus.swift new file mode 100644 index 000000000..a32d0cc4f --- /dev/null +++ b/Core/Core/Domain/Model/SyncStatus.swift @@ -0,0 +1,14 @@ +// +// SyncStatus.swift +// Core +// +// Created by  Stepanok Ivan on 12.06.2024. +// + +import Foundation + +public enum SyncStatus { + case synced + case failed + case offline +} diff --git a/Core/Core/SwiftGen/Strings.swift b/Core/Core/SwiftGen/Strings.swift index 4bd41f9eb..8cdf97b6e 100644 --- a/Core/Core/SwiftGen/Strings.swift +++ b/Core/Core/SwiftGen/Strings.swift @@ -78,6 +78,46 @@ public enum CoreLocalization { return CoreLocalization.tr("Localizable", "COURSEWARE.SECTION_COMPLETED", String(describing: p1), fallback: "You've completed “%@”.") } } + public enum CourseDates { + public enum ResetDate { + /// Your dates could not be shifted. Please try again. + public static let errorMessage = CoreLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.ERROR_MESSAGE", fallback: "Your dates could not be shifted. Please try again.") + /// Your dates have been successfully shifted. + public static let successMessage = CoreLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.SUCCESS_MESSAGE", fallback: "Your dates have been successfully shifted.") + /// Course Dates + public static let title = CoreLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.TITLE", fallback: "Course Dates") + public enum ResetDateBanner { + /// Don't worry - shift our suggested schedule to complete past due assignments without losing any progress. + public static let body = CoreLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.RESET_DATE_BANNER.BODY", fallback: "Don't worry - shift our suggested schedule to complete past due assignments without losing any progress.") + /// Shift due dates + public static let button = CoreLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.RESET_DATE_BANNER.BUTTON", fallback: "Shift due dates") + /// Missed some deadlines? + public static let header = CoreLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.RESET_DATE_BANNER.HEADER", fallback: "Missed some deadlines?") + } + public enum TabInfoBanner { + /// We built a suggested schedule to help you stay on track. But don’t worry – it’s flexible so you can learn at your own pace. If you happen to fall behind, you’ll be able to adjust the dates to keep yourself on track. + public static let body = CoreLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.TAB_INFO_BANNER.BODY", fallback: "We built a suggested schedule to help you stay on track. But don’t worry – it’s flexible so you can learn at your own pace. If you happen to fall behind, you’ll be able to adjust the dates to keep yourself on track.") + /// + public static let header = CoreLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.TAB_INFO_BANNER.HEADER", fallback: "") + } + public enum UpgradeToCompleteGradedBanner { + /// To complete graded assignments as part of this course, you can upgrade today. + public static let body = CoreLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.UPGRADE_TO_COMPLETE_GRADED_BANNER.BODY", fallback: "To complete graded assignments as part of this course, you can upgrade today.") + /// + public static let button = CoreLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.UPGRADE_TO_COMPLETE_GRADED_BANNER.BUTTON", fallback: "") + /// + public static let header = CoreLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.UPGRADE_TO_COMPLETE_GRADED_BANNER.HEADER", fallback: "") + } + public enum UpgradeToResetBanner { + /// You are auditing this course, which means that you are unable to participate in graded assignments. It looks like you missed some important deadlines based on our suggested schedule. To complete graded assignments as part of this course and shift the past due assignments into the future, you can upgrade today. + public static let body = CoreLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.BODY", fallback: "You are auditing this course, which means that you are unable to participate in graded assignments. It looks like you missed some important deadlines based on our suggested schedule. To complete graded assignments as part of this course and shift the past due assignments into the future, you can upgrade today.") + /// + public static let button = CoreLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.BUTTON", fallback: "") + /// + public static let header = CoreLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.HEADER", fallback: "") + } + } + } public enum Date { /// Course Ended public static let courseEnded = CoreLocalization.tr("Localizable", "DATE.COURSE_ENDED", fallback: "Course Ended") diff --git a/Core/Core/View/Base/CalendarManagerProtocol.swift b/Core/Core/View/Base/CalendarManagerProtocol.swift new file mode 100644 index 000000000..ef7fdcb3e --- /dev/null +++ b/Core/Core/View/Base/CalendarManagerProtocol.swift @@ -0,0 +1,36 @@ +// +// CalendarManagerProtocol.swift +// Core +// +// Created by  Stepanok Ivan on 12.06.2024. +// + +import Foundation + +public protocol CalendarManagerProtocol { + func createCalendarIfNeeded() + func filterCoursesBySelected(fetchedCourses: [CourseForSync]) async -> [CourseForSync] + func removeOldCalendar() + func removeOutdatedEvents(courseID: String) async + func syncCourse(courseID: String, courseName: String, dates: CourseDates) async + func requestAccess() async -> Bool + func courseStatus(courseID: String) -> SyncStatus + func clearAllData(removeCalendar: Bool) + func isDatesChanged(courseID: String, checksum: String) -> Bool +} + +#if DEBUG +public struct CalendarManagerMock: CalendarManagerProtocol { + public func createCalendarIfNeeded() {} + public func filterCoursesBySelected(fetchedCourses: [CourseForSync]) async -> [CourseForSync] {[]} + public func removeOldCalendar() {} + public func removeOutdatedEvents(courseID: String) async {} + public func syncCourse(courseID: String, courseName: String, dates: CourseDates) async {} + public func requestAccess() async -> Bool { true } + public func courseStatus(courseID: String) -> SyncStatus { .synced } + public func clearAllData(removeCalendar: Bool) {} + public func isDatesChanged(courseID: String, checksum: String) -> Bool {false} + + public init() {} +} +#endif diff --git a/Core/Core/en.lproj/Localizable.strings b/Core/Core/en.lproj/Localizable.strings index b1fda17c5..b4ca1bc64 100644 --- a/Core/Core/en.lproj/Localizable.strings +++ b/Core/Core/en.lproj/Localizable.strings @@ -120,3 +120,22 @@ "YESTERDAY" = "Yesterday"; "OPEN_IN_BROWSER"="View in Safari"; + +"COURSE_DATES.RESET_DATE.RESET_DATE_BANNER.BODY" = "Don't worry - shift our suggested schedule to complete past due assignments without losing any progress."; +"COURSE_DATES.RESET_DATE.RESET_DATE_BANNER.BUTTON" = "Shift due dates"; +"COURSE_DATES.RESET_DATE.RESET_DATE_BANNER.HEADER" = "Missed some deadlines?"; + +"COURSE_DATES.RESET_DATE.TAB_INFO_BANNER.BODY" = "We built a suggested schedule to help you stay on track. But don’t worry – it’s flexible so you can learn at your own pace. If you happen to fall behind, you’ll be able to adjust the dates to keep yourself on track."; +"COURSE_DATES.RESET_DATE.TAB_INFO_BANNER.HEADER" = ""; + +"COURSE_DATES.RESET_DATE.UPGRADE_TO_COMPLETE_GRADED_BANNER.BODY" = "To complete graded assignments as part of this course, you can upgrade today."; +"COURSE_DATES.RESET_DATE.UPGRADE_TO_COMPLETE_GRADED_BANNER.BUTTON" = ""; +"COURSE_DATES.RESET_DATE.UPGRADE_TO_COMPLETE_GRADED_BANNER.HEADER" = ""; + +"COURSE_DATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.BODY" = "You are auditing this course, which means that you are unable to participate in graded assignments. It looks like you missed some important deadlines based on our suggested schedule. To complete graded assignments as part of this course and shift the past due assignments into the future, you can upgrade today."; +"COURSE_DATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.BUTTON" = ""; +"COURSE_DATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.HEADER" = ""; + +"COURSE_DATES.RESET_DATE.ERROR_MESSAGE" = "Your dates could not be shifted. Please try again."; +"COURSE_DATES.RESET_DATE.SUCCESS_MESSAGE" = "Your dates have been successfully shifted."; +"COURSE_DATES.RESET_DATE.TITLE" = "Course Dates"; diff --git a/Core/Core/uk.lproj/Localizable.strings b/Core/Core/uk.lproj/Localizable.strings index 1026b82e0..f907e02f0 100644 --- a/Core/Core/uk.lproj/Localizable.strings +++ b/Core/Core/uk.lproj/Localizable.strings @@ -110,7 +110,18 @@ "SIGN_IN.LOG_IN_BTN" = "Увійти"; "REGISTER" = "Реєстрація"; -"TOMORROW" = "Tomorrow"; -"YESTERDAY" = "Yesterday"; - -"OPEN_IN_BROWSER"="View in Safari"; +"TOMORROW" = "Завтра"; +"YESTERDAY" = "Учора"; +"OPEN_IN_BROWSER"="Переглянути в Safari"; +"COURSE_DATES.RESET_DATE.RESET_DATE_BANNER.BODY" = "Не хвилюйтеся - перенесіть наш запропонований розклад, щоб виконати прострочені завдання без втрати прогресу."; +"COURSE_DATES.RESET_DATE.RESET_DATE_BANNER.BUTTON" = "Зміщення термінів виконання"; +"COURSE_DATES.RESET_DATE.RESET_DATE_BANNER.HEADER" = "Пропустив деякі терміни?"; +"COURSE_DATES.RESET_DATE.TAB_INFO_BANNER.BODY" = "Ми склали запропонований розклад, щоб допомогти вам не відставати від курсу. Але не хвилюйтеся – він гнучкий, тож ви можете навчатися у своєму власному темпі. Якщо трапиться, що ви відстаєте, ви будете мати можливість коригувати дати, щоб тримати себе в курсі."; +"COURSE_DATES.RESET_DATE.TAB_INFO_BANNER.HEADER" = ""; +"COURSE_DATES.RESET_DATE.UPGRADE_TO_COMPLETE_GRADED_BANNER.BODY" = "Щоб виконати оцінені завдання в рамках цього курсу, ви можете оновити сьогодні."; "COURSE_DATES.RESET_DATE.UPGRADE_TO_COMPLETE_GRADED_BANNER.BUTTON" = ""; +"COURSE_DATES.RESET_DATE.UPGRADE_TO_COMPLETE_GRADED_BANNER.HEADER" = ""; +"COURSE_DATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.BODY" = "Ви перевіряєте цей курс, що означає, що ви не можете брати участь у оцінюваних завданнях. Схоже, що ви пропустили деякі важливі терміни згідно з нашим запропонованим розкладом. Щоб виконати оцінені завдання в рамках цей курс і перенести прострочені завдання в майбутнє, ви можете оновити сьогодні."; +"COURSE_DATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.BUTTON" = ""; +"COURSE_DATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.HEADER" = ""; +"COURSE_DATES.RESET_DATE.ERROR_MESSAGE" = "Ваші дати не можуть бути зміщені. Спробуйте ще раз."; +"COURSE_DATES.RESET_DATE.SUCCESS_MESSAGE" = "Ваші дати успішно перенесено."; "COURSE_DATES.RESET_DATE.TITLE" = "Дати курсу"; diff --git a/Course/Course.xcodeproj/project.pbxproj b/Course/Course.xcodeproj/project.pbxproj index cc778b214..33c43eeaf 100644 --- a/Course/Course.xcodeproj/project.pbxproj +++ b/Course/Course.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 02197DC52C1AFC0600CC8FF2 /* CalendarSyncStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02EBC7542C19CFCF00BE182C /* CalendarSyncStatusView.swift */; }; 02280F5E294B4FDA0032823A /* CourseCoreModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 02280F5C294B4FDA0032823A /* CourseCoreModel.xcdatamodeld */; }; 02280F60294B50030032823A /* CoursePersistenceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02280F5F294B50030032823A /* CoursePersistenceProtocol.swift */; }; 022C64D829ACEC48000F532B /* HandoutsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022C64D729ACEC48000F532B /* HandoutsView.swift */; }; @@ -82,7 +83,6 @@ 97C99C362B9A08FE004EEDE2 /* CalendarSyncProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97C99C352B9A08FE004EEDE2 /* CalendarSyncProgressView.swift */; }; 97CA95252B875EE200A9EDEA /* DatesSuccessView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97CA95242B875EE200A9EDEA /* DatesSuccessView.swift */; }; 97E7DF0F2B7C852A00A2A09B /* DatesStatusInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97E7DF0E2B7C852A00A2A09B /* DatesStatusInfoView.swift */; }; - 97EA4D862B85034D00663F58 /* CalendarManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97EA4D852B85034D00663F58 /* CalendarManager.swift */; }; B8F50317B6B830A0E520C954 /* Pods_App_Course_CourseTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 50E59D2B81E12610964282C5 /* Pods_App_Course_CourseTests.framework */; }; BA58CF5D2B3D804D005B102E /* CourseStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA58CF5C2B3D804D005B102E /* CourseStorage.swift */; }; BA58CF612B471041005B102E /* VideoDownloadQualityBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA58CF602B471041005B102E /* VideoDownloadQualityBarView.swift */; }; @@ -95,8 +95,6 @@ DB205BFB2AE81B1200136EC2 /* CourseDateViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB205BFA2AE81B1200136EC2 /* CourseDateViewModelTests.swift */; }; DB7D6EAC2ADFCAC50036BB13 /* CourseDatesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB7D6EAB2ADFCAC40036BB13 /* CourseDatesView.swift */; }; DB7D6EAE2ADFCB4A0036BB13 /* CourseDatesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB7D6EAD2ADFCB4A0036BB13 /* CourseDatesViewModel.swift */; }; - DB7D6EB02ADFDA0E0036BB13 /* CourseDates.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB7D6EAF2ADFDA0E0036BB13 /* CourseDates.swift */; }; - DB7D6EB22ADFE9510036BB13 /* Data_CourseDates.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB7D6EB12ADFE9510036BB13 /* Data_CourseDates.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -155,6 +153,7 @@ 02C3553A2C08DCE000501342 /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = uk; path = uk.lproj/Localizable.stringsdict; sourceTree = ""; }; 02D4FC2D2BBD7C9C00C47748 /* MessageSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageSectionView.swift; sourceTree = ""; }; 02E3803D2BFF9F0A00815AFA /* CourseProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseProgressView.swift; sourceTree = ""; }; + 02EBC7542C19CFCF00BE182C /* CalendarSyncStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarSyncStatusView.swift; sourceTree = ""; }; 02ED50CF29A64BB6008341CD /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = ""; }; 02F0144E28F46474002E513D /* CourseContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseContainerView.swift; sourceTree = ""; }; 02F0145628F4A2FF002E513D /* CourseContainerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseContainerViewModel.swift; sourceTree = ""; }; @@ -199,7 +198,6 @@ 97C99C352B9A08FE004EEDE2 /* CalendarSyncProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarSyncProgressView.swift; sourceTree = ""; }; 97CA95242B875EE200A9EDEA /* DatesSuccessView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DatesSuccessView.swift; sourceTree = ""; }; 97E7DF0E2B7C852A00A2A09B /* DatesStatusInfoView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DatesStatusInfoView.swift; sourceTree = ""; }; - 97EA4D852B85034D00663F58 /* CalendarManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarManager.swift; sourceTree = ""; }; 99AEF08FD75F1509863D3302 /* Pods-App-CourseDetails.debugprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-CourseDetails.debugprod.xcconfig"; path = "Target Support Files/Pods-App-CourseDetails/Pods-App-CourseDetails.debugprod.xcconfig"; sourceTree = ""; }; 9B5D3D31A9CFA08B6C4347BD /* Pods-App-CourseDetails.releasedev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-CourseDetails.releasedev.xcconfig"; path = "Target Support Files/Pods-App-CourseDetails/Pods-App-CourseDetails.releasedev.xcconfig"; sourceTree = ""; }; A47C63D9EB0D866F303D4588 /* Pods-App-Course.releasestage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Course.releasestage.xcconfig"; path = "Target Support Files/Pods-App-Course/Pods-App-Course.releasestage.xcconfig"; sourceTree = ""; }; @@ -216,8 +214,6 @@ DB205BFA2AE81B1200136EC2 /* CourseDateViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseDateViewModelTests.swift; sourceTree = ""; }; DB7D6EAB2ADFCAC40036BB13 /* CourseDatesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CourseDatesView.swift; sourceTree = ""; }; DB7D6EAD2ADFCB4A0036BB13 /* CourseDatesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseDatesViewModel.swift; sourceTree = ""; }; - DB7D6EAF2ADFDA0E0036BB13 /* CourseDates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseDates.swift; sourceTree = ""; }; - DB7D6EB12ADFE9510036BB13 /* Data_CourseDates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_CourseDates.swift; sourceTree = ""; }; DBE05972CB5115D4535C6B8A /* Pods-App-Course-CourseTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Course-CourseTests.debug.xcconfig"; path = "Target Support Files/Pods-App-Course-CourseTests/Pods-App-Course-CourseTests.debug.xcconfig"; sourceTree = ""; }; E5E795BD160CDA7D9C120DE6 /* Pods_App_Course.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App_Course.framework; sourceTree = BUILT_PRODUCTS_DIR; }; E6BDAE887ED8A46860B3F6D3 /* Pods-App-Course-CourseTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Course-CourseTests.release.xcconfig"; path = "Target Support Files/Pods-App-Course-CourseTests/Pods-App-Course-CourseTests.release.xcconfig"; sourceTree = ""; }; @@ -313,6 +309,7 @@ 02B6B3B828E1D12900232911 /* Data */, 02B6B3B528E1D10700232911 /* Domain */, 02EAE2CA28E1F0A700529644 /* Presentation */, + 97CA95212B875EA200A9EDEA /* Views */, 97EA4D822B84EFA900663F58 /* Managers */, 02B6B3B428E1C49400232911 /* Localizable.strings */, 02C355372C08DCD700501342 /* Localizable.stringsdict */, @@ -365,7 +362,6 @@ 027020FB28E7362100F54332 /* Data_CourseOutlineResponse.swift */, 022C64DB29ACFDEE000F532B /* Data_HandoutsResponse.swift */, 022C64DF29ADEA9B000F532B /* Data_UpdatesResponse.swift */, - DB7D6EB12ADFE9510036BB13 /* Data_CourseDates.swift */, ); path = Model; sourceTree = ""; @@ -434,7 +430,6 @@ 02B6B3C228E1DCD100232911 /* CourseDetails.swift */, 0276D75C29DDA3F80004CDF8 /* ResumeBlock.swift */, 022C64E129ADEB83000F532B /* CourseUpdate.swift */, - DB7D6EAF2ADFDA0E0036BB13 /* CourseDates.swift */, ); path = Model; sourceTree = ""; @@ -544,14 +539,6 @@ path = DatesSuccessView; sourceTree = ""; }; - 97EA4D822B84EFA900663F58 /* Managers */ = { - isa = PBXGroup; - children = ( - 97EA4D852B85034D00663F58 /* CalendarManager.swift */, - ); - path = Managers; - sourceTree = ""; - }; BA58CF622B471047005B102E /* VideoDownloadQualityBarView */ = { isa = PBXGroup; children = ( @@ -600,6 +587,7 @@ 02FCB2B22BBEB36600373180 /* CourseHeaderView.swift */, 02BB20172BFCE7B200364948 /* CustomDisclosureGroup.swift */, 02E3803D2BFF9F0A00815AFA /* CourseProgressView.swift */, + 02EBC7542C19CFCF00BE182C /* CalendarSyncStatusView.swift */, ); path = Subviews; sourceTree = ""; @@ -880,7 +868,6 @@ 02280F60294B50030032823A /* CoursePersistenceProtocol.swift in Sources */, 02454CAA2A2619B40043052A /* LessonProgressView.swift in Sources */, 975F475E2B6151FD00E5B031 /* CourseDatesMock.swift in Sources */, - DB7D6EB22ADFE9510036BB13 /* Data_CourseDates.swift in Sources */, 975F47602B615DA700E5B031 /* CourseStructureMock.swift in Sources */, 02280F5E294B4FDA0032823A /* CourseCoreModel.xcdatamodeld in Sources */, 0766DFCE299AB26D00EBEF6A /* EncodedVideoPlayer.swift in Sources */, @@ -900,9 +887,9 @@ 067B7B4F2BED339200D1768F /* PlayerDelegateProtocol.swift in Sources */, 0231124D28EDA804002588FB /* CourseUnitView.swift in Sources */, 027020FC28E7362100F54332 /* Data_CourseOutlineResponse.swift in Sources */, - DB7D6EB02ADFDA0E0036BB13 /* CourseDates.swift in Sources */, 067B7B532BED339200D1768F /* PlayerServiceProtocol.swift in Sources */, BAD9CA2D2B2736BB00DE790A /* LessonLineProgressView.swift in Sources */, + 02197DC52C1AFC0600CC8FF2 /* CalendarSyncStatusView.swift in Sources */, 060E8BCA2B5FD68C0080C952 /* UnitStack.swift in Sources */, 0295C889299BBE8200ABE571 /* CourseNavigationView.swift in Sources */, 06FD7EDF2B1F29F3008D632B /* CourseVerticalImageView.swift in Sources */, @@ -931,7 +918,6 @@ 02E3803E2BFF9F0A00815AFA /* CourseProgressView.swift in Sources */, 022F8E162A1DFBC6008EFAB9 /* YouTubeVideoPlayerViewModel.swift in Sources */, 02B6B3BE28E1D15C00232911 /* CourseEndpoint.swift in Sources */, - 97EA4D862B85034D00663F58 /* CalendarManager.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Course/Course/Managers/CalendarManager.swift b/Course/Course/Managers/CalendarManager.swift deleted file mode 100644 index 118880aef..000000000 --- a/Course/Course/Managers/CalendarManager.swift +++ /dev/null @@ -1,480 +0,0 @@ -// -// CalendarManager.swift -// Course -// -// Created by Shafqat Muneer on 2/20/24. -// - -import Foundation -import EventKit -import Theme -import Core -import BranchSDK - -enum CalendarDeepLinkType: String { - case courseComponent = "course_component" -} - -private enum CalendarDeepLinkKeys: String, RawStringExtractable { - case courseID = "course_id" - case screenName = "screen_name" - case componentID = "component_id" -} - -struct CourseCalendar: Codable { - var identifier: String - let courseID: String - let title: String - var isOn: Bool - var modalPresented: Bool -} - -class CalendarManager: NSObject { - - private let courseName: String - private let courseID: String - private let courseStructure: CourseStructure? - private let config: ConfigProtocol - - private let eventStore = EKEventStore() - private let iCloudCalendar = "icloud" - private let alertOffset = -1 - private let calendarKey = "CalendarEntries" - - private var localCalendar: EKCalendar? { - if authorizationStatus != .authorized { return nil } - - var calendars = eventStore.calendars(for: .event).filter { $0.title == calendarName } - - if calendars.isEmpty { - return nil - } else { - let calendar = calendars.removeLast() - // calendars.removeLast() pop the element from array and after that, - // following is run on remaing members of array to remove them - // calendar app, if they had been added. - calendars.forEach { try? eventStore.removeCalendar($0, commit: true) } - - return calendar - } - } - - private let calendarColor = Theme.Colors.accentColor - - private var calendarSource: EKSource? { - eventStore.refreshSourcesIfNecessary() - - let iCloud = eventStore.sources.first( - where: { $0.sourceType == .calDAV && $0.title.localizedCaseInsensitiveContains(iCloudCalendar) }) - let local = eventStore.sources.first(where: { $0.sourceType == .local }) - let fallback = eventStore.defaultCalendarForNewEvents?.source - - return iCloud ?? local ?? fallback - } - - private func calendar() -> EKCalendar { - let calendar = EKCalendar(for: .event, eventStore: eventStore) - calendar.title = calendarName - calendar.cgColor = calendarColor.cgColor - calendar.source = calendarSource - - return calendar - } - - var authorizationStatus: EKAuthorizationStatus { - return EKEventStore.authorizationStatus(for: .event) - } - - var calendarName: String { - return config.platformName + " - " + courseName - } - - private lazy var branchEnabled: Bool = { - return config.branch.enabled - }() - - var syncOn: Bool { - get { - if let calendarEntry = calendarEntry, - let localCalendar = localCalendar, - calendarEntry.identifier == localCalendar.calendarIdentifier { - return calendarEntry.isOn - } else if let localCalendar = localCalendar { - let courseCalendar = CourseCalendar( - identifier: localCalendar.calendarIdentifier, - courseID: courseID, - title: calendarName, - isOn: true, - modalPresented: false - ) - addOrUpdateCalendarEntry(courseCalendar: courseCalendar) - return true - } - return false - } - set { - updateCalendarState(isOn: newValue) - } - } - - var isModalPresented: Bool { - get { - return getModalPresented() - } - set { - setModalPresented(presented: newValue) - } - } - - required init(courseID: String, courseName: String, courseStructure: CourseStructure?, config: ConfigProtocol) { - self.courseID = courseID - self.courseName = courseName - self.courseStructure = courseStructure - self.config = config - } - - func requestAccess(completion: @escaping (Bool, EKAuthorizationStatus, EKAuthorizationStatus) -> Void) { - let previousStatus = EKEventStore.authorizationStatus(for: .event) - let requestHandler: (Bool, Error?) -> Void = { [weak self] access, _ in - self?.eventStore.reset() - let currentStatus = EKEventStore.authorizationStatus(for: .event) - DispatchQueue.main.async { - completion(access, previousStatus, currentStatus) - } - } - - if #available(iOS 17.0, *) { - eventStore.requestFullAccessToEvents { access, error in - requestHandler(access, error) - } - } else { - eventStore.requestAccess(to: .event) { access, error in - requestHandler(access, error) - } - } - } - - private func generateCourseCalendar() -> Bool { - guard localCalendar == nil else { return true } - do { - let newCalendar = calendar() - try eventStore.saveCalendar(newCalendar, commit: true) - - let courseCalendar: CourseCalendar - - if var calendarEntry = calendarEntry { - calendarEntry.identifier = newCalendar.calendarIdentifier - courseCalendar = calendarEntry - } else { - courseCalendar = CourseCalendar( - identifier: newCalendar.calendarIdentifier, - courseID: courseID, - title: calendarName, - isOn: true, - modalPresented: false - ) - } - - addOrUpdateCalendarEntry(courseCalendar: courseCalendar) - - return true - } catch { - return false - } - } - - func removeCalendar(completion: ((Bool) -> Void)? = nil) { - guard let calendar = localCalendar else { return } - do { - try eventStore.removeCalendar(calendar, commit: true) - updateSyncSwitchStatus(isOn: false) - completion?(true) - } catch { - completion?(false) - } - } - - private func calendarEvent(for block: CourseDateBlock, generateDeepLink: Bool) -> EKEvent? { - guard !block.title.isEmpty else { return nil } - - let title = block.title + ": " + courseName - // startDate is the start date and time for the event, - // it is also being used as first alert for the event - let startDate = block.date.add(.hour, value: alertOffset) - let secondAlert = startDate.add(.day, value: alertOffset) - let endDate = block.date - var notes = "\(courseName)\n\n\(block.title)" - - if generateDeepLink && block.isAvailable && branchEnabled { - if let link = generateDeeplink(componentBlockID: block.firstComponentBlockID) { - notes += "\n\(link)" - } - } - - return generateEvent( - title: title, - startDate: startDate, - endDate: endDate, - secondAlert: secondAlert, - notes: notes - ) - } - - private func calendarEvent(for blocks: [CourseDateBlock], generateDeepLink: Bool) -> EKEvent? { - guard let block = blocks.first, !block.title.isEmpty else { return nil } - - let title = block.title + ": " + courseName - // startDate is the start date and time for the event, - // it is also being used as first alert for the event - let startDate = block.date.add(.hour, value: alertOffset) - let secondAlert = startDate.add(.day, value: alertOffset) - let endDate = block.date - let notes = "\(courseName)\n\n" + blocks.compactMap { block -> String in - if generateDeepLink && block.isAvailable && branchEnabled { - if let link = generateDeeplink(componentBlockID: block.firstComponentBlockID) { - return "\(block.title)\n\(link)" - } else { - return block.title - } - } else { - return block.title - } - }.joined(separator: "\n\n") - - return generateEvent( - title: title, - startDate: startDate, - endDate: endDate, - secondAlert: secondAlert, - notes: notes - ) - } - - private func generateDeeplink(componentBlockID: String) -> String? { - guard !componentBlockID.isEmpty else { return nil } - let branchUniversalObject = BranchUniversalObject( - canonicalIdentifier: "\(CalendarDeepLinkType.courseComponent.rawValue)/\(componentBlockID)" - ) - let dictionary: NSMutableDictionary = [ - CalendarDeepLinkKeys.screenName.rawValue: CalendarDeepLinkType.courseComponent.rawValue, - CalendarDeepLinkKeys.courseID.rawValue: courseID, - CalendarDeepLinkKeys.componentID.rawValue: componentBlockID - ] - let metadata = BranchContentMetadata() - metadata.customMetadata = dictionary - branchUniversalObject.contentMetadata = metadata - let properties = BranchLinkProperties() - if let block = courseStructure?.blockWithID(courseBlockId: componentBlockID), !block.webUrl.isEmpty { - properties.addControlParam("$desktop_url", withValue: block.webUrl) - } - return branchUniversalObject.getShortUrl(with: properties) - } - - private func generateEvent(title: String, - startDate: Date, - endDate: Date, - secondAlert: Date, - notes: String) -> EKEvent { - let event = EKEvent(eventStore: eventStore) - event.title = title - event.startDate = startDate - event.endDate = endDate - event.calendar = localCalendar - event.notes = notes - - if startDate > Date() { - let alarm = EKAlarm(absoluteDate: startDate) - event.addAlarm(alarm) - } - - if secondAlert > Date() { - let alarm = EKAlarm(absoluteDate: secondAlert) - event.addAlarm(alarm) - } - - return event - } - - private func addEvent(event: EKEvent) { - if !alreadyExist(event: event) { - try? eventStore.save(event, span: .thisEvent) - } - } - - private func alreadyExist(event eventToAdd: EKEvent) -> Bool { - guard let courseCalendar = calendarEntry else { return false } - let calendars = eventStore.calendars(for: .event).filter { $0.calendarIdentifier == courseCalendar.identifier } - let predicate = eventStore.predicateForEvents( - withStart: eventToAdd.startDate, - end: eventToAdd.endDate, - calendars: calendars - ) - let existingEvents = eventStore.events(matching: predicate) - - return existingEvents.contains { event -> Bool in - return event.title == eventToAdd.title - && event.startDate == eventToAdd.startDate - && event.endDate == eventToAdd.endDate - } - } - - private func setModalPresented(presented: Bool) { - guard var calendars = courseCalendars(), - let index = calendars.firstIndex(where: { $0.title == calendarName }) - else { return } - - calendars.modifyElement(atIndex: index) { element in - element.modalPresented = presented - } - - saveCalendarEntry(calendars: calendars) - } - - private func getModalPresented() -> Bool { - guard let calendars = courseCalendars(), - let calendar = calendars.first(where: { $0.title == calendarName }) - else { return false } - - return calendar.modalPresented - } - - private func removeCalendarEntry() { - guard var calendars = courseCalendars() else { return } - - if let index = calendars.firstIndex(where: { $0.title == calendarName }) { - calendars.remove(at: index) - } - - saveCalendarEntry(calendars: calendars) - } - - private func updateSyncSwitchStatus(isOn: Bool) { - guard var calendars = courseCalendars() else { return } - - if let index = calendars.firstIndex(where: { $0.title == calendarName }) { - calendars.modifyElement(atIndex: index) { element in - element.isOn = isOn - } - } - - saveCalendarEntry(calendars: calendars) - } - - private var calendarEntry: CourseCalendar? { - guard let calendars = courseCalendars() else { return nil } - return calendars.first(where: { $0.title == calendarName }) - } -} - -extension CalendarManager { - func addEventsToCalendar(for dateBlocks: [Date: [CourseDateBlock]], completion: @escaping (Bool) -> Void) { - if !generateCourseCalendar() { - completion(false) - return - } - - DispatchQueue.global().async { [weak self] in - guard let weakSelf = self else { return } - let events = weakSelf.generateEvents(for: dateBlocks, generateDeepLink: true) - - if events.isEmpty { - //Ideally this shouldn't happen, but in any case if this happen so lets remove the calendar - weakSelf.removeCalendar() - completion(false) - } else { - events.forEach { event in weakSelf.addEvent(event: event) } - do { - try weakSelf.eventStore.commit() - DispatchQueue.main.async { - completion(true) - } - } catch { - DispatchQueue.main.async { - completion(false) - } - } - } - } - } - - func checkIfEventsShouldBeShifted(for dateBlocks: [Date: [CourseDateBlock]]) -> Bool { - guard calendarEntry != nil else { return true } - - let events = generateEvents(for: dateBlocks, generateDeepLink: false) - let allEvents = events.allSatisfy { alreadyExist(event: $0) } - - return !allEvents - } - - private func generateEvents(for dateBlocks: [Date: [CourseDateBlock]], generateDeepLink: Bool) -> [EKEvent] { - var events: [EKEvent] = [] - dateBlocks.forEach { item in - let blocks = item.value - - if blocks.count > 1 { - if let generatedEvent = calendarEvent(for: blocks, generateDeepLink: generateDeepLink) { - events.append(generatedEvent) - } - } else { - if let block = blocks.first { - if let generatedEvent = calendarEvent(for: block, generateDeepLink: generateDeepLink) { - events.append(generatedEvent) - } - } - } - } - - return events - } - - private func addOrUpdateCalendarEntry(courseCalendar: CourseCalendar) { - var calenders: [CourseCalendar] = [] - - if let decodedCalendars = courseCalendars() { - calenders = decodedCalendars - } - - if let index = calenders.firstIndex(where: { $0.title == calendarName }) { - calenders.modifyElement(atIndex: index) { element in - element = courseCalendar - } - } else { - calenders.append(courseCalendar) - } - - saveCalendarEntry(calendars: calenders) - } - - private func updateCalendarState(isOn: Bool) { - guard var calendars = courseCalendars(), - let index = calendars.firstIndex(where: { $0.title == calendarName }) - else { return } - - calendars.modifyElement(atIndex: index) { element in - element.isOn = isOn - } - - saveCalendarEntry(calendars: calendars) - } - - private func courseCalendars() -> [CourseCalendar]? { - guard let data = UserDefaults.standard.data(forKey: calendarKey), - let courseCalendars = try? PropertyListDecoder().decode([CourseCalendar].self, from: data) - else { return nil } - - return courseCalendars - } - - private func saveCalendarEntry(calendars: [CourseCalendar]) { - guard let data = try? PropertyListEncoder().encode(calendars) else { return } - - UserDefaults.standard.set(data, forKey: calendarKey) - UserDefaults.standard.synchronize() - } -} - -fileprivate extension Date { - func add(_ unit: Calendar.Component, value: Int) -> Date { - return Calendar.current.date(byAdding: unit, value: value, to: self) ?? self - } -} diff --git a/Course/Course/Presentation/Container/CourseContainerView.swift b/Course/Course/Presentation/Container/CourseContainerView.swift index 18964fae5..6ffa4cf88 100644 --- a/Course/Course/Presentation/Container/CourseContainerView.swift +++ b/Course/Course/Presentation/Container/CourseContainerView.swift @@ -144,7 +144,8 @@ public struct CourseContainerView: View { private func showDatesSuccessView(title: String, message: String) -> some View { return DatesSuccessView( title: title, - message: message + message: message, + selectedTab: .dates ) { courseDatesViewModel.resetEventState() } @@ -220,7 +221,8 @@ public struct CourseContainerView: View { courseID: courseID, coordinate: $coordinate, collapsed: $collapsed, - viewModel: courseDatesViewModel + viewModel: Container.shared.resolve(CourseDatesViewModel.self, + arguments: courseID, title)! ) .tabItem { tab.image @@ -357,7 +359,8 @@ struct CourseScreensView_Previews: PreviewProvider { config: ConfigMock(), courseID: "1", courseName: "a", - analytics: CourseAnalyticsMock() + analytics: CourseAnalyticsMock(), + calendarManager: CalendarManagerMock() ), courseID: "", title: "Title of Course" diff --git a/Course/Course/Presentation/CourseRouter.swift b/Course/Course/Presentation/CourseRouter.swift index 570b5a09c..f198f2fb5 100644 --- a/Course/Course/Presentation/CourseRouter.swift +++ b/Course/Course/Presentation/CourseRouter.swift @@ -59,6 +59,8 @@ public protocol CourseRouter: BaseRouter { downloads: [DownloadDataTask], manager: DownloadManagerProtocol ) + + func showDatesAndCalendar() } // Mark - For testing and SwiftUI preview @@ -116,5 +118,7 @@ public class CourseRouterMock: BaseRouterMock, CourseRouter { downloads: [Core.DownloadDataTask], manager: Core.DownloadManagerProtocol ) {} + + public func showDatesAndCalendar() {} } #endif diff --git a/Course/Course/Presentation/Dates/CourseDatesView.swift b/Course/Course/Presentation/Dates/CourseDatesView.swift index d63722f3f..fba76f5fe 100644 --- a/Course/Course/Presentation/Dates/CourseDatesView.swift +++ b/Course/Course/Presentation/Dates/CourseDatesView.swift @@ -114,7 +114,8 @@ public struct CourseDatesView: View { } else { return DatesSuccessView( title: title, - message: message + message: message, + selectedTab: .dates ) { viewModel.resetEventState() } @@ -165,10 +166,11 @@ struct CourseDateListView: View { collapsed: $collapsed ) VStack(alignment: .leading, spacing: 0) { + + CalendarSyncStatusView(status: viewModel.syncStatus(), router: viewModel.router) + .padding(.bottom, 16) + if !courseDates.hasEnded { - CalendarSyncView(courseID: courseID, viewModel: viewModel) - .padding(.bottom, 16) - DatesStatusInfoView( datesBannerInfo: courseDates.datesBannerInfo, courseID: courseID, @@ -404,42 +406,6 @@ struct StyleBlock: View { } } -struct CalendarSyncView: View { - let courseID: String - @ObservedObject var viewModel: CourseDatesViewModel - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - Spacer() - HStack { - CoreAssets.syncToCalendar.swiftUIImage - Text(CourseLocalization.CourseDates.syncToCalendar) - .font(Theme.Fonts.titleMedium) - .foregroundColor(Theme.Colors.textPrimary) - Toggle("", isOn: .constant(viewModel.isOn)) - .toggleStyle(SwitchToggleStyle(tint: Theme.Colors.accentButtonColor)) - .padding(.trailing, 0) - .onTapGesture { - viewModel.calendarState = !viewModel.isOn - } - } - .padding(.horizontal, 16) - - Text(CourseLocalization.CourseDates.syncToCalendarMessage) - .frame(maxWidth: .infinity, alignment: .leading) - .font(Theme.Fonts.labelLarge) - .foregroundColor(Theme.Colors.textPrimary) - .padding(.horizontal, 16) - Spacer() - } - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(Theme.Colors.datesSectionStroke, lineWidth: 2) - ) - .background(Theme.Colors.datesSectionBackground) - } -} - fileprivate extension BlockStatus { var title: String { switch self { @@ -503,13 +469,14 @@ struct CourseDatesView_Previews: PreviewProvider { config: ConfigMock(), courseID: "", courseName: "", - analytics: CourseAnalyticsMock() + analytics: CourseAnalyticsMock(), + calendarManager: CalendarManagerMock() ) CourseDatesView( courseID: "", coordinate: .constant(0), - collapsed: .constant(false), + collapsed: .constant(false), viewModel: viewModel) } } diff --git a/Course/Course/Presentation/Dates/CourseDatesViewModel.swift b/Course/Course/Presentation/Dates/CourseDatesViewModel.swift index 8ee011025..c4bae6933 100644 --- a/Course/Course/Presentation/Dates/CourseDatesViewModel.swift +++ b/Course/Course/Presentation/Dates/CourseDatesViewModel.swift @@ -24,15 +24,6 @@ public class CourseDatesViewModel: ObservableObject { @Published var courseDates: CourseDates? @Published var isOn: Bool = false @Published var eventState: EventState? - - lazy var calendar: CalendarManager = { - return CalendarManager( - courseID: courseID, - courseName: courseStructure?.displayName ?? config.platformName, - courseStructure: courseStructure, - config: config - ) - }() var errorMessage: String? { didSet { @@ -42,21 +33,6 @@ public class CourseDatesViewModel: ObservableObject { } } - var calendarState: Bool { - get { - return calendar.syncOn - } - set { - if newValue { - trackCalendarSyncToggle(action: .on) - handleCalendar() - } else { - trackCalendarSyncToggle(action: .off) - showRemoveCalendarAlert() - } - } - } - private let interactor: CourseInteractorProtocol let cssInjector: CSSInjector let router: CourseRouter @@ -66,6 +42,7 @@ public class CourseDatesViewModel: ObservableObject { let courseName: String var courseStructure: CourseStructure? let analytics: CourseAnalytics + let calendarManager: CalendarManagerProtocol public init( interactor: CourseInteractorProtocol, @@ -75,7 +52,8 @@ public class CourseDatesViewModel: ObservableObject { config: ConfigProtocol, courseID: String, courseName: String, - analytics: CourseAnalytics + analytics: CourseAnalytics, + calendarManager: CalendarManagerProtocol ) { self.interactor = interactor self.router = router @@ -85,6 +63,7 @@ public class CourseDatesViewModel: ObservableObject { self.courseID = courseID self.courseName = courseName self.analytics = analytics + self.calendarManager = calendarManager addObservers() } @@ -116,7 +95,6 @@ public class CourseDatesViewModel: ObservableObject { return } isShowProgress = false - addCourseEventsIfNecessary() } catch let error { isShowProgress = false if error.isInternetError || error is NoCachedDataError { @@ -144,18 +122,21 @@ public class CourseDatesViewModel: ObservableObject { func getCourseStructure(courseID: String) async { do { courseStructure = try await interactor.getLoadedCourseBlocks(courseID: courseID) - isOn = calendarState } catch _ { errorMessage = CourseLocalization.Error.componentNotFount } } + func syncStatus() -> SyncStatus { + return calendarManager.courseStatus(courseID: courseID) + } + @MainActor func shiftDueDates(courseID: String, withProgress: Bool = true, screen: DatesStatusInfoScreen, type: String) async { isShowProgress = withProgress do { try await interactor.shiftDueDates(courseID: courseID) - NotificationCenter.default.post(name: .shiftCourseDates, object: courseID) + NotificationCenter.default.post(name: .shiftCourseDates, object: (courseID, courseName)) isShowProgress = false trackPLSuccessEvent( .plsShiftDatesSuccess, @@ -226,212 +207,6 @@ extension CourseDatesViewModel { } extension CourseDatesViewModel { - private func handleCalendar() { - calendar.requestAccess { [weak self] _, previousStatus, status in - guard let self else { return } - switch status { - case .authorized: - if previousStatus == .notDetermined { - trackCalendarSyncDialogAction(dialog: .devicePermission, action: .allow) - } - showAddCalendarAlert() - default: - if previousStatus == .notDetermined { - trackCalendarSyncDialogAction(dialog: .devicePermission, action: .doNotAllow) - } - isOn = false - if previousStatus == status { - self.showCalendarSettingsAlert() - } - } - } - } - - @MainActor - func addCourseEvents(trackAnalytics: Bool = true, completion: ((Bool) -> Void)? = nil) { - guard let dateBlocks = courseDates?.dateBlocks else { return } - showCalendarSyncProgressView { [weak self] in - self?.calendar.addEventsToCalendar(for: dateBlocks) { [weak self] calendarEventsAdded in - self?.isOn = calendarEventsAdded - if calendarEventsAdded { - self?.calendar.syncOn = calendarEventsAdded - self?.router.dismiss(animated: false) - self?.showEventsAddedSuccessAlert() - } - completion?(calendarEventsAdded) - } - } - } - - func removeCourseCalendar(trackAnalytics: Bool = true, completion: ((Bool) -> Void)? = nil) { - calendar.removeCalendar { [weak self] success in - guard let self else { return } - self.isOn = !success - completion?(success) - } - } - - private func showAddCalendarAlert() { - router.presentAlert( - alertTitle: CourseLocalization.CourseDates.addCalendarTitle, - alertMessage: CourseLocalization.CourseDates.addCalendarPrompt( - config.platformName, - courseName - ), - positiveAction: CoreLocalization.Alert.accept, - onCloseTapped: { [weak self] in - self?.trackCalendarSyncDialogAction(dialog: .addCalendar, action: .cancel) - self?.router.dismiss(animated: true) - self?.isOn = false - self?.calendar.syncOn = false - }, - okTapped: { [weak self] in - self?.trackCalendarSyncDialogAction(dialog: .addCalendar, action: .add) - self?.router.dismiss(animated: true) - Task { [weak self] in - await self?.addCourseEvents() - } - }, - type: .addCalendar - ) - } - - private func showRemoveCalendarAlert() { - router.presentAlert( - alertTitle: CourseLocalization.CourseDates.removeCalendarTitle, - alertMessage: CourseLocalization.CourseDates.removeCalendarPrompt( - config.platformName, - courseName - ), - positiveAction: CoreLocalization.Alert.accept, - onCloseTapped: { [weak self] in - self?.trackCalendarSyncDialogAction(dialog: .removeCalendar, action: .cancel) - self?.router.dismiss(animated: true) - }, - okTapped: { [weak self] in - self?.trackCalendarSyncDialogAction(dialog: .removeCalendar, action: .remove) - self?.router.dismiss(animated: true) - self?.removeCourseCalendar { [weak self] _ in - self?.trackCalendarSyncSnackbar(snackbar: .removed) - self?.eventState = .removedCalendar - } - - }, - type: .removeCalendar - ) - } - - private func showEventsAddedSuccessAlert() { - if calendar.isModalPresented { - trackCalendarSyncSnackbar(snackbar: .added) - eventState = .addedCalendar - return - } - calendar.isModalPresented = true - router.presentAlert( - alertTitle: "", - alertMessage: CourseLocalization.CourseDates.datesAddedAlertMessage( - calendar.calendarName - ), - positiveAction: CourseLocalization.CourseDates.calendarViewEvents, - onCloseTapped: { [weak self] in - self?.trackCalendarSyncDialogAction(dialog: .eventsAdded, action: .done) - self?.router.dismiss(animated: true) - self?.isOn = true - self?.calendar.syncOn = true - }, - okTapped: { [weak self] in - self?.trackCalendarSyncDialogAction(dialog: .eventsAdded, action: .viewEvent) - self?.router.dismiss(animated: true) - if let url = URL(string: "calshow://"), UIApplication.shared.canOpenURL(url) { - UIApplication.shared.open(url, options: [:], completionHandler: nil) - } - }, - type: .calendarAdded - ) - } - - func showCalendarSyncProgressView(completion: @escaping (() -> Void)) { - router.presentView( - transitionStyle: .crossDissolve, - view: CalendarSyncProgressView( - title: CourseLocalization.CourseDates.calendarSyncMessage - ), - completion: completion - ) - } - - @MainActor - private func addCourseEventsIfNecessary() { - Task { - if calendar.syncOn && calendar.checkIfEventsShouldBeShifted(for: courseDates?.dateBlocks ?? [:]) { - showCalendarEventShiftAlert() - } - } - } - - @MainActor - private func showCalendarEventShiftAlert() { - router.presentAlert( - alertTitle: CourseLocalization.CourseDates.calendarOutOfDate, - alertMessage: CourseLocalization.CourseDates.calendarShiftMessage, - positiveAction: CourseLocalization.CourseDates.calendarShiftPromptUpdateNow, - onCloseTapped: { [weak self] in - // Remove course calendar - self?.trackCalendarSyncDialogAction(dialog: .updateCalendar, action: .remove) - self?.router.dismiss(animated: true) - self?.removeCourseCalendar { [weak self] _ in - self?.trackCalendarSyncSnackbar(snackbar: .removed) - self?.eventState = .removedCalendar - } - }, - okTapped: { [weak self] in - // Update Calendar Now - self?.trackCalendarSyncDialogAction(dialog: .updateCalendar, action: .update) - self?.router.dismiss(animated: true) - self?.removeCourseCalendar(trackAnalytics: false) { success in - self?.isOn = !success - self?.calendar.syncOn = false - self?.addCourseEvents(trackAnalytics: false) { [weak self] calendarEventsAdded in - self?.isOn = calendarEventsAdded - if calendarEventsAdded { - self?.trackCalendarSyncSnackbar(snackbar: .updated) - self?.calendar.syncOn = calendarEventsAdded - self?.eventState = .updatedCalendar - } - } - } - }, - type: .updateCalendar - ) - } - - private func showCalendarSettingsAlert() { - guard let settingsURL = URL(string: UIApplication.openSettingsURLString) else { - return - } - - router.presentAlert( - alertTitle: CourseLocalization.CourseDates.settings, - alertMessage: CourseLocalization.CourseDates.calendarPermissionNotDetermined(config.platformName), - positiveAction: CourseLocalization.CourseDates.openSettings, - onCloseTapped: { [weak self] in - self?.isOn = false - self?.router.dismiss(animated: true) - }, - okTapped: { [weak self] in - self?.isOn = false - if UIApplication.shared.canOpenURL(settingsURL) { - UIApplication.shared.open(settingsURL, options: [:], completionHandler: nil) - } - self?.router.dismiss(animated: true) - }, - type: .default( - positiveAction: CourseLocalization.CourseDates.openSettings, - image: CoreAssets.syncToCalendar.swiftUIImage - ) - ) - } func logdateComponentTapped(block: CourseDateBlock, supported: Bool) { analytics.datesComponentTapped( @@ -476,33 +251,3 @@ extension CourseDatesViewModel { ) } } - -extension CourseDatesViewModel { - private func trackCalendarSyncToggle(action: CalendarDialogueAction) { - analytics.calendarSyncToggle( - enrollmentMode: .none, - pacing: courseStructure?.isSelfPaced ?? true ? .`self` : .instructor, - courseId: courseID, - action: action - ) - } - - private func trackCalendarSyncDialogAction(dialog: CalendarDialogueType, action: CalendarDialogueAction) { - analytics.calendarSyncDialogAction( - enrollmentMode: .none, - pacing: courseStructure?.isSelfPaced ?? true ? .`self` : .instructor, - courseId: courseID, - dialog: dialog, - action: action - ) - } - - private func trackCalendarSyncSnackbar(snackbar: SnackbarType) { - analytics.calendarSyncSnackbar( - enrollmentMode: .none, - pacing: courseStructure?.isSelfPaced ?? true ? .`self` : .instructor, - courseId: courseID, - snackbar: snackbar - ) - } -} diff --git a/Course/Course/Presentation/Outline/CourseOutlineView.swift b/Course/Course/Presentation/Outline/CourseOutlineView.swift index 381decc63..bb51fa74a 100644 --- a/Course/Course/Presentation/Outline/CourseOutlineView.swift +++ b/Course/Course/Presentation/Outline/CourseOutlineView.swift @@ -61,9 +61,6 @@ public struct CourseOutlineView: View { group.addTask { await viewModel.getCourseBlocks(courseID: courseID, withProgress: false) } - group.addTask { - await viewModel.getCourseDeadlineInfo(courseID: courseID, withProgress: false) - } } }) { DynamicOffsetView( @@ -72,18 +69,6 @@ public struct CourseOutlineView: View { ) RefreshProgressView(isShowRefresh: $viewModel.isShowRefresh) VStack(alignment: .leading) { - if let courseDeadlineInfo = viewModel.courseDeadlineInfo, - courseDeadlineInfo.datesBannerInfo.status == .resetDatesBanner, - !courseDeadlineInfo.hasEnded, - !isVideo { - DatesStatusInfoView( - datesBannerInfo: courseDeadlineInfo.datesBannerInfo, - courseID: courseID, - courseContainerViewModel: viewModel, - screen: .courseDashbaord - ) - .padding(.horizontal, 16) - } downloadQualityBars certificateView @@ -137,17 +122,6 @@ public struct CourseOutlineView: View { } .accessibilityAction {} - if viewModel.dueDatesShifted && !isVideo { - DatesSuccessView( - title: CourseLocalization.CourseDates.toastSuccessTitle, - message: CourseLocalization.CourseDates.toastSuccessMessage, - selectedTab: .course, - courseContainerViewModel: viewModel - ) { - selection = dateTabIndex - } - } - // MARK: - Offline mode SnackBar OfflineSnackBarView( connectivity: viewModel.connectivity, diff --git a/Course/Course/Presentation/Subviews/CalendarSyncStatusView.swift b/Course/Course/Presentation/Subviews/CalendarSyncStatusView.swift new file mode 100644 index 000000000..c949a9d13 --- /dev/null +++ b/Course/Course/Presentation/Subviews/CalendarSyncStatusView.swift @@ -0,0 +1,71 @@ +// +// CalendarSyncStatusView.swift +// Course +// +// Created by  Stepanok Ivan on 12.06.2024. +// + +import SwiftUI +import Core +import Theme + +struct CalendarSyncStatusView: View { + + var status: SyncStatus + let router: CourseRouter + + var body: some View { + HStack { + icon + Text(statusText) + .font(Theme.Fonts.titleSmall) + Spacer() + } + .frame(height: 40) + .padding(.horizontal, 16) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Theme.Colors.datesSectionStroke, lineWidth: 2) + ) + .background(Theme.Colors.datesSectionBackground) + .onTapGesture { + router.showDatesAndCalendar() + } + } + + private var icon: Image { + switch status { + case .synced: + return CoreAssets.synced.swiftUIImage + case .failed: + return CoreAssets.syncFailed.swiftUIImage + case .offline: + return CoreAssets.syncOffline.swiftUIImage + } + } + + private var statusText: String { + switch status { + case .synced: + return CourseLocalization.CalendarSyncStatus.synced + case .failed: + return CourseLocalization.CalendarSyncStatus.failed + case .offline: + return CourseLocalization.CalendarSyncStatus.offline + } + } +} + +#if DEBUG +struct CalendarSyncStatusView_Previews: PreviewProvider { + static var previews: some View { + VStack { + CalendarSyncStatusView(status: .synced, router: CourseRouterMock()) + CalendarSyncStatusView(status: .failed, router: CourseRouterMock()) + CalendarSyncStatusView(status: .offline, router: CourseRouterMock()) + } + .loadFonts() + .padding() + } +} +#endif diff --git a/Course/Course/Presentation/Subviews/CustomDisclosureGroup.swift b/Course/Course/Presentation/Subviews/CustomDisclosureGroup.swift index 8f152fdd9..22bee864a 100644 --- a/Course/Course/Presentation/Subviews/CustomDisclosureGroup.swift +++ b/Course/Course/Presentation/Subviews/CustomDisclosureGroup.swift @@ -379,7 +379,7 @@ struct CustomDisclosureGroup_Previews: PreviewProvider { courseStart: Date(), courseEnd: nil, enrollmentStart: Date(), - enrollmentEnd: nil, + enrollmentEnd: nil, lastVisitedBlockID: nil, coreAnalytics: CoreAnalyticsMock() ) diff --git a/Course/Course/Presentation/Subviews/DatesSuccessView/DatesSuccessView.swift b/Course/Course/Presentation/Subviews/DatesSuccessView/DatesSuccessView.swift index 2b9040683..3adfe607c 100644 --- a/Course/Course/Presentation/Subviews/DatesSuccessView/DatesSuccessView.swift +++ b/Course/Course/Presentation/Subviews/DatesSuccessView/DatesSuccessView.swift @@ -18,7 +18,7 @@ public struct DatesSuccessView: View { private var title: String private var message: String - var selectedTab: Tab? + var selectedTab: Tab var courseDatesViewModel: CourseDatesViewModel? var courseContainerViewModel: CourseContainerViewModel? var action: () -> Void = {} @@ -29,10 +29,12 @@ public struct DatesSuccessView: View { init ( title: String, message: String, + selectedTab: Tab, dismissAction: @escaping () -> Void ) { self.title = title self.message = message + self.selectedTab = selectedTab self.dismissAction = dismissAction } @@ -147,7 +149,8 @@ struct DatesSuccessView_Previews: PreviewProvider { static var previews: some View { DatesSuccessView( title: CourseLocalization.CourseDates.toastSuccessTitle, - message: CourseLocalization.CourseDates.toastSuccessMessage + message: CourseLocalization.CourseDates.toastSuccessMessage, + selectedTab: .course ) {} } } diff --git a/Course/Course/Presentation/Subviews/VideoDownloadQualityBarView/VideoDownloadQualityBarView.swift b/Course/Course/Presentation/Subviews/VideoDownloadQualityBarView/VideoDownloadQualityBarView.swift index 56d4fa158..8617dcb58 100644 --- a/Course/Course/Presentation/Subviews/VideoDownloadQualityBarView/VideoDownloadQualityBarView.swift +++ b/Course/Course/Presentation/Subviews/VideoDownloadQualityBarView/VideoDownloadQualityBarView.swift @@ -9,7 +9,6 @@ import SwiftUI import Core import Theme import Combine -import Profile struct VideoDownloadQualityBarView: View { diff --git a/Course/Course/SwiftGen/Strings.swift b/Course/Course/SwiftGen/Strings.swift index 91e479f03..8cf2f60a2 100644 --- a/Course/Course/SwiftGen/Strings.swift +++ b/Course/Course/SwiftGen/Strings.swift @@ -40,6 +40,14 @@ public enum CourseLocalization { /// Warning public static let warning = CourseLocalization.tr("Localizable", "ALERT.WARNING", fallback: "Warning") } + public enum CalendarSyncStatus { + /// Calendar Sync Failed + public static let failed = CourseLocalization.tr("Localizable", "CALENDAR_SYNC_STATUS.FAILED", fallback: "Calendar Sync Failed") + /// Offline + public static let offline = CourseLocalization.tr("Localizable", "CALENDAR_SYNC_STATUS.OFFLINE", fallback: "Offline") + /// Synced to Calendar + public static let synced = CourseLocalization.tr("Localizable", "CALENDAR_SYNC_STATUS.SYNCED", fallback: "Synced to Calendar") + } public enum Course { /// Due Today public static let dueToday = CourseLocalization.tr("Localizable", "COURSE.DUE_TODAY", fallback: "Due Today") @@ -165,36 +173,6 @@ public enum CourseLocalization { public static let successMessage = CourseLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.SUCCESS_MESSAGE", fallback: "Your dates have been successfully shifted.") /// Course Dates public static let title = CourseLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.TITLE", fallback: "Course Dates") - public enum ResetDateBanner { - /// Don't worry - shift our suggested schedule to complete past due assignments without losing any progress. - public static let body = CourseLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.RESET_DATE_BANNER.BODY", fallback: "Don't worry - shift our suggested schedule to complete past due assignments without losing any progress.") - /// Shift due dates - public static let button = CourseLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.RESET_DATE_BANNER.BUTTON", fallback: "Shift due dates") - /// Missed some deadlines? - public static let header = CourseLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.RESET_DATE_BANNER.HEADER", fallback: "Missed some deadlines?") - } - public enum TabInfoBanner { - /// We built a suggested schedule to help you stay on track. But don’t worry – it’s flexible so you can learn at your own pace. If you happen to fall behind, you’ll be able to adjust the dates to keep yourself on track. - public static let body = CourseLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.TAB_INFO_BANNER.BODY", fallback: "We built a suggested schedule to help you stay on track. But don’t worry – it’s flexible so you can learn at your own pace. If you happen to fall behind, you’ll be able to adjust the dates to keep yourself on track.") - /// - public static let header = CourseLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.TAB_INFO_BANNER.HEADER", fallback: "") - } - public enum UpgradeToCompleteGradedBanner { - /// To complete graded assignments as part of this course, you can upgrade today. - public static let body = CourseLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.UPGRADE_TO_COMPLETE_GRADED_BANNER.BODY", fallback: "To complete graded assignments as part of this course, you can upgrade today.") - /// - public static let button = CourseLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.UPGRADE_TO_COMPLETE_GRADED_BANNER.BUTTON", fallback: "") - /// - public static let header = CourseLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.UPGRADE_TO_COMPLETE_GRADED_BANNER.HEADER", fallback: "") - } - public enum UpgradeToResetBanner { - /// You are auditing this course, which means that you are unable to participate in graded assignments. It looks like you missed some important deadlines based on our suggested schedule. To complete graded assignments as part of this course and shift the past due assignments into the future, you can upgrade today. - public static let body = CourseLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.BODY", fallback: "You are auditing this course, which means that you are unable to participate in graded assignments. It looks like you missed some important deadlines based on our suggested schedule. To complete graded assignments as part of this course and shift the past due assignments into the future, you can upgrade today.") - /// - public static let button = CourseLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.BUTTON", fallback: "") - /// - public static let header = CourseLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.HEADER", fallback: "") - } } } public enum Download { diff --git a/Course/Course/en.lproj/Localizable.strings b/Course/Course/en.lproj/Localizable.strings index f4a37b8a4..424ecf737 100644 --- a/Course/Course/en.lproj/Localizable.strings +++ b/Course/Course/en.lproj/Localizable.strings @@ -103,21 +103,6 @@ "COURSE_DATES.OPEN_SETTINGS"="Open Settings"; "COURSE_DATES.SETTINGS" = "Settings"; -"COURSE_DATES.RESET_DATE.RESET_DATE_BANNER.BODY" = "Don't worry - shift our suggested schedule to complete past due assignments without losing any progress."; -"COURSE_DATES.RESET_DATE.RESET_DATE_BANNER.BUTTON" = "Shift due dates"; -"COURSE_DATES.RESET_DATE.RESET_DATE_BANNER.HEADER" = "Missed some deadlines?"; - -"COURSE_DATES.RESET_DATE.TAB_INFO_BANNER.BODY" = "We built a suggested schedule to help you stay on track. But don’t worry – it’s flexible so you can learn at your own pace. If you happen to fall behind, you’ll be able to adjust the dates to keep yourself on track."; -"COURSE_DATES.RESET_DATE.TAB_INFO_BANNER.HEADER" = ""; - -"COURSE_DATES.RESET_DATE.UPGRADE_TO_COMPLETE_GRADED_BANNER.BODY" = "To complete graded assignments as part of this course, you can upgrade today."; -"COURSE_DATES.RESET_DATE.UPGRADE_TO_COMPLETE_GRADED_BANNER.BUTTON" = ""; -"COURSE_DATES.RESET_DATE.UPGRADE_TO_COMPLETE_GRADED_BANNER.HEADER" = ""; - -"COURSE_DATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.BODY" = "You are auditing this course, which means that you are unable to participate in graded assignments. It looks like you missed some important deadlines based on our suggested schedule. To complete graded assignments as part of this course and shift the past due assignments into the future, you can upgrade today."; -"COURSE_DATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.BUTTON" = ""; -"COURSE_DATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.HEADER" = ""; - "COURSE_DATES.RESET_DATE.ERROR_MESSAGE" = "Your dates could not be shifted. Please try again."; "COURSE_DATES.RESET_DATE.SUCCESS_MESSAGE" = "Your dates have been successfully shifted."; "COURSE_DATES.RESET_DATE.TITLE" = "Course Dates"; @@ -126,3 +111,6 @@ "COURSE.DUE_TOMORROW" = "Due Tomorrow"; "COURSE.PROGRESS_COMPLETED" = "%@ of %@ assignments complete"; +"CALENDAR_SYNC_STATUS.SYNCED" = "Synced to Calendar"; +"CALENDAR_SYNC_STATUS.FAILED" = "Calendar Sync Failed"; +"CALENDAR_SYNC_STATUS.OFFLINE" = "Offline"; diff --git a/Course/Course/uk.lproj/Localizable.strings b/Course/Course/uk.lproj/Localizable.strings index b5a3746f6..76d23b095 100644 --- a/Course/Course/uk.lproj/Localizable.strings +++ b/Course/Course/uk.lproj/Localizable.strings @@ -102,21 +102,6 @@ "COURSE_DATES.OPEN_SETTINGS"="Open Settings"; "COURSE_DATES.SETTINGS" = "Settings"; -"COURSE_DATES.RESET_DATE.RESET_DATE_BANNER.BODY" = "Don't worry - shift our suggested schedule to complete past due assignments without losing any progress."; -"COURSE_DATES.RESET_DATE.RESET_DATE_BANNER.BUTTON" = "Shift due dates"; -"COURSE_DATES.RESET_DATE.RESET_DATE_BANNER.HEADER" = "Missed some deadlines?"; - -"COURSE_DATES.RESET_DATE.TAB_INFO_BANNER.BODY" = "We built a suggested schedule to help you stay on track. But don’t worry – it’s flexible so you can learn at your own pace. If you happen to fall behind, you’ll be able to adjust the dates to keep yourself on track."; -"COURSE_DATES.RESET_DATE.TAB_INFO_BANNER.HEADER" = ""; - -"COURSE_DATES.RESET_DATE.UPGRADE_TO_COMPLETE_GRADED_BANNER.BODY" = "To complete graded assignments as part of this course, you can upgrade today."; -"COURSE_DATES.RESET_DATE.UPGRADE_TO_COMPLETE_GRADED_BANNER.BUTTON" = ""; -"COURSE_DATES.RESET_DATE.UPGRADE_TO_COMPLETE_GRADED_BANNER.HEADER" = ""; - -"COURSE_DATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.BODY" = "You are auditing this course, which means that you are unable to participate in graded assignments. It looks like you missed some important deadlines based on our suggested schedule. To complete graded assignments as part of this course and shift the past due assignments into the future, you can upgrade today."; -"COURSE_DATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.BUTTON" = ""; -"COURSE_DATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.HEADER" = ""; - "COURSE_DATES.RESET_DATE.ERROR_MESSAGE" = "Your dates could not be shifted. Please try again."; "COURSE_DATES.RESET_DATE.SUCCESS_MESSAGE" = "Your dates have been successfully shifted."; "COURSE_DATES.RESET_DATE.TITLE" = "Course Dates"; @@ -125,3 +110,6 @@ "COURSE.DUE_TOMORROW" = "Закінчується завтра"; "COURSE.PROGRESS_COMPLETED" = "%@ з %@ завдань виконано"; +"CALENDAR_SYNC_STATUS.SYNCED" = "Синхронізовано з календарем"; +"CALENDAR_SYNC_STATUS.FAILED" = "Помилка синхронізації календаря"; +"CALENDAR_SYNC_STATUS.OFFLINE" = "Офлайн"; diff --git a/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift b/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift index 19ae08286..c88637989 100644 --- a/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift +++ b/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift @@ -61,7 +61,8 @@ final class CourseDateViewModelTests: XCTestCase { config: config, courseID: "1", courseName: "a", - analytics: CourseAnalyticsMock() + analytics: CourseAnalyticsMock(), + calendarManager: CalendarManagerMock() ) await viewModel.getCourseDates(courseID: "1") @@ -91,7 +92,8 @@ final class CourseDateViewModelTests: XCTestCase { config: config, courseID: "1", courseName: "a", - analytics: CourseAnalyticsMock() + analytics: CourseAnalyticsMock(), + calendarManager: CalendarManagerMock() ) await viewModel.getCourseDates(courseID: "1") @@ -121,7 +123,8 @@ final class CourseDateViewModelTests: XCTestCase { config: config, courseID: "1", courseName: "a", - analytics: CourseAnalyticsMock() + analytics: CourseAnalyticsMock(), + calendarManager: CalendarManagerMock() ) await viewModel.getCourseDates(courseID: "1") diff --git a/Dashboard/Dashboard/Data/Persistence/DashboardCoreModel.xcdatamodeld/DashboardCoreModel.xcdatamodel/contents b/Dashboard/Dashboard/Data/Persistence/DashboardCoreModel.xcdatamodeld/DashboardCoreModel.xcdatamodel/contents index d3bea41fc..525156723 100644 --- a/Dashboard/Dashboard/Data/Persistence/DashboardCoreModel.xcdatamodeld/DashboardCoreModel.xcdatamodel/contents +++ b/Dashboard/Dashboard/Data/Persistence/DashboardCoreModel.xcdatamodeld/DashboardCoreModel.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -39,8 +39,8 @@ - - + + @@ -54,8 +54,8 @@ - - + + diff --git a/OpenEdX.xcodeproj/project.pbxproj b/OpenEdX.xcodeproj/project.pbxproj index 24d602f45..02d6d8680 100644 --- a/OpenEdX.xcodeproj/project.pbxproj +++ b/OpenEdX.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 0218196528F734FA00202564 /* Discussion.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 0218196328F734FA00202564 /* Discussion.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 0219C67728F4347600D64452 /* Course.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0219C67628F4347600D64452 /* Course.framework */; }; 0219C67828F4347600D64452 /* Course.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 0219C67628F4347600D64452 /* Course.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 022213D22C0E08E500B917E6 /* ProfilePersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022213D12C0E08E500B917E6 /* ProfilePersistence.swift */; }; 024E69202AEFC3FB00FA0B59 /* MainScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 024E691F2AEFC3FB00FA0B59 /* MainScreenViewModel.swift */; }; 025AD4AC2A6FB95C00AB8FA7 /* DatabaseManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 025AD4AB2A6FB95C00AB8FA7 /* DatabaseManager.swift */; }; 025DE1A428DB4DAE0053E0F4 /* Profile.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 025DE1A328DB4DAE0053E0F4 /* Profile.framework */; }; @@ -98,6 +99,7 @@ 020CA5D82AA0A25300970AAF /* AppStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStorage.swift; sourceTree = ""; }; 0218196328F734FA00202564 /* Discussion.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Discussion.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 0219C67628F4347600D64452 /* Course.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Course.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 022213D12C0E08E500B917E6 /* ProfilePersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePersistence.swift; sourceTree = ""; }; 02450ABD29C35FF20094E2D0 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 024E691F2AEFC3FB00FA0B59 /* MainScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainScreenViewModel.swift; sourceTree = ""; }; 025AD4AB2A6FB95C00AB8FA7 /* DatabaseManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseManager.swift; sourceTree = ""; }; @@ -196,6 +198,7 @@ 0293A2042A6FCD430090A336 /* CoursePersistence.swift */, 0293A2062A6FCDA30090A336 /* DiscoveryPersistence.swift */, 0293A2082A6FCDE50090A336 /* DashboardPersistence.swift */, + 022213D12C0E08E500B917E6 /* ProfilePersistence.swift */, 020CA5D82AA0A25300970AAF /* AppStorage.swift */, ); path = Data; @@ -641,6 +644,7 @@ A50066952B614DEF0024680B /* BrazeListener.swift in Sources */, 071009C928D1DB3F00344290 /* ScreenAssembly.swift in Sources */, A59568972B61653700ED4F90 /* DeepLink.swift in Sources */, + 022213D22C0E08E500B917E6 /* ProfilePersistence.swift in Sources */, A5C10D8F2B861A70008E864D /* SegmentAnalyticsService.swift in Sources */, A59568992B616D9400ED4F90 /* PushLink.swift in Sources */, ); diff --git a/OpenEdX/DI/AppAssembly.swift b/OpenEdX/DI/AppAssembly.swift index a39285e7e..5c4639e15 100644 --- a/OpenEdX/DI/AppAssembly.swift +++ b/OpenEdX/DI/AppAssembly.swift @@ -180,6 +180,15 @@ class AppAssembly: Assembly { config: r.resolve(ConfigProtocol.self)! ) }.inObjectScope(.container) + + container.register(CalendarManagerProtocol.self) { r in + CalendarManager( + persistence: r.resolve(ProfilePersistenceProtocol.self)!, + interactor: r.resolve(ProfileInteractorProtocol.self)!, + profileStorage: r.resolve(ProfileStorage.self)! + ) + } + .inObjectScope(.container) container.register(DeepLinkManager.self) { r in DeepLinkManager( diff --git a/OpenEdX/DI/ScreenAssembly.swift b/OpenEdX/DI/ScreenAssembly.swift index 04ddc0514..f76ef7808 100644 --- a/OpenEdX/DI/ScreenAssembly.swift +++ b/OpenEdX/DI/ScreenAssembly.swift @@ -40,6 +40,8 @@ class ScreenAssembly: Assembly { analytics: r.resolve(MainScreenAnalytics.self)!, config: r.resolve(ConfigProtocol.self)!, profileInteractor: r.resolve(ProfileInteractorProtocol.self)!, + appStorage: r.resolve(AppStorage.self)!, + calendarManager: r.resolve(CalendarManagerProtocol.self)!, sourceScreen: sourceScreen ) } @@ -189,6 +191,11 @@ class ScreenAssembly: Assembly { // MARK: Profile + // MARK: Course + container.register(ProfilePersistenceProtocol.self) { r in + ProfilePersistence(context: r.resolve(DatabaseManager.self)!.context) + } + container.register(ProfileRepositoryProtocol.self) { r in ProfileRepository( api: r.resolve(API.self)!, @@ -235,10 +242,16 @@ class ScreenAssembly: Assembly { container.register(DatesAndCalendarViewModel.self) { r in DatesAndCalendarViewModel( - router: r.resolve(ProfileRouter.self)! + router: r.resolve(ProfileRouter.self)!, + interactor: r.resolve(ProfileInteractorProtocol.self)!, + profileStorage: r.resolve(ProfileStorage.self)!, + persistence: r.resolve(ProfilePersistenceProtocol.self)!, + calendarManager: r.resolve(CalendarManagerProtocol.self)!, + connectivity: r.resolve(ConnectivityProtocol.self)! ) } - + .inObjectScope(.weak) + container.register(ManageAccountViewModel.self) { r in ManageAccountViewModel( router: r.resolve(ProfileRouter.self)!, @@ -472,7 +485,8 @@ class ScreenAssembly: Assembly { config: r.resolve(ConfigProtocol.self)!, courseID: courseID, courseName: courseName, - analytics: r.resolve(CourseAnalytics.self)! + analytics: r.resolve(CourseAnalytics.self)!, + calendarManager: r.resolve(CalendarManagerProtocol.self)! ) } diff --git a/OpenEdX/Data/AppStorage.swift b/OpenEdX/Data/AppStorage.swift index 0d75f2851..5f8b8f924 100644 --- a/OpenEdX/Data/AppStorage.swift +++ b/OpenEdX/Data/AppStorage.swift @@ -11,6 +11,7 @@ import Core import Profile import WhatsNew import Course +import Theme public class AppStorage: CoreStorage, ProfileStorage, WhatsNewStorage, CourseStorage { @@ -216,7 +217,26 @@ public class AppStorage: CoreStorage, ProfileStorage, WhatsNewStorage, CourseSto } } } - + + public var calendarSettings: CalendarSettings? { + get { + guard let userJson = userDefaults.data(forKey: KEY_CALENDAR_SETTINGS) else { + return nil + } + return try? JSONDecoder().decode(CalendarSettings.self, from: userJson) + } + set(newValue) { + if let settings = newValue { + let encoder = JSONEncoder() + if let encoded = try? encoder.encode(settings) { + userDefaults.set(encoded, forKey: KEY_CALENDAR_SETTINGS) + } + } else { + userDefaults.set(nil, forKey: KEY_CALENDAR_SETTINGS) + } + } + } + public var resetAppSupportDirectoryUserData: Bool? { get { return userDefaults.bool(forKey: KEY_RESET_APP_SUPPORT_DIRECTORY_USER_DATA) @@ -230,6 +250,74 @@ public class AppStorage: CoreStorage, ProfileStorage, WhatsNewStorage, CourseSto } } + public var lastCalendarName: String? { + get { + return userDefaults.string(forKey: KEY_LAST_CALENDAR_NAME) + } + set(newValue) { + if let newValue { + userDefaults.set(newValue, forKey: KEY_LAST_CALENDAR_NAME) + } else { + userDefaults.removeObject(forKey: KEY_LAST_CALENDAR_NAME) + } + } + } + + public var lastLoginUsername: String? { + get { + return userDefaults.string(forKey: KEY_LAST_LOGIN_USERNAME) + } + set(newValue) { + if let newValue { + userDefaults.set(newValue, forKey: KEY_LAST_LOGIN_USERNAME) + } else { + userDefaults.removeObject(forKey: KEY_LAST_LOGIN_USERNAME) + } + } + } + + public var lastCalendarUpdateDate: Date? { + get { + guard let dateString = userDefaults.string(forKey: KEY_LAST_CALENDAR_UPDATE_DATE) else { + return nil + } + return Date(iso8601: dateString) + } + set(newValue) { + if let newValue { + userDefaults.set(newValue.dateToString(style: .iso8601), forKey: KEY_LAST_CALENDAR_UPDATE_DATE) + } else { + userDefaults.removeObject(forKey: KEY_LAST_CALENDAR_UPDATE_DATE) + } + } + } + + public var hideInactiveCourses: Bool? { + get { + return userDefaults.bool(forKey: KEY_HIDE_INACTIVE_COURSES) + } + set(newValue) { + if let newValue { + userDefaults.set(newValue, forKey: KEY_HIDE_INACTIVE_COURSES) + } else { + userDefaults.removeObject(forKey: KEY_HIDE_INACTIVE_COURSES) + } + } + } + + public var firstCalendarUpdate: Bool? { + get { + return userDefaults.bool(forKey: KEY_FIRST_CALENDAR_UPDATE) + } + set(newValue) { + if let newValue { + userDefaults.set(newValue, forKey: KEY_FIRST_CALENDAR_UPDATE) + } else { + userDefaults.removeObject(forKey: KEY_FIRST_CALENDAR_UPDATE) + } + } + } + public func clear() { accessToken = nil refreshToken = nil @@ -251,5 +339,11 @@ public class AppStorage: CoreStorage, ProfileStorage, WhatsNewStorage, CourseSto private let KEY_APPLE_SIGN_FULLNAME = "appleSignFullName" private let KEY_APPLE_SIGN_EMAIL = "appleSignEmail" private let KEY_ALLOWED_DOWNLOAD_LARGE_FILE = "allowedDownloadLargeFile" + private let KEY_CALENDAR_SETTINGS = "calendarSettings" + private let KEY_LAST_LOGIN_USERNAME = "lastLoginUsername" + private let KEY_LAST_CALENDAR_NAME = "lastCalendarName" + private let KEY_LAST_CALENDAR_UPDATE_DATE = "lastCalendarUpdateDate" + private let KEY_HIDE_INACTIVE_COURSES = "hideInactiveCourses" + private let KEY_FIRST_CALENDAR_UPDATE = "firstCalendarUpdate" private let KEY_RESET_APP_SUPPORT_DIRECTORY_USER_DATA = "resetAppSupportDirectoryUserData" } diff --git a/OpenEdX/Data/DashboardPersistence.swift b/OpenEdX/Data/DashboardPersistence.swift index 2ac59a583..ab0f14e52 100644 --- a/OpenEdX/Data/DashboardPersistence.swift +++ b/OpenEdX/Data/DashboardPersistence.swift @@ -234,17 +234,13 @@ public class DashboardPersistence: DashboardPersistenceProtocol { context.perform {[context] in let fetchRequest1: NSFetchRequest = CDDashboardCourse.fetchRequest() let batchDeleteRequest1 = NSBatchDeleteRequest(fetchRequest: fetchRequest1) - - let fetchRequest2: NSFetchRequest = CDPrimaryCourse.fetchRequest() + + let fetchRequest2: NSFetchRequest = CDMyEnrollments.fetchRequest() let batchDeleteRequest2 = NSBatchDeleteRequest(fetchRequest: fetchRequest2) - - let fetchRequest3: NSFetchRequest = CDMyEnrollments.fetchRequest() - let batchDeleteRequest3 = NSBatchDeleteRequest(fetchRequest: fetchRequest3) - + do { try context.execute(batchDeleteRequest1) try context.execute(batchDeleteRequest2) - try context.execute(batchDeleteRequest3) } catch { print("Error when deleting old data:", error) } diff --git a/OpenEdX/Data/DatabaseManager.swift b/OpenEdX/Data/DatabaseManager.swift index b8f71b346..57cd98d45 100644 --- a/OpenEdX/Data/DatabaseManager.swift +++ b/OpenEdX/Data/DatabaseManager.swift @@ -11,6 +11,7 @@ import Core import Discovery import Dashboard import Course +import Profile class DatabaseManager: CoreDataHandlerProtocol { @@ -20,7 +21,8 @@ class DatabaseManager: CoreDataHandlerProtocol { Bundle(for: CoreBundle.self), Bundle(for: DiscoveryBundle.self), Bundle(for: DashboardBundle.self), - Bundle(for: CourseBundle.self) + Bundle(for: CourseBundle.self), + Bundle(for: ProfileBundle.self) ] private lazy var persistentContainer: NSPersistentContainer = { diff --git a/OpenEdX/Data/ProfilePersistence.swift b/OpenEdX/Data/ProfilePersistence.swift new file mode 100644 index 000000000..9b37befc9 --- /dev/null +++ b/OpenEdX/Data/ProfilePersistence.swift @@ -0,0 +1,168 @@ +// +// ProfilePersistence.swift +// OpenEdX +// +// Created by  Stepanok Ivan on 03.06.2024. +// + +import Profile +import Core +import Foundation +import CoreData + +public class ProfilePersistence: ProfilePersistenceProtocol { + + private var context: NSManagedObjectContext + + public init(context: NSManagedObjectContext) { + self.context = context + } + + public func getCourseState(courseID: String) -> CourseCalendarState? { + context.performAndWait { + let fetchRequest: NSFetchRequest = CDCourseCalendarState.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "courseID == %@", courseID) + do { + if let result = try context.fetch(fetchRequest).first, + let courseID = result.courseID, + let checksum = result.checksum { + return CourseCalendarState(courseID: courseID, checksum: checksum) + } + } catch { + debugLog("⛔️ Error fetching CourseCalendarEvents: \(error)") + } + return nil + } + } + + public func getAllCourseStates() -> [CourseCalendarState] { + var states: [CourseCalendarState] = [] + context.performAndWait { + let fetchRequest: NSFetchRequest = CDCourseCalendarState.fetchRequest() + do { + let results = try context.fetch(fetchRequest) + states = results.compactMap { result in + if let courseID = result.courseID, let checksum = result.checksum { + return CourseCalendarState(courseID: courseID, checksum: checksum) + } + return nil + } + } catch { + debugLog("⛔️ Error fetching CourseCalendarEvents: \(error)") + } + } + return states + } + + public func saveCourseState(state: CourseCalendarState) { + context.performAndWait { + let newState = CDCourseCalendarState(context: context) + context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump + newState.courseID = state.courseID + newState.checksum = state.checksum + do { + try context.save() + } catch { + debugLog("⛔️ Error saving CourseCalendarEvent: \(error)") + } + } + } + + public func removeCourseState(courseID: String) { + context.performAndWait { + let fetchRequest: NSFetchRequest = CDCourseCalendarState.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "courseID == %@", courseID) + do { + if let result = try context.fetch(fetchRequest).first { + if let object = result as? NSManagedObject { + context.delete(object) + try context.save() + } + } + } catch { + debugLog("⛔️ Error removing CDCourseCalendarState: \(error)") + } + } + } + + public func deleteAllCourseStatesAndEvents() { + let fetchRequestCalendarStates: NSFetchRequest = CDCourseCalendarState.fetchRequest() + let deleteRequestCalendarStates = NSBatchDeleteRequest(fetchRequest: fetchRequestCalendarStates) + let fetchRequestCalendarEvents: NSFetchRequest = CDCourseCalendarEvent.fetchRequest() + let deleteRequestCalendarEvents = NSBatchDeleteRequest(fetchRequest: fetchRequestCalendarEvents) + + do { + try context.execute(deleteRequestCalendarStates) + try context.execute(deleteRequestCalendarEvents) + try context.save() + } catch { + debugLog("⛔️⛔️⛔️⛔️⛔️", error) + } + } + + public func saveCourseCalendarEvent(_ event: CourseCalendarEvent) { + context.performAndWait { + let newEvent = CDCourseCalendarEvent(context: context) + context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump + newEvent.courseID = event.courseID + newEvent.eventIdentifier = event.eventIdentifier + do { + try context.save() + } catch { + debugLog("⛔️ Error saving CourseCalendarEvent: \(error)") + } + } + } + + public func removeCourseCalendarEvents(for courseId: String) { + context.performAndWait { + let fetchRequest: NSFetchRequest = CDCourseCalendarEvent.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "courseID == %@", courseId) + do { + let results = try context.fetch(fetchRequest) + results.forEach { result in + if let object = result as? NSManagedObject { + context.delete(object) + } + } + try context.save() + } catch { + debugLog("⛔️ Error removing CourseCalendarEvents: \(error)") + } + } + } + + public func removeAllCourseCalendarEvents() { + context.performAndWait { + let fetchRequest: NSFetchRequest = CDCourseCalendarEvent.fetchRequest() + let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) + do { + try context.execute(deleteRequest) + try context.save() + } catch { + debugLog("⛔️ Error removing CourseCalendarEvents: \(error)") + } + } + } + + public func getCourseCalendarEvents(for courseId: String) -> [CourseCalendarEvent] { + var events: [CourseCalendarEvent] = [] + context.performAndWait { + let fetchRequest: NSFetchRequest = CDCourseCalendarEvent.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "courseID == %@", courseId) + do { + let results = try context.fetch(fetchRequest) + events = results.compactMap { result in + if let courseID = result.courseID, let eventIdentifier = result.eventIdentifier { + return CourseCalendarEvent(courseID: courseID, eventIdentifier: eventIdentifier) + } + return nil + } + } catch { + debugLog("⛔️ Error fetching CourseCalendarEvents: \(error)") + } + } + return events + } + +} diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index fce1cca5d..186ff6329 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -734,7 +734,15 @@ public class Router: AuthorizationRouter, public func showDatesAndCalendar() { let viewModel = Container.shared.resolve(DatesAndCalendarViewModel.self)! - let view = DatesAndCalendarView(viewModel: viewModel) + let storage = Container.shared.resolve(ProfileStorage.self) + + let view: AnyView + if storage?.calendarSettings == nil { + view = AnyView(DatesAndCalendarView(viewModel: viewModel)) + } else { + view = AnyView(SyncCalendarOptionsView(viewModel: viewModel)) + } + let controller = UIHostingController(rootView: view) navigationController.pushViewController(controller, animated: true) } diff --git a/OpenEdX/View/MainScreenView.swift b/OpenEdX/View/MainScreenView.swift index 7e3e30d60..72fa66b56 100644 --- a/OpenEdX/View/MainScreenView.swift +++ b/OpenEdX/View/MainScreenView.swift @@ -182,6 +182,7 @@ struct MainScreenView: View { .onFirstAppear { Task { await viewModel.prefetchDataForOffline() + await viewModel.loadCalendar() } } .accentColor(Theme.Colors.accentXColor) diff --git a/OpenEdX/View/MainScreenViewModel.swift b/OpenEdX/View/MainScreenViewModel.swift index d3409b191..740d0fd93 100644 --- a/OpenEdX/View/MainScreenViewModel.swift +++ b/OpenEdX/View/MainScreenViewModel.swift @@ -8,6 +8,8 @@ import Foundation import Core import Profile +import Swinject +import Combine public enum MainTab { case discovery @@ -17,29 +19,45 @@ public enum MainTab { } final class MainScreenViewModel: ObservableObject { - + private let analytics: MainScreenAnalytics let config: ConfigProtocol - let profileInteractor: ProfileInteractorProtocol + private let profileInteractor: ProfileInteractorProtocol var sourceScreen: LogistrationSourceScreen - + private var appStorage: CoreStorage & ProfileStorage + private let calendarManager: CalendarManagerProtocol + private var cancellables = Set() + @Published var selection: MainTab = .dashboard - + init(analytics: MainScreenAnalytics, config: ConfigProtocol, profileInteractor: ProfileInteractorProtocol, + appStorage: CoreStorage & ProfileStorage, + calendarManager: CalendarManagerProtocol, sourceScreen: LogistrationSourceScreen = .default ) { self.analytics = analytics self.config = config self.profileInteractor = profileInteractor + self.appStorage = appStorage + self.calendarManager = calendarManager self.sourceScreen = sourceScreen + + NotificationCenter.default.publisher(for: .shiftCourseDates, object: nil) + .sink { notification in + guard let (courseID, courseName) = notification.object as? (String, String) else { return } + Task { + await self.updateCourseDates(courseID: courseID, courseName: courseName) + } + } + .store(in: &cancellables) } - + public func select(tab: MainTab) { selection = tab } - + func trackMainDiscoveryTabClicked() { analytics.mainDiscoveryTabClicked() } @@ -52,7 +70,7 @@ final class MainScreenViewModel: ObservableObject { func trackMainProfileTabClicked() { analytics.mainProfileTabClicked() } - + @MainActor func prefetchDataForOffline() async { if profileInteractor.getMyProfileOffline() == nil { @@ -60,4 +78,69 @@ final class MainScreenViewModel: ObservableObject { } } + func loadCalendar() async { + if let username = appStorage.user?.username { + await updateCalendarIfNeeded(for: username) + } + } +} + +extension MainScreenViewModel { + + // MARK: Update calendar on startup + private func updateCalendarIfNeeded(for username: String) async { + + if username == appStorage.lastLoginUsername { + let today = Date() + let calendar = Calendar.current + + if let lastUpdate = appStorage.lastCalendarUpdateDate { + if calendar.isDateInToday(lastUpdate) { + return + } + } + appStorage.lastCalendarUpdateDate = today + + guard appStorage.calendarSettings?.calendarName != "", + appStorage.calendarSettings?.courseCalendarSync ?? true + else { + debugLog("No calendar for user: \(username)") + return + } + + do { + var coursesForSync = try await profileInteractor.enrollmentsStatus().filter { $0.active } + + let selectedCourses = await calendarManager.filterCoursesBySelected(fetchedCourses: coursesForSync) + + for course in selectedCourses { + if let courseDates = try? await profileInteractor.getCourseDates(courseID: course.courseID), + calendarManager.isDatesChanged(courseID: course.courseID, checksum: courseDates.checksum) { + debugLog("Calendar needs update for courseID: \(course.courseID)") + await calendarManager.removeOutdatedEvents(courseID: course.courseID) + await calendarManager.syncCourse( + courseID: course.courseID, + courseName: course.name, + dates: courseDates + ) + } + } + debugLog("No calendar update needed for username: \(username)") + } catch { + debugLog("Error updating calendar: \(error.localizedDescription)") + } + } else { + appStorage.lastLoginUsername = username + calendarManager.clearAllData(removeCalendar: false) + } + } + + private func updateCourseDates(courseID: String, courseName: String) async { + if let courseDates = try? await profileInteractor.getCourseDates(courseID: courseID), + calendarManager.isDatesChanged(courseID: courseID, checksum: courseDates.checksum) { + debugLog("Calendar update needed for courseID: \(courseID)") + await calendarManager.removeOutdatedEvents(courseID: courseID) + await calendarManager.syncCourse(courseID: courseID, courseName: courseName, dates: courseDates) + } + } } diff --git a/Profile/Data/ProfileStorage.swift b/Profile/Data/ProfileStorage.swift deleted file mode 100644 index 2770f6060..000000000 --- a/Profile/Data/ProfileStorage.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// ProfileStorage.swift -// Profile -// -// Created by  Stepanok Ivan on 30.08.2023. -// - -import Foundation -import Core - -public protocol ProfileStorage { - var userProfile: DataLayer.UserProfile? {get set} -} - -#if DEBUG -public class ProfileStorageMock: ProfileStorage { - - public var userProfile: DataLayer.UserProfile? - - public init() {} -} -#endif diff --git a/Profile/Profile.xcodeproj/project.pbxproj b/Profile/Profile.xcodeproj/project.pbxproj index a0f53c75d..afc5ff356 100644 --- a/Profile/Profile.xcodeproj/project.pbxproj +++ b/Profile/Profile.xcodeproj/project.pbxproj @@ -20,16 +20,21 @@ 021D925528DC92F800ACC565 /* ProfileInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 021D925428DC92F800ACC565 /* ProfileInteractor.swift */; }; 021D925C28DDADBD00ACC565 /* swiftgen.yml in Resources */ = {isa = PBXBuildFile; fileRef = 021D925B28DDADBD00ACC565 /* swiftgen.yml */; }; 021D925F28DDADE600ACC565 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 021D926128DDADE600ACC565 /* Localizable.strings */; }; + 022213CD2C0E050B00B917E6 /* CalendarSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022213CC2C0E050B00B917E6 /* CalendarSettings.swift */; }; + 022213D02C0E072400B917E6 /* ProfilePersistenceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022213CF2C0E072400B917E6 /* ProfilePersistenceProtocol.swift */; }; + 022213D72C0E18A300B917E6 /* CourseCalendarState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022213D62C0E18A200B917E6 /* CourseCalendarState.swift */; }; 022301E42BF4B7610028A287 /* SyncCalendarOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022301E32BF4B7610028A287 /* SyncCalendarOptionsView.swift */; }; 022301E62BF4B7A20028A287 /* AssignmentStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022301E52BF4B7A20028A287 /* AssignmentStatusView.swift */; }; 022301E82BF4C4E70028A287 /* ToggleWithDescriptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022301E72BF4C4E70028A287 /* ToggleWithDescriptionView.swift */; }; 0248F9B128DDB09D0041327E /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0248F9B028DDB09D0041327E /* Strings.swift */; }; + 0250C1AD2C231E2500B9E554 /* ProfileCoreModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 022213D32C0E092300B917E6 /* ProfileCoreModel.xcdatamodeld */; }; 0259104429C39C9E004B5A55 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0259104329C39C9E004B5A55 /* SettingsView.swift */; }; 0259104629C39CCF004B5A55 /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0259104529C39CCF004B5A55 /* SettingsViewModel.swift */; }; 0259104829C3A5F0004B5A55 /* VideoQualityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0259104729C3A5F0004B5A55 /* VideoQualityView.swift */; }; 025DE1A028DB4D9D0053E0F4 /* Core.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 025DE19F28DB4D9D0053E0F4 /* Core.framework */; }; 0262149229AE57A1008BD75A /* DeleteAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0262149129AE57A1008BD75A /* DeleteAccountView.swift */; }; 0262149429AE57B1008BD75A /* DeleteAccountViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0262149329AE57B1008BD75A /* DeleteAccountViewModel.swift */; }; + 027FEF372C1710040037807E /* CourseCalendarEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027FEF362C1710040037807E /* CourseCalendarEvent.swift */; }; 0281D1532BEA9A40006DAD7A /* NewCalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0281D1522BEA9A40006DAD7A /* NewCalendarView.swift */; }; 0281D1552BEBA8D9006DAD7A /* DropDownPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0281D1542BEBA8D9006DAD7A /* DropDownPicker.swift */; }; 0288C4EA2BC6AE2D009158B9 /* ManageAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0288C4E92BC6AE2D009158B9 /* ManageAccountView.swift */; }; @@ -42,6 +47,7 @@ 02B089432A9F832200754BD4 /* ProfileStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B089422A9F832200754BD4 /* ProfileStorage.swift */; }; 02D0FD092AD698380020D752 /* UserProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02D0FD082AD698380020D752 /* UserProfileView.swift */; }; 02D0FD0B2AD6984D0020D752 /* UserProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02D0FD0A2AD6984D0020D752 /* UserProfileViewModel.swift */; }; + 02EBC7532C19CD1800BE182C /* CalendarManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02EBC7522C19CD1700BE182C /* CalendarManager.swift */; }; 02F175352A4DAD030019CD70 /* ProfileAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F175342A4DAD030019CD70 /* ProfileAnalytics.swift */; }; 02F3BFE7292539850051930C /* ProfileRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F3BFE6292539850051930C /* ProfileRouter.swift */; }; 02F81DDF2BF4D83E002D3604 /* CalendarDialogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F81DDE2BF4D83E002D3604 /* CalendarDialogView.swift */; }; @@ -79,6 +85,10 @@ 021D925428DC92F800ACC565 /* ProfileInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileInteractor.swift; sourceTree = ""; }; 021D925B28DDADBD00ACC565 /* swiftgen.yml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.yaml; path = swiftgen.yml; sourceTree = ""; }; 021D926028DDADE600ACC565 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; + 022213CC2C0E050B00B917E6 /* CalendarSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarSettings.swift; sourceTree = ""; }; + 022213CF2C0E072400B917E6 /* ProfilePersistenceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePersistenceProtocol.swift; sourceTree = ""; }; + 022213D42C0E092300B917E6 /* ProfileCoreModel.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = ProfileCoreModel.xcdatamodel; sourceTree = ""; }; + 022213D62C0E18A200B917E6 /* CourseCalendarState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseCalendarState.swift; sourceTree = ""; }; 022301E32BF4B7610028A287 /* SyncCalendarOptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncCalendarOptionsView.swift; sourceTree = ""; }; 022301E52BF4B7A20028A287 /* AssignmentStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssignmentStatusView.swift; sourceTree = ""; }; 022301E72BF4C4E70028A287 /* ToggleWithDescriptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleWithDescriptionView.swift; sourceTree = ""; }; @@ -89,6 +99,7 @@ 025DE19F28DB4D9D0053E0F4 /* Core.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Core.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 0262149129AE57A1008BD75A /* DeleteAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteAccountView.swift; sourceTree = ""; }; 0262149329AE57B1008BD75A /* DeleteAccountViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteAccountViewModel.swift; sourceTree = ""; }; + 027FEF362C1710040037807E /* CourseCalendarEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseCalendarEvent.swift; sourceTree = ""; }; 0281D1522BEA9A40006DAD7A /* NewCalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewCalendarView.swift; sourceTree = ""; }; 0281D1542BEBA8D9006DAD7A /* DropDownPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DropDownPicker.swift; sourceTree = ""; }; 0288C4E92BC6AE2D009158B9 /* ManageAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManageAccountView.swift; sourceTree = ""; }; @@ -101,6 +112,7 @@ 02B089422A9F832200754BD4 /* ProfileStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileStorage.swift; sourceTree = ""; }; 02D0FD082AD698380020D752 /* UserProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileView.swift; sourceTree = ""; }; 02D0FD0A2AD6984D0020D752 /* UserProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileViewModel.swift; sourceTree = ""; }; + 02EBC7522C19CD1700BE182C /* CalendarManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarManager.swift; sourceTree = ""; }; 02ED50CE29A64BAD008341CD /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = ""; }; 02F175342A4DAD030019CD70 /* ProfileAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileAnalytics.swift; sourceTree = ""; }; 02F3BFE6292539850051930C /* ProfileRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileRouter.swift; sourceTree = ""; }; @@ -176,7 +188,6 @@ 020F834028DB4CCD0062FA70 = { isa = PBXGroup; children = ( - 02B089412A9F830D00754BD4 /* Data */, 021D925B28DDADBD00ACC565 /* swiftgen.yml */, 020F834C28DB4CCD0062FA70 /* Profile */, 02A9A91B2978194A00B55797 /* ProfileTests */, @@ -211,11 +222,13 @@ 021C90D32BC986A4004876AF /* DatesAndCalendar */ = { isa = PBXGroup; children = ( + 022213CB2C0E04FB00B917E6 /* Models */, 0281D1512BEA9A2D006DAD7A /* Elements */, 021C90D42BC986B3004876AF /* DatesAndCalendarView.swift */, 022301E32BF4B7610028A287 /* SyncCalendarOptionsView.swift */, 02F81DE02BF4F009002D3604 /* CoursesToSyncView.swift */, 021C90D62BC98734004876AF /* DatesAndCalendarViewModel.swift */, + 02EBC7522C19CD1700BE182C /* CalendarManager.swift */, ); path = DatesAndCalendar; sourceTree = ""; @@ -237,6 +250,8 @@ 021D924928DC882B00ACC565 /* Data */ = { isa = PBXGroup; children = ( + 02B089422A9F832200754BD4 /* ProfileStorage.swift */, + 022213CE2C0E070F00B917E6 /* Persistence */, 021D924A28DC883000ACC565 /* Network */, 021D924D28DC88BB00ACC565 /* ProfileRepository.swift */, ); @@ -268,6 +283,25 @@ path = SwiftGen; sourceTree = ""; }; + 022213CB2C0E04FB00B917E6 /* Models */ = { + isa = PBXGroup; + children = ( + 022213CC2C0E050B00B917E6 /* CalendarSettings.swift */, + 022213D62C0E18A200B917E6 /* CourseCalendarState.swift */, + 027FEF362C1710040037807E /* CourseCalendarEvent.swift */, + ); + path = Models; + sourceTree = ""; + }; + 022213CE2C0E070F00B917E6 /* Persistence */ = { + isa = PBXGroup; + children = ( + 022213CF2C0E072400B917E6 /* ProfilePersistenceProtocol.swift */, + 022213D32C0E092300B917E6 /* ProfileCoreModel.xcdatamodeld */, + ); + path = Persistence; + sourceTree = ""; + }; 02362C8329350C0C00134A5B /* Model */ = { isa = PBXGroup; children = ( @@ -345,14 +379,6 @@ path = ProfileTests; sourceTree = ""; }; - 02B089412A9F830D00754BD4 /* Data */ = { - isa = PBXGroup; - children = ( - 02B089422A9F832200754BD4 /* ProfileStorage.swift */, - ); - path = Data; - sourceTree = ""; - }; 02D0FD072AD695E10020D752 /* UserProfile */ = { isa = PBXGroup; children = ( @@ -635,7 +661,9 @@ 0796C8C929B7905300444B05 /* ProfileBottomSheet.swift in Sources */, 021D924C28DC884A00ACC565 /* ProfileEndpoint.swift in Sources */, 02F81DE12BF4F009002D3604 /* CoursesToSyncView.swift in Sources */, + 027FEF372C1710040037807E /* CourseCalendarEvent.swift in Sources */, 0281D1532BEA9A40006DAD7A /* NewCalendarView.swift in Sources */, + 02EBC7532C19CD1800BE182C /* CalendarManager.swift in Sources */, BAD9CA3F2B29BF5C00DE790A /* ProfileSupportInfoView.swift in Sources */, 022301E82BF4C4E70028A287 /* ToggleWithDescriptionView.swift in Sources */, 022301E42BF4B7610028A287 /* SyncCalendarOptionsView.swift in Sources */, @@ -650,7 +678,11 @@ 029301DA2938948500E99AB8 /* ProfileType.swift in Sources */, 0259104429C39C9E004B5A55 /* SettingsView.swift in Sources */, 02B089432A9F832200754BD4 /* ProfileStorage.swift in Sources */, + 022213D72C0E18A300B917E6 /* CourseCalendarState.swift in Sources */, + 0250C1AD2C231E2500B9E554 /* ProfileCoreModel.xcdatamodeld in Sources */, 021D925228DC918D00ACC565 /* ProfileViewModel.swift in Sources */, + 022213CD2C0E050B00B917E6 /* CalendarSettings.swift in Sources */, + 022213D02C0E072400B917E6 /* ProfilePersistenceProtocol.swift in Sources */, 0248F9B128DDB09D0041327E /* Strings.swift in Sources */, 020306CA2932B14D000949EA /* EditProfileViewModel.swift in Sources */, 0259104829C3A5F0004B5A55 /* VideoQualityView.swift in Sources */, @@ -1675,6 +1707,19 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCVersionGroup section */ + 022213D32C0E092300B917E6 /* ProfileCoreModel.xcdatamodeld */ = { + isa = XCVersionGroup; + children = ( + 022213D42C0E092300B917E6 /* ProfileCoreModel.xcdatamodel */, + ); + currentVersion = 022213D42C0E092300B917E6 /* ProfileCoreModel.xcdatamodel */; + path = ProfileCoreModel.xcdatamodeld; + sourceTree = ""; + versionGroupType = wrapper.xcdatamodel; + }; +/* End XCVersionGroup section */ }; rootObject = 020F834128DB4CCD0062FA70 /* Project object */; } diff --git a/Profile/Profile/Data/Network/ProfileEndpoint.swift b/Profile/Profile/Data/Network/ProfileEndpoint.swift index b17e5db6d..a72264ebd 100644 --- a/Profile/Profile/Data/Network/ProfileEndpoint.swift +++ b/Profile/Profile/Data/Network/ProfileEndpoint.swift @@ -16,10 +16,12 @@ enum ProfileEndpoint: EndPointType { case deleteProfilePicture(username: String) case logOut(refreshToken: String, clientID: String) case deleteAccount(password: String) + case enrollmentsStatus(username: String) + case getCourseDates(courseID: String) var path: String { switch self { - case .getUserProfile(let username): + case let .getUserProfile(username): return "/api/user/v1/accounts/\(username)" case .logOut: return "/oauth2/revoke_token/" @@ -27,16 +29,20 @@ enum ProfileEndpoint: EndPointType { return "/api/user/v1/accounts/\(username)" case let .uploadProfilePicture(username, _): return "/api/user/v1/accounts/\(username)/image" - case .deleteProfilePicture(username: let username): + case let .deleteProfilePicture(username): return "/api/user/v1/accounts/\(username)/image" case .deleteAccount: return "/api/user/v1/accounts/deactivate_logout/" + case let .enrollmentsStatus(username): + return "/api/mobile/v1/users/\(username)/enrollments_status/" + case let .getCourseDates(courseID): + return "/api/course_home/v1/dates/\(courseID)" } } var httpMethod: HTTPMethod { switch self { - case .getUserProfile: + case .getUserProfile, .enrollmentsStatus, .getCourseDates: return .get case .logOut: return .post @@ -82,11 +88,15 @@ enum ProfileEndpoint: EndPointType { "username": username ] return .requestParameters(parameters: params, encoding: JSONEncoding.default) - case .deleteAccount(password: let password): + case let .deleteAccount(password): let params: [String: String] = [ "password": password ] return .requestParameters(parameters: params, encoding: URLEncoding.httpBody) + case let .enrollmentsStatus(username): + return .requestParameters(parameters: nil, encoding: JSONEncoding.default) + case .getCourseDates: + return .requestParameters(encoding: JSONEncoding.default) } } } diff --git a/Profile/Profile/Data/Persistence/ProfileCoreModel.xcdatamodeld/ProfileCoreModel.xcdatamodel/contents b/Profile/Profile/Data/Persistence/ProfileCoreModel.xcdatamodeld/ProfileCoreModel.xcdatamodel/contents new file mode 100644 index 000000000..7ebe9bf79 --- /dev/null +++ b/Profile/Profile/Data/Persistence/ProfileCoreModel.xcdatamodeld/ProfileCoreModel.xcdatamodel/contents @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Profile/Profile/Data/Persistence/ProfilePersistenceProtocol.swift b/Profile/Profile/Data/Persistence/ProfilePersistenceProtocol.swift new file mode 100644 index 000000000..3e26799bd --- /dev/null +++ b/Profile/Profile/Data/Persistence/ProfilePersistenceProtocol.swift @@ -0,0 +1,39 @@ +// +// ProfilePersistenceProtocol.swift +// Profile +// +// Created by  Stepanok Ivan on 03.06.2024. +// + +import CoreData +import Core + +public protocol ProfilePersistenceProtocol { + func getCourseState(courseID: String) -> CourseCalendarState? + func getAllCourseStates() -> [CourseCalendarState] + func saveCourseState(state: CourseCalendarState) + func removeCourseState(courseID: String) + func deleteAllCourseStatesAndEvents() + func saveCourseCalendarEvent(_ event: CourseCalendarEvent) + func removeCourseCalendarEvents(for courseId: String) + func removeAllCourseCalendarEvents() + func getCourseCalendarEvents(for courseId: String) -> [CourseCalendarEvent] +} + +#if DEBUG +public struct ProfilePersistenceMock: ProfilePersistenceProtocol { + public func getCourseState(courseID: String) -> CourseCalendarState? { nil } + public func getAllCourseStates() -> [CourseCalendarState] {[]} + public func saveCourseState(state: CourseCalendarState) {} + public func removeCourseState(courseID: String) {} + public func deleteAllCourseStatesAndEvents() {} + public func saveCourseCalendarEvent(_ event: CourseCalendarEvent) {} + public func removeCourseCalendarEvents(for courseId: String) {} + public func removeAllCourseCalendarEvents() {} + public func getCourseCalendarEvents(for courseId: String) -> [CourseCalendarEvent] { [] } +} +#endif + +public final class ProfileBundle { + private init() {} +} diff --git a/Profile/Profile/Data/ProfileRepository.swift b/Profile/Profile/Data/ProfileRepository.swift index ffeca922d..2bd35cdfe 100644 --- a/Profile/Profile/Data/ProfileRepository.swift +++ b/Profile/Profile/Data/ProfileRepository.swift @@ -22,6 +22,8 @@ public protocol ProfileRepositoryProtocol { func deleteAccount(password: String) async throws -> Bool func getSettings() -> UserSettings func saveSettings(_ settings: UserSettings) + func enrollmentsStatus() async throws -> [CourseForSync] + func getCourseDates(courseID: String) async throws -> CourseDates } public class ProfileRepository: ProfileRepositoryProtocol { @@ -149,6 +151,20 @@ public class ProfileRepository: ProfileRepositoryProtocol { public func saveSettings(_ settings: UserSettings) { storage.userSettings = settings } + + public func enrollmentsStatus() async throws -> [CourseForSync] { + let username = storage.user?.username ?? "" + let result = try await api.requestData(ProfileEndpoint.enrollmentsStatus(username: username)) + .mapResponse(DataLayer.EnrollmentsStatus.self).domain + return result + } + + public func getCourseDates(courseID: String) async throws -> CourseDates { + let courseDates = try await api.requestData( + ProfileEndpoint.getCourseDates(courseID: courseID) + ).mapResponse(DataLayer.CourseDates.self).domain + return courseDates + } } // Mark - For testing and SwiftUI preview @@ -238,6 +254,38 @@ class ProfileRepositoryMock: ProfileRepositoryProtocol { return UserSettings(wifiOnly: true, streamingQuality: .auto, downloadQuality: .auto) } public func saveSettings(_ settings: UserSettings) {} + + public func enrollmentsStatus() async throws -> [CourseForSync] { + let result = [ + DataLayer.EnrollmentsStatusElement(courseID: "1", courseName: "Course 1", isActive: true), + DataLayer.EnrollmentsStatusElement(courseID: "2", courseName: "Course 2", isActive: false), + DataLayer.EnrollmentsStatusElement(courseID: "3", courseName: "Course 3", isActive: false), + DataLayer.EnrollmentsStatusElement(courseID: "4", courseName: "Course 4", isActive: true), + DataLayer.EnrollmentsStatusElement(courseID: "5", courseName: "Course 5", isActive: true), + DataLayer.EnrollmentsStatusElement(courseID: "6", courseName: "Course 6", isActive: false), + DataLayer.EnrollmentsStatusElement(courseID: "7", courseName: "Course 7", isActive: true), + DataLayer.EnrollmentsStatusElement(courseID: "8", courseName: "Course 8", isActive: true), + DataLayer.EnrollmentsStatusElement(courseID: "9", courseName: "Course 9", isActive: true), + ] + + return result.domain + } + + func getCourseDates(courseID: String) async throws -> CourseDates { + return CourseDates( + datesBannerInfo: DatesBannerInfo( + missedDeadlines: false, + contentTypeGatingEnabled: false, + missedGatedContent: false, + verifiedUpgradeLink: "", + status: .datesTabInfoBanner + ), + courseDateBlocks: [], + hasEnded: true, + learnerIsFullAccess: true, + userTimezone: nil + ) + } } // swiftlint:enable all #endif diff --git a/Profile/Profile/Data/ProfileStorage.swift b/Profile/Profile/Data/ProfileStorage.swift new file mode 100644 index 000000000..8110ada04 --- /dev/null +++ b/Profile/Profile/Data/ProfileStorage.swift @@ -0,0 +1,35 @@ +// +// ProfileStorage.swift +// Profile +// +// Created by  Stepanok Ivan on 30.08.2023. +// + +import Foundation +import Core +import UIKit + +public protocol ProfileStorage { + var userProfile: DataLayer.UserProfile? {get set} + var calendarSettings: CalendarSettings? {get set} + var hideInactiveCourses: Bool? {get set} + var lastLoginUsername: String? {get set} + var lastCalendarName: String? {get set} + var lastCalendarUpdateDate: Date? {get set} + var firstCalendarUpdate: Bool? {get set} +} + +#if DEBUG +public class ProfileStorageMock: ProfileStorage { + + public var userProfile: DataLayer.UserProfile? + public var calendarSettings: CalendarSettings? + public var hideInactiveCourses: Bool? + public var lastLoginUsername: String? + public var lastCalendarName: String? + public var lastCalendarUpdateDate: Date? + public var firstCalendarUpdate: Bool? + + public init() {} +} +#endif diff --git a/Profile/Profile/Domain/ProfileInteractor.swift b/Profile/Profile/Domain/ProfileInteractor.swift index 18e09aec2..bce7cf620 100644 --- a/Profile/Profile/Domain/ProfileInteractor.swift +++ b/Profile/Profile/Domain/ProfileInteractor.swift @@ -23,6 +23,8 @@ public protocol ProfileInteractorProtocol { func deleteAccount(password: String) async throws -> Bool func getSettings() -> UserSettings func saveSettings(_ settings: UserSettings) + func enrollmentsStatus() async throws -> [CourseForSync] + func getCourseDates(courseID: String) async throws -> CourseDates } public class ProfileInteractor: ProfileInteractorProtocol { @@ -80,6 +82,14 @@ public class ProfileInteractor: ProfileInteractorProtocol { public func saveSettings(_ settings: UserSettings) { return repository.saveSettings(settings) } + + public func enrollmentsStatus() async throws -> [CourseForSync] { + return try await repository.enrollmentsStatus() + } + + public func getCourseDates(courseID: String) async throws -> CourseDates { + return try await repository.getCourseDates(courseID: courseID) + } } // Mark - For testing and SwiftUI preview diff --git a/Profile/Profile/Presentation/DatesAndCalendar/CalendarManager.swift b/Profile/Profile/Presentation/DatesAndCalendar/CalendarManager.swift new file mode 100644 index 000000000..8e4a7ad22 --- /dev/null +++ b/Profile/Profile/Presentation/DatesAndCalendar/CalendarManager.swift @@ -0,0 +1,415 @@ +// +// CalendarManager.swift +// Profile +// +// Created by  Stepanok Ivan on 12.06.2024. +// + +import SwiftUI +import Combine +import EventKit +import Theme +import BranchSDK +import CryptoKit +import Core + +// MARK: - CalendarManager +public class CalendarManager: CalendarManagerProtocol { + let eventStore = EKEventStore() + private let alertOffset = -1 + private var persistence: ProfilePersistenceProtocol + private var interactor: ProfileInteractorProtocol + private var profileStorage: ProfileStorage + + public init( + persistence: ProfilePersistenceProtocol, + interactor: ProfileInteractorProtocol, + profileStorage: ProfileStorage + ) { + self.persistence = persistence + self.interactor = interactor + self.profileStorage = profileStorage + } + + var authorizationStatus: EKAuthorizationStatus { + return EKEventStore.authorizationStatus(for: .event) + } + + var calendarName: String { + profileStorage.calendarSettings?.calendarName + ?? ProfileLocalization.Calendar.courseDates((Bundle.main.applicationName ?? "")) + } + + var colorSelection: DropDownPicker.DownPickerOption? { + .init( + color: DropDownColor( + rawValue: profileStorage.calendarSettings?.colorSelection ?? "" + ) ?? .accent + ) + } + + var calendarSource: EKSource? { + eventStore.refreshSourcesIfNecessary() + + let iCloud = eventStore.sources.first( + where: { $0.sourceType == .calDAV && $0.title.localizedCaseInsensitiveContains("icloud") }) + let local = eventStore.sources.first(where: { $0.sourceType == .local }) + let fallback = eventStore.defaultCalendarForNewEvents?.source + guard let accountSelection = profileStorage.calendarSettings?.accountSelection else { + return iCloud ?? local ?? fallback + } + switch accountSelection { + case ProfileLocalization.Calendar.Dropdown.icloud: + return iCloud ?? local ?? fallback + case ProfileLocalization.Calendar.Dropdown.local: + return fallback ?? local + default: + return iCloud ?? local ?? fallback + } + } + + var calendar: EKCalendar? { + eventStore.calendars(for: .event).first(where: { $0.title == calendarName }) + } + + public func courseStatus(courseID: String) -> SyncStatus { + let states = persistence.getAllCourseStates() + if states.contains(where: { $0.courseID == courseID }) { + return .synced + } else { + return .offline + } + } + + public func createCalendarIfNeeded() { + if eventStore.calendars(for: .event).first(where: { $0.title == calendarName }) == nil { + let calendar = EKCalendar(for: .event, eventStore: eventStore) + calendar.title = calendarName + + if let swiftUIColor = colorSelection?.color { + let uiColor = UIColor(swiftUIColor) + calendar.cgColor = uiColor.cgColor + } else { + calendar.cgColor = Theme.Colors.accentColor.cgColor + } + + calendar.source = calendarSource + do { + try eventStore.saveCalendar(calendar, commit: true) + } catch { + print(">>>> 🥷", error) + } + } + } + + public func isDatesChanged(courseID: String, checksum: String) -> Bool { + guard let oldState = persistence.getCourseState(courseID: courseID) else { return false } + return checksum != oldState.checksum + } + + public func syncCourse(courseID: String, courseName: String, dates: CourseDates) async { + createCalendarIfNeeded() + guard let calendar else { return } + if saveEvents(for: dates.dateBlocks, courseID: courseID, courseName: courseName, calendar: calendar) { + saveCourseDatesChecksum(courseID: courseID, checksum: dates.checksum) + } else { + debugLog("Failed to sync calendar for courseID: \(courseID)") + } + } + + public func removeOutdatedEvents(courseID: String) async { + let events = persistence.getCourseCalendarEvents(for: courseID) + for event in events { + deleteEventFromCalendar(eventIdentifier: event.eventIdentifier) + } + if var state = persistence.getCourseState(courseID: courseID) { + persistence.saveCourseState(state: CourseCalendarState(courseID: state.courseID, checksum: "")) + } + persistence.removeCourseCalendarEvents(for: courseID) + } + + func deleteEventFromCalendar(eventIdentifier: String) { + if let event = self.eventStore.event(withIdentifier: eventIdentifier) { + do { + try self.eventStore.remove(event, span: .thisEvent) + } catch let error { + debugLog("Failed to remove event: \(error)") + } + } + } + + @MainActor + public func requestAccess() async -> Bool { + await withCheckedContinuation { continuation in + eventStore.requestAccess(to: .event) { granted, _ in + if granted { + continuation.resume(returning: true) + } else { + continuation.resume(returning: false) + } + } + } + } + + public func clearAllData(removeCalendar: Bool) { + persistence.deleteAllCourseStatesAndEvents() + if removeCalendar { + removeOldCalendar() + } + profileStorage.firstCalendarUpdate = false + profileStorage.hideInactiveCourses = nil + profileStorage.lastCalendarName = nil + profileStorage.calendarSettings = nil + profileStorage.lastCalendarUpdateDate = nil + } + + private func saveCourseDatesChecksum(courseID: String, checksum: String) { + var states = persistence.getAllCourseStates() + states.append(CourseCalendarState(courseID: courseID, checksum: checksum)) + for state in states { + persistence.saveCourseState(state: state) + } + } + + private func saveEvents( + for dateBlocks: [Date: [CourseDateBlock]], + courseID: String, + courseName: String, + calendar: EKCalendar + ) -> Bool { + let events = generateEvents(for: dateBlocks, courseName: courseName, calendar: calendar) + var saveSuccessful = true + events.forEach { event in + if !eventExists(event, in: calendar) { + do { + try eventStore.save(event, span: .thisEvent) + persistence.saveCourseCalendarEvent( + CourseCalendarEvent(courseID: courseID, eventIdentifier: event.eventIdentifier) + ) + } catch { + saveSuccessful = false + } + } + } + return saveSuccessful + } + + private func eventExists(_ event: EKEvent, in calendar: EKCalendar) -> Bool { + let predicate = eventStore.predicateForEvents( + withStart: event.startDate, + end: event.endDate, + calendars: [calendar] + ) + let existingEvents = eventStore.events(matching: predicate) + + return existingEvents.contains { existingEvent in + existingEvent.title == event.title && + existingEvent.startDate == event.startDate && + existingEvent.endDate == event.endDate && + existingEvent.notes == event.notes + } + } + + public func filterCoursesBySelected(fetchedCourses: [CourseForSync]) async -> [CourseForSync] { + let courseCalendarStates = persistence.getAllCourseStates() + if !courseCalendarStates.isEmpty { + let coursesToDelete = courseCalendarStates.filter { course in + !fetchedCourses.contains { $0.courseID == course.courseID } + } + let inactiveCourses = fetchedCourses.filter { course in + courseCalendarStates.contains { $0.courseID == course.courseID } && !course.active + } + + for course in coursesToDelete { + await removeOutdatedEvents(courseID: course.courseID) + } + + for course in inactiveCourses { + await removeOutdatedEvents(courseID: course.courseID) + } + + let newlyActiveCourses = fetchedCourses.filter { fetchedCourse in + courseCalendarStates.contains { $0.courseID == fetchedCourse.courseID } && fetchedCourse.active + } + + return fetchedCourses.filter { course in + courseCalendarStates.contains { $0.courseID == course.courseID } && course.active + } + } else { + return fetchedCourses + } + } + + private func generateEvents( + for dateBlocks: [Date: [CourseDateBlock]], + courseName: String, + calendar: EKCalendar + ) -> [EKEvent] { + var events: [EKEvent] = [] + dateBlocks.forEach { item in + let blocks = item.value + if blocks.count > 1 { + if let generatedEvent = calendarEvent(for: blocks, courseName: courseName, calendar: calendar) { + events.append(generatedEvent) + } + } else { + if let block = blocks.first { + if let generatedEvent = calendarEvent(for: block, courseName: courseName, calendar: calendar) { + events.append(generatedEvent) + } + } + } + } + return events + } + + private func calendarEvent(for block: CourseDateBlock, courseName: String, calendar: EKCalendar) -> EKEvent? { + guard !block.title.isEmpty else { return nil } + + let title = block.title + let startDate = block.date.addingTimeInterval(Double(alertOffset) * 3600) + let secondAlert = startDate.addingTimeInterval(Double(alertOffset) * 86400) + let endDate = block.date + var notes = "\(calendar.title)\n\n\(block.title)" + + if let link = generateDeeplink(componentBlockID: block.firstComponentBlockID) { + notes += "\n\(link)" + } + + return generateEvent( + title: title, + startDate: startDate, + endDate: endDate, + secondAlert: secondAlert, + notes: notes, + location: courseName, + calendar: calendar + ) + } + + private func calendarEvent(for blocks: [CourseDateBlock], courseName: String, calendar: EKCalendar) -> EKEvent? { + guard let block = blocks.first, !block.title.isEmpty else { return nil } + + let title = block.title + let startDate = block.date.addingTimeInterval(Double(alertOffset) * 3600) + let secondAlert = startDate.addingTimeInterval(Double(alertOffset) * 86400) + let endDate = block.date + let notes = "\(calendar.title)\n\n" + blocks.compactMap { block -> String in + if let link = generateDeeplink(componentBlockID: block.firstComponentBlockID) { + return "\(block.title)\n\(link)" + } else { + return block.title + } + }.joined(separator: "\n\n") + + return generateEvent( + title: title, + startDate: startDate, + endDate: endDate, + secondAlert: secondAlert, + notes: notes, + location: courseName, + calendar: calendar + ) + } + + private func removeCalendar(for courseID: String, calendarName: String, completion: ((Bool) -> Void)? = nil) { + guard let calendar = localCalendar(for: courseID, calendarName: calendarName) else { completion?(true); return } + do { + try eventStore.removeCalendar(calendar, commit: true) + persistence.removeCourseCalendarEvents(for: courseID) + completion?(true) + } catch { + completion?(false) + } + } + + private func localCalendar(for courseID: String, calendarName: String) -> EKCalendar? { + if authorizationStatus != .authorized { return nil } + let calendarName = "\(calendarName) - \(courseID)" + var calendars = eventStore.calendars(for: .event).filter { $0.title == calendarName } + if calendars.isEmpty { + return nil + } else { + let calendar = calendars.removeLast() + calendars.forEach { try? eventStore.removeCalendar($0, commit: true) } + return calendar + } + } + + private func generateDeeplink(componentBlockID: String) -> String? { + guard !componentBlockID.isEmpty else { + return nil + } + let branchUniversalObject = BranchUniversalObject( + canonicalIdentifier: "\(CalendarDeepLinkType.courseComponent.rawValue)/\(componentBlockID)" + ) + let dictionary: NSMutableDictionary = [ + CalendarDeepLinkKeys.screenName.rawValue: CalendarDeepLinkType.courseComponent.rawValue, + CalendarDeepLinkKeys.courseID.rawValue: profileStorage.calendarSettings?.calendarName ?? "", + CalendarDeepLinkKeys.componentID.rawValue: componentBlockID + ] + let metadata = BranchContentMetadata() + metadata.customMetadata = dictionary + branchUniversalObject.contentMetadata = metadata + let properties = BranchLinkProperties() + let shortUrl = branchUniversalObject.getShortUrl(with: properties) + return shortUrl + } + + private func generateEvent( + title: String, + startDate: Date, + endDate: Date, + secondAlert: Date, + notes: String, + location: String, + calendar: EKCalendar + ) -> EKEvent { + let event = EKEvent(eventStore: eventStore) + event.title = title + event.location = location + event.startDate = startDate + event.endDate = endDate + event.calendar = calendar + event.notes = notes + + if startDate > Date() { + let alarm = EKAlarm(absoluteDate: startDate) + event.addAlarm(alarm) + } + + if secondAlert > Date() { + let alarm = EKAlarm(absoluteDate: secondAlert) + event.addAlarm(alarm) + } + return event + } + + public func removeOldCalendar() { + guard let lastCalendarName = profileStorage.lastCalendarName else { return } + if let oldCalendar = eventStore.calendars(for: .event).first(where: { $0.title == lastCalendarName }) { + do { + try eventStore.removeCalendar(oldCalendar, commit: true) + debugLog("Old calendar '\(lastCalendarName)' removed successfully") + } catch { + debugLog("Failed to remove old calendar '\(lastCalendarName)': \(error.localizedDescription)") + } + } else { + debugLog("Old calendar '\(lastCalendarName)' not found") + } + profileStorage.lastCalendarName = nil + } +} + +// MARK: - Enums and Constants + +enum CalendarDeepLinkType: String { + case courseComponent = "course_component" +} + +private enum CalendarDeepLinkKeys: String, RawStringExtractable { + case courseID = "course_id" + case screenName = "screen_name" + case componentID = "component_id" +} diff --git a/Profile/Profile/Presentation/DatesAndCalendar/CoursesToSyncView.swift b/Profile/Profile/Presentation/DatesAndCalendar/CoursesToSyncView.swift index 5ba8b2944..54e0341fa 100644 --- a/Profile/Profile/Presentation/DatesAndCalendar/CoursesToSyncView.swift +++ b/Profile/Profile/Presentation/DatesAndCalendar/CoursesToSyncView.swift @@ -84,47 +84,77 @@ public struct CoursesToSyncView: View { private var coursesList: some View { VStack(alignment: .leading, spacing: 24) { - ForEach( - Array( - viewModel.coursesForSync.filter({ course in - course.synced == viewModel.synced && (!viewModel.hideInactiveCourses || course.active) - }).enumerated() - ), - id: \.offset - ) { _, course in - HStack { - CheckBoxView( - checked: Binding( - get: { course.synced }, - set: { _ in viewModel.toggleSync(for: course) } - ), - text: course.name, - color: Theme.Colors.textPrimary.opacity(course.active ? 1 : 0.8) - ) - - if !course.active { - Text(ProfileLocalization.CoursesToSync.inactive) - .font(Theme.Fonts.labelSmall) - .foregroundStyle(Theme.Colors.textPrimary.opacity(0.8)) + if viewModel.coursesForSync.allSatisfy({ !$0.synced }) && viewModel.synced { + noSyncedCourses + } else { + ForEach( + Array( + viewModel.coursesForSync.filter({ course in + course.synced == viewModel.synced && (!viewModel.hideInactiveCourses || course.active) + }) + .sorted { $0.active && !$1.active } + .enumerated() + ), + id: \.offset + ) { _, course in + HStack { + CheckBoxView( + checked: Binding( + get: { course.synced }, + set: { _ in viewModel.toggleSync(for: course) } + ), + text: course.name, + color: Theme.Colors.textPrimary.opacity(course.active ? 1 : 0.8) + ) + + if !course.active { + Text(ProfileLocalization.CoursesToSync.inactive) + .font(Theme.Fonts.labelSmall) + .foregroundStyle(Theme.Colors.textPrimary.opacity(0.8)) + } } + .frame( + minWidth: 0, + maxWidth: .infinity, + alignment: .leading + ) } - .frame( - minWidth: 0, - maxWidth: .infinity, - alignment: .leading - ) } + Spacer(minLength: 100) } .padding(.horizontal, 24) .padding(.vertical, 16) } + + private var noSyncedCourses: some View { + VStack(spacing: 8) { + Spacer() + CoreAssets.learnEmpty.swiftUIImage + .resizable() + .frame(width: 96, height: 96) + .foregroundStyle(Theme.Colors.textSecondaryLight) + Text(ProfileLocalization.Sync.noSynced) + .foregroundStyle(Theme.Colors.textPrimary) + .font(Theme.Fonts.titleMedium) + Text(ProfileLocalization.Sync.noSyncedDescription) + .multilineTextAlignment(.center) + .foregroundStyle(Theme.Colors.textPrimary) + .font(Theme.Fonts.labelMedium) + .frame(width: 245) + } + } } #if DEBUG struct CoursesToSyncView_Previews: PreviewProvider { static var previews: some View { let vm = DatesAndCalendarViewModel( - router: ProfileRouterMock() + router: ProfileRouterMock(), + interactor: ProfileInteractor(repository: ProfileRepositoryMock()), + profileStorage: ProfileStorageMock(), + persistence: ProfilePersistenceMock(), + calendarManager: CalendarManagerMock(), + connectivity: Connectivity() ) return CoursesToSyncView(viewModel: vm) .previewDisplayName("Courses to Sync") diff --git a/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarView.swift b/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarView.swift index a1ab29968..886909605 100644 --- a/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarView.swift +++ b/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarView.swift @@ -55,28 +55,45 @@ public struct DatesAndCalendarView: View { .navigationBarHidden(true) .navigationBarBackButtonHidden(true) - // Error Alert if needed - if viewModel.showError { - ErrorAlertView(errorMessage: $viewModel.errorMessage) - } if screenDimmed { Color.black.opacity(0.3) .ignoresSafeArea() .onTapGesture { viewModel.openNewCalendarView = false screenDimmed = false + viewModel.showCalendaAccessDenied = false + viewModel.calendarName = viewModel.oldCalendarName + viewModel.colorSelection = viewModel.oldColorSelection } } + + // Error Alert if needed + if viewModel.showError { + ErrorAlertView(errorMessage: $viewModel.errorMessage) + } + if viewModel.openNewCalendarView { NewCalendarView( title: .newCalendar, viewModel: viewModel, beginSyncingTapped: { + guard viewModel.isInternetAvaliable else { + viewModel.openNewCalendarView = false + screenDimmed = false + viewModel.calendarName = viewModel.oldCalendarName + viewModel.colorSelection = viewModel.oldColorSelection + return + } if viewModel.calendarName == "" { viewModel.calendarName = viewModel.calendarNameHint } - viewModel.router.showSyncCalendarOptions() }, + viewModel.saveCalendarOptions() + viewModel.router.back(animated: false) + viewModel.router.showSyncCalendarOptions() + }, onCloseTapped: { + viewModel.calendarName = viewModel.oldCalendarName + viewModel.colorSelection = viewModel.oldColorSelection viewModel.openNewCalendarView = false screenDimmed = false } @@ -88,16 +105,16 @@ public struct DatesAndCalendarView: View { } } - if viewModel.showCalendaAccessDenided { + if viewModel.showCalendaAccessDenied { CalendarDialogView( type: .calendarAccess, action: { - viewModel.showCalendaAccessDenided = false + viewModel.showCalendaAccessDenied = false screenDimmed = false viewModel.openAppSettings() }, onCloseTapped: { - viewModel.showCalendaAccessDenided = false + viewModel.showCalendaAccessDenied = false screenDimmed = false } ) @@ -138,9 +155,15 @@ public struct DatesAndCalendarView: View { .foregroundColor(Theme.Colors.textPrimary) .accessibilityIdentifier("calendar_sync_description") - StyledButton(ProfileLocalization.CalendarSync.button, action: { - viewModel.requestCalendarPermission() - }, horizontalPadding: true) + StyledButton( + ProfileLocalization.CalendarSync.button, + action: { + Task { + await viewModel.requestCalendarPermission() + } + }, + horizontalPadding: true + ) .fixedSize() .accessibilityIdentifier("calendar_sync_button") } @@ -185,11 +208,15 @@ public struct DatesAndCalendarView: View { struct DatesAndCalendarView_Previews: PreviewProvider { static var previews: some View { let vm = DatesAndCalendarViewModel( - router: ProfileRouterMock() + router: ProfileRouterMock(), + interactor: ProfileInteractor(repository: ProfileRepositoryMock()), + profileStorage: ProfileStorageMock(), + persistence: ProfilePersistenceMock(), + calendarManager: CalendarManagerMock(), + connectivity: Connectivity() ) DatesAndCalendarView(viewModel: vm) .loadFonts() } } #endif - diff --git a/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarViewModel.swift b/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarViewModel.swift index a87dff139..560d18978 100644 --- a/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarViewModel.swift +++ b/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarViewModel.swift @@ -7,125 +7,430 @@ import SwiftUI import Combine -import Core import EventKit import Theme +import BranchSDK +import CryptoKit +import Core -public struct CourseForSync: Identifiable { - public let id: UUID - public let name: String - public var synced: Bool - public var active: Bool - - public init(id: UUID = UUID(), name: String, synced: Bool, active: Bool) { - self.id = id - self.name = name - self.synced = synced - self.active = active - } -} +// MARK: - DatesAndCalendarViewModel public class DatesAndCalendarViewModel: ObservableObject { - // Output @Published var useRelativeDates: Bool = false - @Published var showCalendaAccessDenided: Bool = false + @Published var showCalendaAccessDenied: Bool = false + @Published var showDisableCalendarSync: Bool = false @Published var showError: Bool = false - @Published var errorMessage: String? @Published var openNewCalendarView: Bool = false - // NewCalendarView @Published var accountSelection: DropDownPicker.DownPickerOption? = .init( title: ProfileLocalization.Calendar.Dropdown.icloud ) - var calendarNameHint: String @Published var calendarName: String = "" - @Published var colorSelection: DropDownPicker.DownPickerOption? = .init( - title: ProfileLocalization.Calendar.DropdownColor.accent, - color: Theme.Colors.accentColor - ) + @Published var oldCalendarName: String = "" + @Published var colorSelection: DropDownPicker.DownPickerOption? = .init(color: .accent) + @Published var oldColorSelection: DropDownPicker.DownPickerOption? = .init(color: .accent) - // SyncCalendarOptions @Published var assignmentStatus: AssignmentStatus = .synced - @Published var courseCalendarSync: Bool = false + @Published var courseCalendarSync: Bool = true @Published var reconnectRequired: Bool = false @Published var openChangeSyncView: Bool = false - - let accounts: [DropDownPicker.DownPickerOption] = [ + @Published var syncingCoursesCount: Int = 0 + + @Published var coursesForSync = [CourseForSync]() + + private var coursesForSyncBeforeChanges = [CourseForSync]() + + private var coursesForDeleting = [CourseForSync]() + private var coursesForAdding = [CourseForSync]() + + @Published var synced: Bool = true + @Published var hideInactiveCourses: Bool = false + + var errorMessage: String? { + didSet { + DispatchQueue.main.async { + withAnimation { + self.showError = self.errorMessage != nil + } + } + } + } + + private let accounts: [DropDownPicker.DownPickerOption] = [ .init(title: ProfileLocalization.Calendar.Dropdown.icloud), .init(title: ProfileLocalization.Calendar.Dropdown.local) ] let colors: [DropDownPicker.DownPickerOption] = [ - .init(title: ProfileLocalization.Calendar.DropdownColor.accent, color: Theme.Colors.accentColor), - .init(title: ProfileLocalization.Calendar.DropdownColor.red, color: .red), - .init(title: ProfileLocalization.Calendar.DropdownColor.orange, color: .orange), - .init(title: ProfileLocalization.Calendar.DropdownColor.yellow, color: .yellow), - .init(title: ProfileLocalization.Calendar.DropdownColor.green, color: .green), - .init(title: ProfileLocalization.Calendar.DropdownColor.blue, color: .blue), - .init(title: ProfileLocalization.Calendar.DropdownColor.purple, color: .purple), - .init(title: ProfileLocalization.Calendar.DropdownColor.brown, color: .brown) + .init(color: .accent), + .init(color: .red), + .init(color: .orange), + .init(color: .yellow), + .init(color: .green), + .init(color: .blue), + .init(color: .purple), + .init(color: .brown) ] var router: ProfileRouter + private var interactor: ProfileInteractorProtocol + private var profileStorage: ProfileStorage + private var persistence: ProfilePersistenceProtocol + private var calendarManager: CalendarManagerProtocol + private var connectivity: ConnectivityProtocol - // CoursesToSyncView - - @Published var coursesForSync = [ - CourseForSync(name: "History of Example Studies", synced: true, active: true), - CourseForSync(name: "Example Language 101", synced: true, active: true), - CourseForSync(name: "Example Course", synced: true, active: true), - CourseForSync(name: "More Example Courses", synced: true, active: true), - CourseForSync(name: "Another Example Course", synced: true, active: true), - CourseForSync(name: "Example Excluded Course", synced: false, active: false), - CourseForSync(name: "Science of Examples", synced: false, active: true), - CourseForSync(name: "Example Learning", synced: false, active: false), - CourseForSync(name: "Science of Examples", synced: false, active: false) - ] - - @Published var synced: Bool = true - @Published var hideInactiveCourses: Bool = false + private var cancellables = Set() + var calendarNameHint: String - func toggleSync(for course: CourseForSync) { - if let index = coursesForSync.firstIndex(where: { $0.id == course.id }) { - if coursesForSync[index].active { - coursesForSync[index].synced.toggle() - } - } - } - - public init(router: ProfileRouter) { + public init( + router: ProfileRouter, + interactor: ProfileInteractorProtocol, + profileStorage: ProfileStorage, + persistence: ProfilePersistenceProtocol, + calendarManager: CalendarManagerProtocol, + connectivity: ConnectivityProtocol + ) { self.router = router + self.interactor = interactor + self.profileStorage = profileStorage + self.persistence = persistence + self.calendarManager = calendarManager + self.connectivity = connectivity self.calendarNameHint = ProfileLocalization.Calendar.courseDates((Bundle.main.applicationName ?? "")) } + + @MainActor + var isInternetAvaliable: Bool { + let avaliable = connectivity.isInternetAvaliable + if !avaliable { + errorMessage = CoreLocalization.Error.slowOrNoInternetConnection + } + return avaliable + } + + // MARK: - Lifecycle Functions + + func loadCalendarOptions() { + guard let calendarSettings = profileStorage.calendarSettings else { return } + self.colorSelection = colors.first(where: { $0.colorString == calendarSettings.colorSelection }) + self.accountSelection = accounts.first(where: { $0.title == calendarSettings.accountSelection }) + self.oldCalendarName = profileStorage.lastCalendarName ?? calendarName + self.oldColorSelection = colorSelection + if let calendarName = calendarSettings.calendarName { + self.calendarName = calendarName + } + self.courseCalendarSync = calendarSettings.courseCalendarSync + self.hideInactiveCourses = profileStorage.hideInactiveCourses ?? false - // MARK: - Request Calendar Permission - func requestCalendarPermission() { - let eventStore = EKEventStore() - eventStore.requestAccess(to: .event) { [weak self] granted, error in - DispatchQueue.main.async { - if granted { - self?.showNewCalendarSetup() - } else { - self?.showCalendarAccessDenided() + $hideInactiveCourses + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] hide in + guard let self = self else { return } + self.profileStorage.hideInactiveCourses = hide + }) + .store(in: &cancellables) + + $courseCalendarSync + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] sync in + guard let self = self else { return } + if !sync { + Task { + await self.showDisableCalendarSync() + } + } + }) + .store(in: &cancellables) + + updateCoursesCount() + } + + func clearAllData() { + calendarManager.clearAllData(removeCalendar: true) + router.back(animated: false) + courseCalendarSync = true + showDisableCalendarSync = false + openNewCalendarView = false + router.showDatesAndCalendar() + } + + func deleteOrAddNewDatesIfNeeded() async { + if !coursesForDeleting.isEmpty { + await removeDeselectedCoursesFromCalendar() + } + if !coursesForAdding.isEmpty { + await fetchCourses() + } + } + + func saveCalendarOptions() { + if var calendarSettings = profileStorage.calendarSettings { + oldCalendarName = calendarName + oldColorSelection = colorSelection + calendarSettings.calendarName = calendarName + profileStorage.lastCalendarName = calendarName + + if let colorSelection, let colorString = colorSelection.colorString { + calendarSettings.colorSelection = colorString + } + + if let accountSelection = accountSelection?.title { + calendarSettings.accountSelection = accountSelection + } + + calendarSettings.courseCalendarSync = self.courseCalendarSync + profileStorage.calendarSettings = calendarSettings + } else { + if let colorSelection, + let colorString = colorSelection.colorString, + let accountSelection = accountSelection?.title { + profileStorage.calendarSettings = CalendarSettings( + colorSelection: colorString, + calendarName: calendarName, + accountSelection: accountSelection, + courseCalendarSync: self.courseCalendarSync, + useRelativeDates: self.useRelativeDates + ) + profileStorage.lastCalendarName = calendarName + } + } + } + + // MARK: - Fetch Courses and Sync + @MainActor + func fetchCourses() async { + guard connectivity.isInternetAvaliable else { return } + assignmentStatus = .loading + guard await calendarManager.requestAccess() else { + await showCalendarAccessDenied() + return + } + calendarManager.createCalendarIfNeeded() + do { + let fetchedCourses = try await interactor.enrollmentsStatus() + self.coursesForSync = fetchedCourses + let courseCalendarStates = persistence.getAllCourseStates() + if profileStorage.firstCalendarUpdate == false && courseCalendarStates.isEmpty { + await syncAllActiveCourses() + } else { + coursesForSync = coursesForSync.map { course in + var updatedCourse = course + updatedCourse.synced = courseCalendarStates.contains { + $0.courseID == course.courseID + } && course.active + return updatedCourse } + + let addingIDs = Set(coursesForAdding.map { $0.courseID }) + + coursesForSync = coursesForSync.map { course in + var updatedCourse = course + if addingIDs.contains(course.courseID) { + updatedCourse.synced = true + } + return updatedCourse + } + + for course in coursesForSync.filter { $0.synced } { + do { + let courseDates = try await interactor.getCourseDates(courseID: course.courseID) + await syncSelectedCourse( + courseID: course.courseID, + courseName: course.name, + courseDates: courseDates, + active: course.active + ) + } catch { + assignmentStatus = .failed + } + } + coursesForAdding = [] + profileStorage.firstCalendarUpdate = true + updateCoursesCount() } + assignmentStatus = .synced + } catch { + self.assignmentStatus = .failed + debugLog("Error fetching courses: \(error)") } } - func openAppSettings() { - if let url = URL(string: UIApplication.openSettingsURLString), UIApplication.shared.canOpenURL(url) { - UIApplication.shared.open(url, options: [:], completionHandler: nil) + private func updateCoursesCount() { + syncingCoursesCount = coursesForSync.filter { $0.active && $0.synced }.count + } + + @MainActor + private func syncAllActiveCourses() async { + guard profileStorage.firstCalendarUpdate == false else { + coursesForAdding = [] + coursesForSyncBeforeChanges = [] + assignmentStatus = .synced + updateCoursesCount() + return } + let selectedCourses = await calendarManager.filterCoursesBySelected(fetchedCourses: coursesForSync) + let activeSelectedCourses = selectedCourses.filter { $0.active } + assignmentStatus = .loading + for course in activeSelectedCourses { + do { + let courseDates = try await interactor.getCourseDates(courseID: course.courseID) + await syncSelectedCourse( + courseID: course.courseID, + courseName: course.name, + courseDates: courseDates, + active: course.active + ) + } catch { + assignmentStatus = .failed + } + } + profileStorage.firstCalendarUpdate = true + coursesForAdding = [] + coursesForSyncBeforeChanges = [] + assignmentStatus = .synced + updateCoursesCount() } - private func showCalendarAccessDenided() { - withAnimation(.bouncy(duration: 0.3)) { - self.showCalendaAccessDenided = true + private func filterCoursesBySynced() -> [CourseForSync] { + let syncedCourses = coursesForSync.filter { $0.synced && $0.active } + return syncedCourses + } + + func deleteOldCalendarIfNeeded() async { + guard let calSettings = profileStorage.calendarSettings else { return } + let courseCalendarStates = persistence.getAllCourseStates() + let courseCountChanges = courseCalendarStates.count != coursesForSync.count + let nameChanged = oldCalendarName != calendarName + let colorChanged = colorSelection != colors.first(where: { $0.colorString == calSettings.colorSelection }) + let accountChanged = accountSelection != accounts.first(where: { $0.title == calSettings.accountSelection }) + + guard nameChanged || colorChanged || accountChanged || courseCountChanges else { return } + + calendarManager.removeOldCalendar() + saveCalendarOptions() + persistence.removeAllCourseCalendarEvents() + await fetchCourses() + } + + private func syncSelectedCourse( + courseID: String, + courseName: String, + courseDates: CourseDates, + active: Bool + ) async { + await MainActor.run { + self.assignmentStatus = .loading + } + + await calendarManager.removeOutdatedEvents(courseID: courseID) + guard active else { + await MainActor.run { + self.assignmentStatus = .synced + } + return + } + + await calendarManager.syncCourse(courseID: courseID, courseName: courseName, dates: courseDates) + if let index = self.coursesForSync.firstIndex(where: { $0.courseID == courseID && $0.active }) { + await MainActor.run { + self.coursesForSync[index].synced = true + } + } + await MainActor.run { + self.assignmentStatus = .synced + } + } + + @MainActor + func removeDeselectedCoursesFromCalendar() async { + for course in coursesForDeleting { + await calendarManager.removeOutdatedEvents(courseID: course.courseID) + persistence.removeCourseState(courseID: course.courseID) + persistence.removeCourseCalendarEvents(for: course.courseID) + if let index = self.coursesForSync.firstIndex(where: { $0.courseID == course.courseID }) { + self.coursesForSync[index].synced = false + } + } + updateCoursesCount() + coursesForDeleting = [] + coursesForSyncBeforeChanges = [] + } + + func toggleSync(for course: CourseForSync) { + guard course.active else { return } + if coursesForSyncBeforeChanges.isEmpty { + coursesForSyncBeforeChanges = coursesForSync + } + if let index = coursesForSync.firstIndex(where: { $0.courseID == course.courseID }) { + coursesForSync[index].synced.toggle() + updateCoursesForSyncAndDeletion(course: coursesForSync[index]) + } + } + + private func updateCoursesForSyncAndDeletion(course: CourseForSync) { + guard let initialCourse = coursesForSyncBeforeChanges.first(where: { + $0.courseID == course.courseID + }) else { return } + + if course.synced != initialCourse.synced { + if course.synced { + if !coursesForAdding.contains(where: { $0.courseID == course.courseID }) { + coursesForAdding.append(course) + } + if let index = coursesForDeleting.firstIndex(where: { $0.courseID == course.courseID }) { + coursesForDeleting.remove(at: index) + } + } else { + if !coursesForDeleting.contains(where: { $0.courseID == course.courseID }) { + coursesForDeleting.append(course) + } + if let index = coursesForAdding.firstIndex(where: { $0.courseID == course.courseID }) { + coursesForAdding.remove(at: index) + } + } + } else { + if let index = coursesForAdding.firstIndex(where: { $0.courseID == course.courseID }) { + coursesForAdding.remove(at: index) + } + if let index = coursesForDeleting.firstIndex(where: { $0.courseID == course.courseID }) { + coursesForDeleting.remove(at: index) + } + } + } + + // MARK: - Request Calendar Permission + @MainActor + func requestCalendarPermission() async { + if await calendarManager.requestAccess() { + await showNewCalendarSetup() + } else { + await showCalendarAccessDenied() } } - private func showNewCalendarSetup() { + @MainActor + private func showCalendarAccessDenied() async { + withAnimation(.bouncy(duration: 0.3)) { + self.showCalendaAccessDenied = true + } + } + + @MainActor + private func showDisableCalendarSync() async { withAnimation(.bouncy(duration: 0.3)) { - openNewCalendarView = true + self.showDisableCalendarSync = true + } + } + + @MainActor + private func showNewCalendarSetup() async { + withAnimation(.bouncy(duration: 0.3)) { + self.openNewCalendarView = true + } + } + + func openAppSettings() { + if let url = URL(string: UIApplication.openSettingsURLString), UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url, options: [:], completionHandler: nil) } } } diff --git a/Profile/Profile/Presentation/DatesAndCalendar/Elements/AssignmentStatusView.swift b/Profile/Profile/Presentation/DatesAndCalendar/Elements/AssignmentStatusView.swift index b1451a997..ace87a64c 100644 --- a/Profile/Profile/Presentation/DatesAndCalendar/Elements/AssignmentStatusView.swift +++ b/Profile/Profile/Presentation/DatesAndCalendar/Elements/AssignmentStatusView.swift @@ -13,6 +13,7 @@ enum AssignmentStatus { case synced case failed case offline + case loading var statusText: String { switch self { @@ -22,10 +23,12 @@ enum AssignmentStatus { ProfileLocalization.AssignmentStatus.failed case .offline: ProfileLocalization.AssignmentStatus.offline + case .loading: + ProfileLocalization.AssignmentStatus.syncing } } - var image: Image { + var image: Image? { switch self { case .synced: CoreAssets.synced.swiftUIImage @@ -33,6 +36,8 @@ enum AssignmentStatus { CoreAssets.syncFailed.swiftUIImage case .offline: CoreAssets.syncOffline.swiftUIImage + case .loading: + nil } } } @@ -67,8 +72,12 @@ struct AssignmentStatusView: View { .multilineTextAlignment(.leading) Spacer() status.image + if status == .loading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + } } - + .frame(height: 52) .padding(.horizontal, 16) } .background( @@ -82,9 +91,9 @@ struct AssignmentStatusView: View { #Preview { AssignmentStatusView( title: "My Assignments", - status: .constant(.synced), + status: .constant(.loading), calendarColor: .blue ) - .loadFonts() + .loadFonts() } #endif diff --git a/Profile/Profile/Presentation/DatesAndCalendar/Elements/CalendarDialogView.swift b/Profile/Profile/Presentation/DatesAndCalendar/Elements/CalendarDialogView.swift index c8a6b88db..cd5e89526 100644 --- a/Profile/Profile/Presentation/DatesAndCalendar/Elements/CalendarDialogView.swift +++ b/Profile/Profile/Presentation/DatesAndCalendar/Elements/CalendarDialogView.swift @@ -13,7 +13,7 @@ struct CalendarDialogView: View { enum CalendarDialogType { case calendarAccess - case disableCalendarSync + case disableCalendarSync(calendarName: String) var title: String { switch self { @@ -28,8 +28,8 @@ struct CalendarDialogView: View { switch self { case .calendarAccess: ProfileLocalization.CalendarDialog.calendarAccessDescription - case .disableCalendarSync: - ProfileLocalization.CalendarDialog.disableCalendarSyncDescription + case .disableCalendarSync(let calendarName): + ProfileLocalization.CalendarDialog.disableCalendarSyncDescription(calendarName) } } } @@ -176,9 +176,9 @@ struct CalendarDialogView: View { #if DEBUG #Preview { CalendarDialogView( - type: .calendarAccess, - calendarCircleColor: .blue, - calendarName: "My Assignments", + type: .disableCalendarSync(calendarName: "Demo Calendar"), + calendarCircleColor: .red, + calendarName: "Demo Calendar", action: {}, onCloseTapped: {} ) diff --git a/Profile/Profile/Presentation/DatesAndCalendar/Elements/DropDownPicker.swift b/Profile/Profile/Presentation/DatesAndCalendar/Elements/DropDownPicker.swift index bef40545b..73fe0a39a 100644 --- a/Profile/Profile/Presentation/DatesAndCalendar/Elements/DropDownPicker.swift +++ b/Profile/Profile/Presentation/DatesAndCalendar/Elements/DropDownPicker.swift @@ -9,20 +9,81 @@ import SwiftUI import Core import Theme -enum DropDownPickerState { +public enum DropDownPickerState { case top case bottom } -struct DropDownPicker: View { +public enum DropDownColor: String { + case accent + case red + case orange + case yellow + case green + case blue + case purple + case brown + + var title: String { + switch self { + case .accent: + ProfileLocalization.Calendar.DropdownColor.accent + case .red: + ProfileLocalization.Calendar.DropdownColor.red + case .orange: + ProfileLocalization.Calendar.DropdownColor.orange + case .yellow: + ProfileLocalization.Calendar.DropdownColor.yellow + case .green: + ProfileLocalization.Calendar.DropdownColor.green + case .blue: + ProfileLocalization.Calendar.DropdownColor.blue + case .purple: + ProfileLocalization.Calendar.DropdownColor.purple + case .brown: + ProfileLocalization.Calendar.DropdownColor.brown + } + } + var color: Color { + switch self { + case .accent: + .accentColor + case .red: + .red + case .orange: + .orange + case .yellow: + .yellow + case .green: + .green + case .blue: + .blue + case .purple: + .purple + case .brown: + .brown + } + } +} + +struct DropDownPicker: View { + struct DownPickerOption: Hashable { let title: String let color: Color? + let colorString: String? - init(title: String, color: Color? = nil) { + init(title: String) { self.title = title - self.color = color + self.color = nil + self.colorString = nil + } + + init(color: DropDownColor) { + self.title = color.title + self.color = color.color + self.colorString = color.rawValue } func hash(into hasher: inout Hasher) { @@ -136,8 +197,6 @@ struct DropDownPicker: View { .font(Theme.Fonts.bodyMedium) .foregroundStyle(Theme.Colors.textPrimary) Spacer() -// Image(systemName: "checkmark") -// .opacity(selection == option ? 1 : 0) } VStack { Spacer() diff --git a/Profile/Profile/Presentation/DatesAndCalendar/Elements/NewCalendarView.swift b/Profile/Profile/Presentation/DatesAndCalendar/Elements/NewCalendarView.swift index 264914c75..9d8b9dd70 100644 --- a/Profile/Profile/Presentation/DatesAndCalendar/Elements/NewCalendarView.swift +++ b/Profile/Profile/Presentation/DatesAndCalendar/Elements/NewCalendarView.swift @@ -8,6 +8,7 @@ import SwiftUI import Core import Theme +import Combine struct NewCalendarView: View { @@ -30,6 +31,7 @@ struct NewCalendarView: View { @Environment(\.isHorizontal) private var isHorizontal private var beginSyncingTapped: (() -> Void) = {} private var onCloseTapped: (() -> Void) = {} + @State private var calendarName: String = "" private let title: Title @@ -58,6 +60,9 @@ struct NewCalendarView: View { content } } + .onAppear { + calendarName = viewModel.calendarName + } } private var content: some View { @@ -76,14 +81,14 @@ struct NewCalendarView: View { }) } .padding(.bottom, 20) - Text(ProfileLocalization.Calendar.account) - .font(Theme.Fonts.bodySmall).bold() - DropDownPicker(selection: $viewModel.accountSelection, state: .bottom, options: viewModel.accounts) Text(ProfileLocalization.Calendar.calendarName) .font(Theme.Fonts.bodySmall).bold() .padding(.top, 16) - TextField(viewModel.calendarNameHint, text: $viewModel.calendarName) + TextField(viewModel.calendarNameHint, text: $calendarName) + .onReceive(Just(calendarName), perform: { _ in + limitText(40) + }) .font(Theme.Fonts.bodyLarge) .padding() .background(Theme.Colors.background) @@ -103,13 +108,14 @@ struct NewCalendarView: View { Text(ProfileLocalization.Calendar.upcomingAssignments) .font(Theme.Fonts.bodySmall) .foregroundColor(Theme.Colors.textPrimary) - .padding(.vertical, 16) + .padding(.vertical, 13) .multilineTextAlignment(.center) .frame( minWidth: 0, maxWidth: .infinity, alignment: .center ) + .frame(height: 65) VStack(spacing: 16) { StyledButton( @@ -123,6 +129,7 @@ struct NewCalendarView: View { ) StyledButton(ProfileLocalization.Calendar.beginSyncing) { + viewModel.calendarName = calendarName beginSyncingTapped() } } @@ -140,14 +147,30 @@ struct NewCalendarView: View { ) .padding(24) } + + func limitText(_ upper: Int) { + if calendarName.count > upper { + calendarName = String(calendarName.prefix(upper)) + } + } } #if DEBUG #Preview { NewCalendarView( - title: .newCalendar, - viewModel: DatesAndCalendarViewModel(router: ProfileRouterMock()), - beginSyncingTapped: {}, + title: .changeSyncOptions, + viewModel: DatesAndCalendarViewModel( + router: ProfileRouterMock(), + interactor: ProfileInteractor( + repository: ProfileRepositoryMock() + ), + profileStorage: ProfileStorageMock(), + persistence: ProfilePersistenceMock(), + calendarManager: CalendarManagerMock(), + connectivity: Connectivity() + ), + beginSyncingTapped: { + }, onCloseTapped: {} ) .loadFonts() diff --git a/Profile/Profile/Presentation/DatesAndCalendar/Models/CalendarSettings.swift b/Profile/Profile/Presentation/DatesAndCalendar/Models/CalendarSettings.swift new file mode 100644 index 000000000..7c1970d0c --- /dev/null +++ b/Profile/Profile/Presentation/DatesAndCalendar/Models/CalendarSettings.swift @@ -0,0 +1,56 @@ +// +// CalendarSettings.swift +// Profile +// +// Created by  Stepanok Ivan on 03.06.2024. +// + +import Foundation + +public struct CalendarSettings: Codable { + public var colorSelection: String + public var calendarName: String? + public var accountSelection: String + public var courseCalendarSync: Bool + public var useRelativeDates: Bool + + public init( + colorSelection: String, + calendarName: String?, + accountSelection: String, + courseCalendarSync: Bool, + useRelativeDates: Bool + ) { + self.colorSelection = colorSelection + self.calendarName = calendarName + self.accountSelection = accountSelection + self.courseCalendarSync = courseCalendarSync + self.useRelativeDates = useRelativeDates + } + + enum CodingKeys: String, CodingKey { + case colorSelection + case calendarName + case accountSelection + case courseCalendarSync + case useRelativeDates + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.colorSelection = try container.decode(String.self, forKey: .colorSelection) + self.calendarName = try container.decode(String.self, forKey: .calendarName) + self.accountSelection = try container.decode(String.self, forKey: .accountSelection) + self.courseCalendarSync = try container.decode(Bool.self, forKey: .courseCalendarSync) + self.useRelativeDates = try container.decode(Bool.self, forKey: .useRelativeDates) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(colorSelection, forKey: .colorSelection) + try container.encode(calendarName, forKey: .calendarName) + try container.encode(accountSelection, forKey: .accountSelection) + try container.encode(courseCalendarSync, forKey: .courseCalendarSync) + try container.encode(useRelativeDates, forKey: .useRelativeDates) + } +} diff --git a/Profile/Profile/Presentation/DatesAndCalendar/Models/CourseCalendarEvent.swift b/Profile/Profile/Presentation/DatesAndCalendar/Models/CourseCalendarEvent.swift new file mode 100644 index 000000000..3ebe6c737 --- /dev/null +++ b/Profile/Profile/Presentation/DatesAndCalendar/Models/CourseCalendarEvent.swift @@ -0,0 +1,18 @@ +// +// CourseCalendarEvent.swift +// Profile +// +// Created by  Stepanok Ivan on 10.06.2024. +// + +import Foundation + +public struct CourseCalendarEvent { + public let courseID: String + public let eventIdentifier: String + + public init(courseID: String, eventIdentifier: String) { + self.courseID = courseID + self.eventIdentifier = eventIdentifier + } +} diff --git a/Profile/Profile/Presentation/DatesAndCalendar/Models/CourseCalendarState.swift b/Profile/Profile/Presentation/DatesAndCalendar/Models/CourseCalendarState.swift new file mode 100644 index 000000000..4bdfa2310 --- /dev/null +++ b/Profile/Profile/Presentation/DatesAndCalendar/Models/CourseCalendarState.swift @@ -0,0 +1,18 @@ +// +// CourseCalendarState.swift +// Profile +// +// Created by  Stepanok Ivan on 03.06.2024. +// + +import Foundation + +public struct CourseCalendarState { + public let courseID: String + public var checksum: String + + public init(courseID: String, checksum: String) { + self.courseID = courseID + self.checksum = checksum + } +} diff --git a/Profile/Profile/Presentation/DatesAndCalendar/SyncCalendarOptionsView.swift b/Profile/Profile/Presentation/DatesAndCalendar/SyncCalendarOptionsView.swift index ecb0f213d..82414e263 100644 --- a/Profile/Profile/Presentation/DatesAndCalendar/SyncCalendarOptionsView.swift +++ b/Profile/Profile/Presentation/DatesAndCalendar/SyncCalendarOptionsView.swift @@ -72,7 +72,7 @@ public struct SyncCalendarOptionsView: View { screenDimmed = true withAnimation(.bouncy(duration: 0.3)) { if viewModel.reconnectRequired { - viewModel.showCalendaAccessDenided = true + viewModel.showCalendaAccessDenied = true } else { viewModel.openChangeSyncView = true } @@ -95,7 +95,7 @@ public struct SyncCalendarOptionsView: View { coursesToSync .padding(.bottom, 24) } - relativeDatesToggle +// relativeDatesToggle } .padding(.horizontal, isHorizontal ? 48 : 0) .frameLimit(width: proxy.size.width) @@ -106,51 +106,107 @@ public struct SyncCalendarOptionsView: View { .navigationBarHidden(true) .navigationBarBackButtonHidden(true) - // Error Alert if needed - if viewModel.showError { - ErrorAlertView(errorMessage: $viewModel.errorMessage) - } if screenDimmed { Color.black.opacity(0.3) .ignoresSafeArea() .onTapGesture { viewModel.openChangeSyncView = false - viewModel.showCalendaAccessDenided = false + viewModel.showCalendaAccessDenied = false + viewModel.showDisableCalendarSync = false + viewModel.courseCalendarSync = true screenDimmed = false + viewModel.calendarName = viewModel.oldCalendarName + viewModel.colorSelection = viewModel.oldColorSelection } } + + // Error Alert if needed + if viewModel.showError { + ErrorAlertView(errorMessage: $viewModel.errorMessage) + } + if viewModel.openChangeSyncView { NewCalendarView( title: .changeSyncOptions, viewModel: viewModel, - beginSyncingTapped: {}, + beginSyncingTapped: { + viewModel.openChangeSyncView = false + screenDimmed = false + + guard viewModel.isInternetAvaliable else { + viewModel.calendarName = viewModel.oldCalendarName + viewModel.colorSelection = viewModel.oldColorSelection + return + } + + Task { + await viewModel.deleteOldCalendarIfNeeded() + } + }, onCloseTapped: { viewModel.openChangeSyncView = false screenDimmed = false + viewModel.calendarName = viewModel.oldCalendarName + viewModel.colorSelection = viewModel.oldColorSelection } ) .transition(.move(edge: .bottom)) .frame(alignment: .center) - } else if viewModel.showCalendaAccessDenided { + } else if viewModel.showCalendaAccessDenied { CalendarDialogView( type: .calendarAccess, action: { - viewModel.showCalendaAccessDenided = false + viewModel.showCalendaAccessDenied = false screenDimmed = false viewModel.openAppSettings() }, onCloseTapped: { - viewModel.showCalendaAccessDenided = false + viewModel.showCalendaAccessDenied = false screenDimmed = false } ) .transition(.move(edge: .bottom)) .frame(alignment: .center) + .onAppear { + screenDimmed = true + } + } else if viewModel.showDisableCalendarSync { + CalendarDialogView( + type: .disableCalendarSync(calendarName: viewModel.calendarName), + calendarCircleColor: viewModel.colorSelection?.color, + calendarName: viewModel.calendarName, + action: { + viewModel.clearAllData() + }, + onCloseTapped: { + viewModel.showDisableCalendarSync = false + screenDimmed = false + viewModel.courseCalendarSync = true + } + ) + .transition(.move(edge: .bottom)) + .frame(alignment: .center) } } .ignoresSafeArea(.all, edges: .horizontal) } + .onFirstAppear { + Task { + await viewModel.fetchCourses() + } + } + .onChange(of: viewModel.courseCalendarSync) { sync in + if !sync { + screenDimmed = true + } + } + .onAppear { + viewModel.loadCalendarOptions() + Task { + await viewModel.deleteOrAddNewDatesIfNeeded() + } + } } // MARK: - Options Title @@ -175,6 +231,7 @@ public struct SyncCalendarOptionsView: View { VStack(alignment: .leading, spacing: 27) { Button(action: { // viewModel.trackProfileVideoSettingsClicked() + guard viewModel.isInternetAvaliable else { return } viewModel.router.showCoursesToSync() }, label: { @@ -182,7 +239,7 @@ public struct SyncCalendarOptionsView: View { Text( String( format: ProfileLocalization.CoursesToSync.syncingCourses( - viewModel.coursesForSync.count + viewModel.syncingCoursesCount ) ) ) @@ -222,7 +279,12 @@ public struct SyncCalendarOptionsView: View { struct SyncCalendarOptionsView_Previews: PreviewProvider { static var previews: some View { let vm = DatesAndCalendarViewModel( - router: ProfileRouterMock() + router: ProfileRouterMock(), + interactor: ProfileInteractor(repository: ProfileRepositoryMock()), + profileStorage: ProfileStorageMock(), + persistence: ProfilePersistenceMock(), + calendarManager: CalendarManagerMock(), + connectivity: Connectivity() ) SyncCalendarOptionsView(viewModel: vm) .loadFonts() diff --git a/Profile/Profile/SwiftGen/Strings.swift b/Profile/Profile/SwiftGen/Strings.swift index 0cf331373..d83527f97 100644 --- a/Profile/Profile/SwiftGen/Strings.swift +++ b/Profile/Profile/SwiftGen/Strings.swift @@ -60,6 +60,8 @@ public enum ProfileLocalization { public static let offline = ProfileLocalization.tr("Localizable", "ASSIGNMENT_STATUS.OFFLINE", fallback: "Offline") /// Synced public static let synced = ProfileLocalization.tr("Localizable", "ASSIGNMENT_STATUS.SYNCED", fallback: "Synced") + /// Syncing to calendar... + public static let syncing = ProfileLocalization.tr("Localizable", "ASSIGNMENT_STATUS.SYNCING", fallback: "Syncing to calendar...") } public enum Calendar { /// Account @@ -114,10 +116,12 @@ public enum ProfileLocalization { public static let calendarAccessDescription = ProfileLocalization.tr("Localizable", "CALENDAR_DIALOG.CALENDAR_ACCESS_DESCRIPTION", fallback: "To show upcoming assignments and course milestones on your calendar, we need permission to access your calendar.") /// Cancel public static let cancel = ProfileLocalization.tr("Localizable", "CALENDAR_DIALOG.CANCEL", fallback: "Cancel") - /// Change Sync Options - public static let disableCalendarSync = ProfileLocalization.tr("Localizable", "CALENDAR_DIALOG.DISABLE_CALENDAR_SYNC", fallback: "Change Sync Options") - /// Disabling calendar sync will delete the calendar “My Assignments.” You can turn calendar sync back on at any time. - public static let disableCalendarSyncDescription = ProfileLocalization.tr("Localizable", "CALENDAR_DIALOG.DISABLE_CALENDAR_SYNC_DESCRIPTION", fallback: "Disabling calendar sync will delete the calendar “My Assignments.” You can turn calendar sync back on at any time.") + /// Disable Calendar Sync + public static let disableCalendarSync = ProfileLocalization.tr("Localizable", "CALENDAR_DIALOG.DISABLE_CALENDAR_SYNC", fallback: "Disable Calendar Sync") + /// Disabling calendar sync will delete the calendar “%@”. You can turn calendar sync back on at any time. + public static func disableCalendarSyncDescription(_ p1: Any) -> String { + return ProfileLocalization.tr("Localizable", "CALENDAR_DIALOG.DISABLE_CALENDAR_SYNC_DESCRIPTION", String(describing: p1), fallback: "Disabling calendar sync will delete the calendar “%@”. You can turn calendar sync back on at any time.") + } /// Disable Syncing public static let disableSyncing = ProfileLocalization.tr("Localizable", "CALENDAR_DIALOG.DISABLE_SYNCING", fallback: "Disable Syncing") /// Grant Calendar Access @@ -277,11 +281,17 @@ public enum ProfileLocalization { /// Wi-fi only download public static let wifiTitle = ProfileLocalization.tr("Localizable", "SETTINGS.WIFI_TITLE", fallback: "Wi-fi only download") } + public enum Sync { + /// No Synced Courses + public static let noSynced = ProfileLocalization.tr("Localizable", "SYNC.NO_SYNCED", fallback: "No Synced Courses") + /// No courses are currently being synced to your calendar. + public static let noSyncedDescription = ProfileLocalization.tr("Localizable", "SYNC.NO_SYNCED_DESCRIPTION", fallback: "No courses are currently being synced to your calendar.") + } public enum SyncSelector { /// Not Synced public static let notSynced = ProfileLocalization.tr("Localizable", "SYNC_SELECTOR.NOT_SYNCED", fallback: "Not Synced") - /// Synced - public static let synced = ProfileLocalization.tr("Localizable", "SYNC_SELECTOR.SYNCED", fallback: "Synced") + /// To Sync + public static let synced = ProfileLocalization.tr("Localizable", "SYNC_SELECTOR.SYNCED", fallback: "To Sync") } public enum UnsavedDataAlert { /// Changes you have made will be discarded. diff --git a/Profile/Profile/en.lproj/Localizable.strings b/Profile/Profile/en.lproj/Localizable.strings index 9b6872159..d94e1152c 100644 --- a/Profile/Profile/en.lproj/Localizable.strings +++ b/Profile/Profile/en.lproj/Localizable.strings @@ -95,11 +95,12 @@ "ASSIGNMENT_STATUS.SYNCED" = "Synced"; "ASSIGNMENT_STATUS.FAILED" = "Sync Failed"; "ASSIGNMENT_STATUS.OFFLINE" = "Offline"; +"ASSIGNMENT_STATUS.SYNCING" = "Syncing to calendar..."; "CALENDAR_DIALOG.CALENDAR_ACCESS" = "Calendar Access"; -"CALENDAR_DIALOG.DISABLE_CALENDAR_SYNC" = "Change Sync Options"; +"CALENDAR_DIALOG.DISABLE_CALENDAR_SYNC" = "Disable Calendar Sync"; "CALENDAR_DIALOG.CALENDAR_ACCESS_DESCRIPTION" = "To show upcoming assignments and course milestones on your calendar, we need permission to access your calendar."; -"CALENDAR_DIALOG.DISABLE_CALENDAR_SYNC_DESCRIPTION" = "Disabling calendar sync will delete the calendar “My Assignments.” You can turn calendar sync back on at any time."; +"CALENDAR_DIALOG.DISABLE_CALENDAR_SYNC_DESCRIPTION" = "Disabling calendar sync will delete the calendar “%@”. You can turn calendar sync back on at any time."; "CALENDAR_DIALOG.GRANT_CALENDAR_ACCESS" = "Grant Calendar Access"; "CALENDAR_DIALOG.DISABLE_SYNCING" = "Disable Syncing"; "CALENDAR_DIALOG.CANCEL" = "Cancel"; @@ -145,5 +146,8 @@ "DROP_DOWN_PICKER.SELECT" = "Select"; -"SYNC_SELECTOR.SYNCED" = "Synced"; +"SYNC_SELECTOR.SYNCED" = "To Sync"; "SYNC_SELECTOR.NOT_SYNCED" = "Not Synced"; + +"SYNC.NO_SYNCED" = "No Synced Courses"; +"SYNC.NO_SYNCED_DESCRIPTION" = "No courses are currently being synced to your calendar."; diff --git a/Profile/Profile/uk.lproj/Localizable.strings b/Profile/Profile/uk.lproj/Localizable.strings index a8590f765..da932c17e 100644 --- a/Profile/Profile/uk.lproj/Localizable.strings +++ b/Profile/Profile/uk.lproj/Localizable.strings @@ -95,9 +95,9 @@ "ASSIGNMENT_STATUS.OFFLINE" = "Офлайн"; "CALENDAR_DIALOG.CALENDAR_ACCESS" = "Доступ до календаря"; -"CALENDAR_DIALOG.DISABLE_CALENDAR_SYNC" = "Змінити параметри синхронізації"; +"CALENDAR_DIALOG.DISABLE_CALENDAR_SYNC" = "Cкасувати синхронізацію календаря"; "CALENDAR_DIALOG.CALENDAR_ACCESS_DESCRIPTION" = "Щоб показати майбутні завдання та віхи курсу у вашому календарі, нам потрібен дозвіл на доступ до вашого календаря."; -"CALENDAR_DIALOG.DISABLE_CALENDAR_SYNC_DESCRIPTION" = "Вимкнення синхронізації календаря видалить календар “Мої завдання”. Ви можете знову увімкнути синхронізацію календаря в будь-який час."; +"CALENDAR_DIALOG.DISABLE_CALENDAR_SYNC_DESCRIPTION" = "Вимкнення синхронізації календаря видалить календар “%@”. Ви можете знову увімкнути синхронізацію календаря в будь-який час."; "CALENDAR_DIALOG.GRANT_CALENDAR_ACCESS" = "Надати доступ до календаря"; "CALENDAR_DIALOG.DISABLE_SYNCING" = "Вимкнути синхронізацію"; "CALENDAR_DIALOG.CANCEL" = "Скасувати"; @@ -143,3 +143,6 @@ "CALENDAR.COURSE_DATES" = "%@ Дати курсу"; "DROP_DOWN_PICKER.SELECT" = "Оберіть"; + +"SYNC.NO_SYNCED" = "Немає синхронізованих курсів"; +"SYNC.NO_SYNCED_DESCRIPTION" = "Жоден курс зараз не синхронізується з вашим календарем."; diff --git a/Profile/ProfileTests/ProfileMock.generated.swift b/Profile/ProfileTests/ProfileMock.generated.swift index 1817697a4..534a1dde5 100644 --- a/Profile/ProfileTests/ProfileMock.generated.swift +++ b/Profile/ProfileTests/ProfileMock.generated.swift @@ -2738,6 +2738,38 @@ open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { perform?(`settings`) } + open func enrollmentsStatus() throws -> [CourseForSync] { + addInvocation(.m_enrollmentsStatus) + let perform = methodPerformValue(.m_enrollmentsStatus) as? () -> Void + perform?() + var __value: [CourseForSync] + do { + __value = try methodReturnValue(.m_enrollmentsStatus).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for enrollmentsStatus(). Use given") + Failure("Stub return value not specified for enrollmentsStatus(). Use given") + } catch { + throw error + } + return __value + } + + open func getCourseDates(courseID: String) throws -> CourseDates { + addInvocation(.m_getCourseDates__courseID_courseID(Parameter.value(`courseID`))) + let perform = methodPerformValue(.m_getCourseDates__courseID_courseID(Parameter.value(`courseID`))) as? (String) -> Void + perform?(`courseID`) + var __value: CourseDates + do { + __value = try methodReturnValue(.m_getCourseDates__courseID_courseID(Parameter.value(`courseID`))).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for getCourseDates(courseID: String). Use given") + Failure("Stub return value not specified for getCourseDates(courseID: String). Use given") + } catch { + throw error + } + return __value + } + fileprivate enum MethodType { case m_getUserProfile__username_username(Parameter) @@ -2752,6 +2784,8 @@ open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { case m_deleteAccount__password_password(Parameter) case m_getSettings case m_saveSettings__settings(Parameter) + case m_enrollmentsStatus + case m_getCourseDates__courseID_courseID(Parameter) static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { switch (lhs, rhs) { @@ -2793,6 +2827,13 @@ open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSettings, rhs: rhsSettings, with: matcher), lhsSettings, rhsSettings, "_ settings")) return Matcher.ComparisonResult(results) + + case (.m_enrollmentsStatus, .m_enrollmentsStatus): return .match + + case (.m_getCourseDates__courseID_courseID(let lhsCourseid), .m_getCourseDates__courseID_courseID(let rhsCourseid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseID")) + return Matcher.ComparisonResult(results) default: return .none } } @@ -2811,6 +2852,8 @@ open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { case let .m_deleteAccount__password_password(p0): return p0.intValue case .m_getSettings: return 0 case let .m_saveSettings__settings(p0): return p0.intValue + case .m_enrollmentsStatus: return 0 + case let .m_getCourseDates__courseID_courseID(p0): return p0.intValue } } func assertionName() -> String { @@ -2827,6 +2870,8 @@ open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { case .m_deleteAccount__password_password: return ".deleteAccount(password:)" case .m_getSettings: return ".getSettings()" case .m_saveSettings__settings: return ".saveSettings(_:)" + case .m_enrollmentsStatus: return ".enrollmentsStatus()" + case .m_getCourseDates__courseID_courseID: return ".getCourseDates(courseID:)" } } } @@ -2867,6 +2912,12 @@ open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { public static func getSettings(willReturn: UserSettings...) -> MethodStub { return Given(method: .m_getSettings, products: willReturn.map({ StubProduct.return($0 as Any) })) } + public static func enrollmentsStatus(willReturn: [CourseForSync]...) -> MethodStub { + return Given(method: .m_enrollmentsStatus, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getCourseDates(courseID: Parameter, willReturn: CourseDates...) -> MethodStub { + return Given(method: .m_getCourseDates__courseID_courseID(`courseID`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } public static func getMyProfileOffline(willProduce: (Stubber) -> Void) -> MethodStub { let willReturn: [UserProfile?] = [] let given: Given = { return Given(method: .m_getMyProfileOffline, products: willReturn.map({ StubProduct.return($0 as Any) })) }() @@ -2965,6 +3016,26 @@ open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { willProduce(stubber) return given } + public static func enrollmentsStatus(willThrow: Error...) -> MethodStub { + return Given(method: .m_enrollmentsStatus, products: willThrow.map({ StubProduct.throw($0) })) + } + public static func enrollmentsStatus(willProduce: (StubberThrows<[CourseForSync]>) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_enrollmentsStatus, products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: ([CourseForSync]).self) + willProduce(stubber) + return given + } + public static func getCourseDates(courseID: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_getCourseDates__courseID_courseID(`courseID`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func getCourseDates(courseID: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_getCourseDates__courseID_courseID(`courseID`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (CourseDates).self) + willProduce(stubber) + return given + } } public struct Verify { @@ -2982,6 +3053,8 @@ open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { public static func deleteAccount(password: Parameter) -> Verify { return Verify(method: .m_deleteAccount__password_password(`password`))} public static func getSettings() -> Verify { return Verify(method: .m_getSettings)} public static func saveSettings(_ settings: Parameter) -> Verify { return Verify(method: .m_saveSettings__settings(`settings`))} + public static func enrollmentsStatus() -> Verify { return Verify(method: .m_enrollmentsStatus)} + public static func getCourseDates(courseID: Parameter) -> Verify { return Verify(method: .m_getCourseDates__courseID_courseID(`courseID`))} } public struct Perform { @@ -3024,6 +3097,12 @@ open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { public static func saveSettings(_ settings: Parameter, perform: @escaping (UserSettings) -> Void) -> Perform { return Perform(method: .m_saveSettings__settings(`settings`), performs: perform) } + public static func enrollmentsStatus(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_enrollmentsStatus, performs: perform) + } + public static func getCourseDates(courseID: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_getCourseDates__courseID_courseID(`courseID`), performs: perform) + } } public func given(_ method: Given) {