From b76d824de31cb2c1f0540ddf25fc448dcb7e7c9e Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Wed, 4 Oct 2023 18:24:57 +0300 Subject: [PATCH 001/158] october bugfixes (#100) * october bugfixes Change some icons to help adjust color themes (DeleteAccountView, Send button on the keyboard, etc.) Fix CSSInjector style by adding 100% width. Need for normal work at Handouts view. Add new logic for icons viewing in CourseVerticalView. Fix a bug where the discussion page does not appear in case there is only one on the page. Fix a bug with text trimming on ParentCommentView. Change button design on PostsView by Design code. Fix the issue with the English localization on DeleteAccountView. Fix error handling at DeleteAccountViewModel. * update xcode version for unit tests * Downgrade xcode version for unit tests * Update unit_tests.yml * Update Fastfile * Update unit_tests.yml * code style improvements --- .github/workflows/unit_tests.yml | 3 -- .../Discussions/send.imageset/Contents.json | 4 +- .../Discussions/send.imageset/Group 62-2.svg | 12 ------ .../Discussions/send.imageset/Group 62.svg | 12 ------ .../Discussions/send.imageset/send 1.svg | 11 +++++ .../Discussions/send.imageset/send.svg | 11 +++++ .../Contents.json | 4 +- .../bg_delete.imageset/bg_delete 1.svg | 7 ++++ .../bg_delete.imageset/delete_bg_light.svg | 7 ++++ .../deleteAccount.imageset/Group-2.svg | 13 ------ .../Profile/deleteAccount.imageset/Group.svg | 13 ------ .../delete_char.imageset/Contents.json | 15 +++++++ .../delete_char.imageset/delete_char.svg | 8 ++++ .../delete_eyes.imageset/Contents.json | 12 ++++++ .../delete_eyes.imageset/delete_eyes.svg | 7 ++++ Core/Core/Configuration/CSSInjector.swift | 6 ++- Core/Core/SwiftGen/Assets.swift | 4 +- .../View/Base/FlexibleKeyboardInputView.swift | 14 +++++-- .../Handouts/HandoutsUpdatesDetailView.swift | 11 ++++- .../Outline/CourseVerticalView.swift | 16 +++++++- .../Presentation/Unit/CourseUnitView.swift | 5 +++ .../Comments/Base/ParentCommentView.swift | 1 + .../Presentation/Posts/PostsView.swift | 40 +++++++++++++------ .../DeleteAccount/DeleteAccountView.swift | 10 ++++- .../DeleteAccountViewModel.swift | 2 +- Profile/Profile/SwiftGen/Strings.swift | 4 +- Profile/Profile/en.lproj/Localizable.strings | 2 +- fastlane/Fastfile | 2 +- 28 files changed, 172 insertions(+), 84 deletions(-) delete mode 100644 Core/Core/Assets.xcassets/Discussions/send.imageset/Group 62-2.svg delete mode 100644 Core/Core/Assets.xcassets/Discussions/send.imageset/Group 62.svg create mode 100644 Core/Core/Assets.xcassets/Discussions/send.imageset/send 1.svg create mode 100644 Core/Core/Assets.xcassets/Discussions/send.imageset/send.svg rename Core/Core/Assets.xcassets/Profile/{deleteAccount.imageset => bg_delete.imageset}/Contents.json (77%) create mode 100644 Core/Core/Assets.xcassets/Profile/bg_delete.imageset/bg_delete 1.svg create mode 100644 Core/Core/Assets.xcassets/Profile/bg_delete.imageset/delete_bg_light.svg delete mode 100644 Core/Core/Assets.xcassets/Profile/deleteAccount.imageset/Group-2.svg delete mode 100644 Core/Core/Assets.xcassets/Profile/deleteAccount.imageset/Group.svg create mode 100644 Core/Core/Assets.xcassets/Profile/delete_char.imageset/Contents.json create mode 100644 Core/Core/Assets.xcassets/Profile/delete_char.imageset/delete_char.svg create mode 100644 Core/Core/Assets.xcassets/Profile/delete_eyes.imageset/Contents.json create mode 100644 Core/Core/Assets.xcassets/Profile/delete_eyes.imageset/delete_eyes.svg diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index e9d655c49..16dee327e 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -35,9 +35,6 @@ jobs: run: source ci_scripts/ci_prepare_env.sh && setup_github_actions_environment - - run: | - xcversion installed - - name: SwiftLint run: bundle exec fastlane linting diff --git a/Core/Core/Assets.xcassets/Discussions/send.imageset/Contents.json b/Core/Core/Assets.xcassets/Discussions/send.imageset/Contents.json index 66cc886a8..501261e9c 100644 --- a/Core/Core/Assets.xcassets/Discussions/send.imageset/Contents.json +++ b/Core/Core/Assets.xcassets/Discussions/send.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "Group 62.svg", + "filename" : "send.svg", "idiom" : "universal" }, { @@ -11,7 +11,7 @@ "value" : "dark" } ], - "filename" : "Group 62-2.svg", + "filename" : "send 1.svg", "idiom" : "universal" } ], diff --git a/Core/Core/Assets.xcassets/Discussions/send.imageset/Group 62-2.svg b/Core/Core/Assets.xcassets/Discussions/send.imageset/Group 62-2.svg deleted file mode 100644 index bafc12f2e..000000000 --- a/Core/Core/Assets.xcassets/Discussions/send.imageset/Group 62-2.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/Core/Core/Assets.xcassets/Discussions/send.imageset/Group 62.svg b/Core/Core/Assets.xcassets/Discussions/send.imageset/Group 62.svg deleted file mode 100644 index 312dc8d1a..000000000 --- a/Core/Core/Assets.xcassets/Discussions/send.imageset/Group 62.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/Core/Core/Assets.xcassets/Discussions/send.imageset/send 1.svg b/Core/Core/Assets.xcassets/Discussions/send.imageset/send 1.svg new file mode 100644 index 000000000..db8ab01ea --- /dev/null +++ b/Core/Core/Assets.xcassets/Discussions/send.imageset/send 1.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/Core/Core/Assets.xcassets/Discussions/send.imageset/send.svg b/Core/Core/Assets.xcassets/Discussions/send.imageset/send.svg new file mode 100644 index 000000000..db8ab01ea --- /dev/null +++ b/Core/Core/Assets.xcassets/Discussions/send.imageset/send.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/Core/Core/Assets.xcassets/Profile/deleteAccount.imageset/Contents.json b/Core/Core/Assets.xcassets/Profile/bg_delete.imageset/Contents.json similarity index 77% rename from Core/Core/Assets.xcassets/Profile/deleteAccount.imageset/Contents.json rename to Core/Core/Assets.xcassets/Profile/bg_delete.imageset/Contents.json index 2a3f6edf2..118426dbb 100644 --- a/Core/Core/Assets.xcassets/Profile/deleteAccount.imageset/Contents.json +++ b/Core/Core/Assets.xcassets/Profile/bg_delete.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "Group-2.svg", + "filename" : "delete_bg_light.svg", "idiom" : "universal" }, { @@ -11,7 +11,7 @@ "value" : "dark" } ], - "filename" : "Group.svg", + "filename" : "bg_delete 1.svg", "idiom" : "universal" } ], diff --git a/Core/Core/Assets.xcassets/Profile/bg_delete.imageset/bg_delete 1.svg b/Core/Core/Assets.xcassets/Profile/bg_delete.imageset/bg_delete 1.svg new file mode 100644 index 000000000..92b146822 --- /dev/null +++ b/Core/Core/Assets.xcassets/Profile/bg_delete.imageset/bg_delete 1.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Core/Core/Assets.xcassets/Profile/bg_delete.imageset/delete_bg_light.svg b/Core/Core/Assets.xcassets/Profile/bg_delete.imageset/delete_bg_light.svg new file mode 100644 index 000000000..96007a9b5 --- /dev/null +++ b/Core/Core/Assets.xcassets/Profile/bg_delete.imageset/delete_bg_light.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Core/Core/Assets.xcassets/Profile/deleteAccount.imageset/Group-2.svg b/Core/Core/Assets.xcassets/Profile/deleteAccount.imageset/Group-2.svg deleted file mode 100644 index 60f0fafb9..000000000 --- a/Core/Core/Assets.xcassets/Profile/deleteAccount.imageset/Group-2.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/Core/Core/Assets.xcassets/Profile/deleteAccount.imageset/Group.svg b/Core/Core/Assets.xcassets/Profile/deleteAccount.imageset/Group.svg deleted file mode 100644 index 09d83d54a..000000000 --- a/Core/Core/Assets.xcassets/Profile/deleteAccount.imageset/Group.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/Core/Core/Assets.xcassets/Profile/delete_char.imageset/Contents.json b/Core/Core/Assets.xcassets/Profile/delete_char.imageset/Contents.json new file mode 100644 index 000000000..44ef99c1d --- /dev/null +++ b/Core/Core/Assets.xcassets/Profile/delete_char.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "delete_char.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Core/Core/Assets.xcassets/Profile/delete_char.imageset/delete_char.svg b/Core/Core/Assets.xcassets/Profile/delete_char.imageset/delete_char.svg new file mode 100644 index 000000000..d1422ad6a --- /dev/null +++ b/Core/Core/Assets.xcassets/Profile/delete_char.imageset/delete_char.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/Core/Core/Assets.xcassets/Profile/delete_eyes.imageset/Contents.json b/Core/Core/Assets.xcassets/Profile/delete_eyes.imageset/Contents.json new file mode 100644 index 000000000..19c371f58 --- /dev/null +++ b/Core/Core/Assets.xcassets/Profile/delete_eyes.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "delete_eyes.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Core/Core/Assets.xcassets/Profile/delete_eyes.imageset/delete_eyes.svg b/Core/Core/Assets.xcassets/Profile/delete_eyes.imageset/delete_eyes.svg new file mode 100644 index 000000000..af854563b --- /dev/null +++ b/Core/Core/Assets.xcassets/Profile/delete_eyes.imageset/delete_eyes.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Core/Core/Configuration/CSSInjector.swift b/Core/Core/Configuration/CSSInjector.swift index 9e7faf0ec..b90afc26f 100644 --- a/Core/Core/Configuration/CSSInjector.swift +++ b/Core/Core/Configuration/CSSInjector.swift @@ -115,6 +115,10 @@ public class CSSInjector { let style = """ - +
""" diff --git a/Core/Core/SwiftGen/Assets.swift b/Core/Core/SwiftGen/Assets.swift index 887501306..ee82da098 100644 --- a/Core/Core/SwiftGen/Assets.swift +++ b/Core/Core/SwiftGen/Assets.swift @@ -84,8 +84,10 @@ public enum CoreAssets { public static let profile = ImageAsset(name: "profile") public static let programs = ImageAsset(name: "programs") public static let addPhoto = ImageAsset(name: "addPhoto") + public static let bgDelete = ImageAsset(name: "bg_delete") public static let checkmark = ImageAsset(name: "checkmark") - public static let deleteAccount = ImageAsset(name: "deleteAccount") + public static let deleteChar = ImageAsset(name: "delete_char") + public static let deleteEyes = ImageAsset(name: "delete_eyes") public static let done = ImageAsset(name: "done") public static let gallery = ImageAsset(name: "gallery") public static let leaveProfile = ImageAsset(name: "leaveProfile") diff --git a/Core/Core/View/Base/FlexibleKeyboardInputView.swift b/Core/Core/View/Base/FlexibleKeyboardInputView.swift index 97bc11401..e8d6d0d8a 100644 --- a/Core/Core/View/Base/FlexibleKeyboardInputView.swift +++ b/Core/Core/View/Base/FlexibleKeyboardInputView.swift @@ -74,9 +74,17 @@ public struct FlexibleKeyboardInputView: View { } }, label: { VStack { - commentText.trimmingCharacters(in: .whitespacesAndNewlines).count > 0 - ? CoreAssets.send.swiftUIImage - : CoreAssets.sendDisabled.swiftUIImage + if commentText.trimmingCharacters(in: .whitespacesAndNewlines).count > 0 { + ZStack { + Circle() + .frame(width: 36, height: 36) + .foregroundColor(.accentColor) + CoreAssets.send.swiftUIImage + .offset(y: 1) + } + } else { + CoreAssets.sendDisabled.swiftUIImage + } } .frame(width: 36, height: 36) .foregroundColor(.white) diff --git a/Course/Course/Presentation/Handouts/HandoutsUpdatesDetailView.swift b/Course/Course/Presentation/Handouts/HandoutsUpdatesDetailView.swift index 57b12b542..46d79825d 100644 --- a/Course/Course/Presentation/Handouts/HandoutsUpdatesDetailView.swift +++ b/Course/Course/Presentation/Handouts/HandoutsUpdatesDetailView.swift @@ -10,8 +10,9 @@ import Core public struct HandoutsUpdatesDetailView: View { - @Environment(\.colorScheme) var colorScheme + @Environment(\.colorScheme) var colorSchemeNative private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } + @State var colorScheme: ColorScheme = UITraitCollection.current.userInterfaceStyle == .light ? .light : .dark private var router: CourseRouter private let cssInjector: CSSInjector @@ -36,6 +37,10 @@ public struct HandoutsUpdatesDetailView: View { self.cssInjector = cssInjector } + private func updateColorScheme() { + colorScheme = UITraitCollection.current.userInterfaceStyle == .light ? .light : .dark + } + private func fixBrokenLinks(in htmlString: String) -> String { do { let regex = try NSRegularExpression( @@ -126,6 +131,10 @@ public struct HandoutsUpdatesDetailView: View { .navigationBarHidden(false) .navigationBarBackButtonHidden(false) .navigationTitle(title) + .onChange(of: colorSchemeNative) { newValue in + guard UIApplication.shared.applicationState == .active else { return } + updateColorScheme() + } } } diff --git a/Course/Course/Presentation/Outline/CourseVerticalView.swift b/Course/Course/Presentation/Outline/CourseVerticalView.swift index c5cb290af..20b1bc56f 100644 --- a/Course/Course/Presentation/Outline/CourseVerticalView.swift +++ b/Course/Course/Presentation/Outline/CourseVerticalView.swift @@ -31,6 +31,20 @@ public struct CourseVerticalView: View { self.viewModel = viewModel } + private func verticalImage(childs: [CourseBlock]) -> Image { + if childs.contains(where: { $0.type == .problem }) { + return CoreAssets.pen.swiftUIImage.renderingMode(.template) + } else if childs.contains(where: { $0.type == .video }) { + return CoreAssets.video.swiftUIImage.renderingMode(.template) + } else if childs.contains(where: { $0.type == .discussion }) { + return CoreAssets.discussion.swiftUIImage.renderingMode(.template) + } else if childs.contains(where: { $0.type == .html }) { + return CoreAssets.extra.swiftUIImage.renderingMode(.template) + } else { + return CoreAssets.extra.swiftUIImage.renderingMode(.template) + } + } + public var body: some View { ZStack(alignment: .top) { // MARK: - Page Body @@ -67,7 +81,7 @@ public struct CourseVerticalView: View { .renderingMode(.template) .foregroundColor(.accentColor) } else { - vertical.type.image + verticalImage(childs: vertical.childs) } Text(vertical.displayName) .font(Theme.Fonts.titleMedium) diff --git a/Course/Course/Presentation/Unit/CourseUnitView.swift b/Course/Course/Presentation/Unit/CourseUnitView.swift index e9afacbc1..411d8ac15 100644 --- a/Course/Course/Presentation/Unit/CourseUnitView.swift +++ b/Course/Course/Presentation/Unit/CourseUnitView.swift @@ -197,6 +197,11 @@ public struct CourseUnitView: View { } } } + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + showDiscussion = viewModel.selectedLesson().type == .discussion + } + } .navigationBarHidden(false) .navigationBarBackButtonHidden(false) .navigationTitle("") diff --git a/Discussion/Discussion/Presentation/Comments/Base/ParentCommentView.swift b/Discussion/Discussion/Presentation/Comments/Base/ParentCommentView.swift index ab6aa3455..0e8a35828 100644 --- a/Discussion/Discussion/Presentation/Comments/Base/ParentCommentView.swift +++ b/Discussion/Discussion/Presentation/Comments/Base/ParentCommentView.swift @@ -68,6 +68,7 @@ public struct ParentCommentView: View { .font(Theme.Fonts.titleLarge) Text(comments.postBodyHtml.hideHtmlTagsAndUrls()) .font(Theme.Fonts.bodyMedium) + .fixedSize(horizontal: false, vertical: true) .padding(.bottom, 8) ForEach(Array(comments.postBody.extractURLs().enumerated()), id: \.offset) { _, url in if url.isImage() { diff --git a/Discussion/Discussion/Presentation/Posts/PostsView.swift b/Discussion/Discussion/Presentation/Posts/PostsView.swift index dc7145a3a..906a47ce2 100644 --- a/Discussion/Discussion/Presentation/Posts/PostsView.swift +++ b/Discussion/Discussion/Presentation/Posts/PostsView.swift @@ -20,8 +20,16 @@ public struct PostsView: View { private let courseID: String private var showTopMenu: Bool - public init(courseID: String, currentBlockID: String, topics: Topics, title: String, type: ThreadType, - viewModel: PostsViewModel, router: DiscussionRouter, showTopMenu: Bool = true) { + public init( + courseID: String, + currentBlockID: String, + topics: Topics, + title: String, + type: ThreadType, + viewModel: PostsViewModel, + router: DiscussionRouter, + showTopMenu: Bool = true + ) { self.courseID = courseID self.title = title self.currentBlockID = currentBlockID @@ -155,18 +163,24 @@ public struct PostsView: View { .multilineTextAlignment(.center) .frame(maxWidth: .infinity) .padding(.top, 12) - StyledButton(DiscussionLocalization.Posts.NoDiscussion.createbutton, - action: { - router.createNewThread(courseID: courseID, - selectedTopic: currentBlockID, - onPostCreated: { - reloadPage(onSuccess: { - withAnimation { - scroll.scrollTo(1) - } + StyledButton( + DiscussionLocalization.Posts.NoDiscussion.createbutton, + action: { + router.createNewThread(courseID: courseID, + selectedTopic: currentBlockID, + onPostCreated: { + reloadPage(onSuccess: { + withAnimation { + scroll.scrollTo(1) + } + }) }) - }) - }).frame(width: 215).padding(.top, 40) + }, + isTransparent: true) + .frame(width: 215) + .padding(.top, 40) + .colorMultiply(.accentColor) + }.padding(24) .padding(.top, 100) } diff --git a/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift b/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift index 88c5546a2..7f8afff4c 100644 --- a/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift +++ b/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift @@ -23,8 +23,14 @@ public struct DeleteAccountView: View { ScrollView { VStack { Group { - CoreAssets.deleteAccount.swiftUIImage - .padding(.top, 50) + ZStack { + CoreAssets.bgDelete.swiftUIImage + CoreAssets.deleteChar.swiftUIImage + .foregroundColor(.accentColor) + .offset(y: -31) + CoreAssets.deleteEyes.swiftUIImage + .offset(x: -7, y: -27) + }.padding(.top, 50) Text(ProfileLocalization.DeleteAccount.areYouSure) .foregroundColor(Theme.Colors.textPrimary) + Text(ProfileLocalization.DeleteAccount.wantToDelete) diff --git a/Profile/Profile/Presentation/DeleteAccount/DeleteAccountViewModel.swift b/Profile/Profile/Presentation/DeleteAccount/DeleteAccountViewModel.swift index 58dbe5c52..c5f464d6d 100644 --- a/Profile/Profile/Presentation/DeleteAccount/DeleteAccountViewModel.swift +++ b/Profile/Profile/Presentation/DeleteAccount/DeleteAccountViewModel.swift @@ -47,7 +47,7 @@ public class DeleteAccountViewModel: ObservableObject { } } catch { isShowProgress = false - if error.asAFError?.responseCode == 403 { + if error.validationError?.statusCode == 403 { incorrectPassword = true } else if let validationError = error.validationError, let value = validationError.data?["error_code"] as? String, diff --git a/Profile/Profile/SwiftGen/Strings.swift b/Profile/Profile/SwiftGen/Strings.swift index f31fe2bf0..7f57c3ee7 100644 --- a/Profile/Profile/SwiftGen/Strings.swift +++ b/Profile/Profile/SwiftGen/Strings.swift @@ -50,8 +50,8 @@ public enum ProfileLocalization { public static let backToProfile = ProfileLocalization.tr("Localizable", "DELETE_ACCOUNT.BACK_TO_PROFILE", fallback: "Back to profile") /// Yes, delete account public static let comfirm = ProfileLocalization.tr("Localizable", "DELETE_ACCOUNT.COMFIRM", fallback: "Yes, delete account") - /// To confirm this action you need to enter you account password. - public static let description = ProfileLocalization.tr("Localizable", "DELETE_ACCOUNT.DESCRIPTION", fallback: "To confirm this action you need to enter you account password.") + /// To confirm this action you need to enter your account password. + public static let description = ProfileLocalization.tr("Localizable", "DELETE_ACCOUNT.DESCRIPTION", fallback: "To confirm this action you need to enter your account password.") /// The password is incorrect. Please try again. public static let incorrectPassword = ProfileLocalization.tr("Localizable", "DELETE_ACCOUNT.INCORRECT_PASSWORD", fallback: "The password is incorrect. Please try again.") /// Password diff --git a/Profile/Profile/en.lproj/Localizable.strings b/Profile/Profile/en.lproj/Localizable.strings index 5c0cd4270..9eef8b47f 100644 --- a/Profile/Profile/en.lproj/Localizable.strings +++ b/Profile/Profile/en.lproj/Localizable.strings @@ -48,7 +48,7 @@ "DELETE_ACCOUNT.TITLE" = "Delete account"; "DELETE_ACCOUNT.ARE_YOU_SURE" = "Are you sure you want to "; "DELETE_ACCOUNT.WANT_TO_DELETE" = "delete your account?"; -"DELETE_ACCOUNT.DESCRIPTION" = "To confirm this action you need to enter you account password."; +"DELETE_ACCOUNT.DESCRIPTION" = "To confirm this action you need to enter your account password."; "DELETE_ACCOUNT.PASSWORD" = "Password"; "DELETE_ACCOUNT.PASSWORD_DESCRIPTION" = "Enter password"; "DELETE_ACCOUNT.COMFIRM" = "Yes, delete account"; diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 0f4ba890a..35445da9e 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -14,7 +14,7 @@ # update_fastlane before_all do - xcversion(version: "~> 14.3") + xcversion(version: "~> 15.0.0") ENV["FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT"] = "180" ENV["FASTLANE_XCODE_LIST_TIMEOUT"] = "180" From ec3ad4e0413175ddb03f6e990d0afdedf36eb86d Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Fri, 13 Oct 2023 12:24:44 +0300 Subject: [PATCH 002/158] Transcript navigation (#101) * Transcript navigation Display of video transcripts with active sentence highlighting. Tapping on transcript sentences to navigate to the corresponding part of the video. Mechanism to quickly return to the current active sentence after scrolling. https://github.com/openedx/openedx-app-ios/assets/128456094/b2cf1f49-e23b-42dd-a9b0-0ddac26bda59 --- Core/Core/Extensions/DateExtension.swift | 17 ++++++++- .../Video/EncodedVideoPlayer.swift | 20 ++++++++-- .../Presentation/Video/SubtittlesView.swift | 37 ++++++++++++++----- .../Video/YouTubeVideoPlayer.swift | 7 +++- .../Video/YouTubeVideoPlayerViewModel.swift | 12 +++++- 5 files changed, 77 insertions(+), 16 deletions(-) diff --git a/Core/Core/Extensions/DateExtension.swift b/Core/Core/Extensions/DateExtension.swift index fae6cd14c..059b54cc2 100644 --- a/Core/Core/Extensions/DateExtension.swift +++ b/Core/Core/Extensions/DateExtension.swift @@ -13,7 +13,7 @@ public extension Date { var dateFormatter: DateFormatter? dateFormatter = DateFormatter() dateFormatter?.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ" - + dateFormatter?.locale = Locale(identifier: "en_US_POSIX") date = dateFormatter?.date(from: iso8601) ?? Date() defer { dateFormatter = nil @@ -25,6 +25,7 @@ public extension Date { let formatter = RelativeDateTimeFormatter() formatter.locale = .current formatter.unitsStyle = .full + formatter.locale = Locale(identifier: "en_US_POSIX") if self.description == Date().description { return CoreLocalization.Date.justNow } else { @@ -38,6 +39,7 @@ public extension Date { let components = calendar.dateComponents([.year, .month, .day], from: now) let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss,SSS" + dateFormatter.locale = Locale(identifier: "en_US_POSIX") let dateString = "\(components.year!)-\(components.month!)-\(components.day!) \(subtitleTime)" guard let date = dateFormatter.date(from: dateString) else { self = now @@ -70,6 +72,19 @@ public enum DateStringStyle { } public extension Date { + + func secondsSinceMidnight() -> Double { + let calendar = Calendar.current + let components = calendar.dateComponents([.hour, .minute, .second], from: self) + + guard let hours = components.hour, let minutes = components.minute, let seconds = components.second else { + return 0.0 + } + + let totalSeconds = Double(hours) * 3600.0 + Double(minutes) * 60.0 + Double(seconds) + return totalSeconds + } + func dateToString(style: DateStringStyle) -> String { let dateFormatter = DateFormatter() dateFormatter.locale = Locale(identifier: "en_US_POSIX") diff --git a/Course/Course/Presentation/Video/EncodedVideoPlayer.swift b/Course/Course/Presentation/Video/EncodedVideoPlayer.swift index 251b59417..0afcfba01 100644 --- a/Course/Course/Presentation/Video/EncodedVideoPlayer.swift +++ b/Course/Course/Presentation/Video/EncodedVideoPlayer.swift @@ -30,6 +30,7 @@ public struct EncodedVideoPlayer: View { @State private var isViewedOnce: Bool = false @State private var currentTime: Double = 0 @State private var isOrientationChanged: Bool = false + @State private var pause: Bool = false @State var showAlert = false @State var alertMessage: String? { @@ -64,7 +65,9 @@ public struct EncodedVideoPlayer: View { } } }, seconds: { seconds in - currentTime = seconds + if !pause { + currentTime = seconds + } }) .aspectRatio(16 / 9, contentMode: .fit) .cornerRadius(12) @@ -89,8 +92,13 @@ public struct EncodedVideoPlayer: View { } } SubtittlesView(languages: viewModel.languages, - currentTime: $currentTime, - viewModel: viewModel) + currentTime: $currentTime, + viewModel: viewModel, scrollTo: { date in + viewModel.controller.player?.seek(to: CMTime(seconds: date.secondsSinceMidnight(), + preferredTimescale: 10000)) + pauseScrolling() + currentTime = (date.secondsSinceMidnight() + 1) + }) Spacer() if !orientation.isLandscape || idiom != .pad { VStack {}.onAppear { @@ -120,6 +128,12 @@ public struct EncodedVideoPlayer: View { } } } + private func pauseScrolling() { + pause = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + self.pause = false + } + } } #if DEBUG diff --git a/Course/Course/Presentation/Video/SubtittlesView.swift b/Course/Course/Presentation/Video/SubtittlesView.swift index 6501cb409..eb3496bdc 100644 --- a/Course/Course/Presentation/Video/SubtittlesView.swift +++ b/Course/Course/Presentation/Video/SubtittlesView.swift @@ -18,17 +18,21 @@ public struct SubtittlesView: View { @ObservedObject private var viewModel: VideoPlayerViewModel + private var scrollTo: ((Date) -> Void) = { _ in } @Binding var currentTime: Double @State var id = 0 + @State var pause: Bool = false @State var languages: [SubtitleUrl] public init(languages: [SubtitleUrl], currentTime: Binding, - viewModel: VideoPlayerViewModel) { + viewModel: VideoPlayerViewModel, + scrollTo: @escaping (Date) -> Void) { self.languages = languages self.viewModel = viewModel self._currentTime = currentTime + self.scrollTo = scrollTo } public var body: some View { @@ -51,42 +55,57 @@ public struct SubtittlesView: View { } } ZStack { + ScrollView { if viewModel.subtitles.count > 0 { VStack(alignment: .leading, spacing: 0) { ForEach(viewModel.subtitles, id: \.id) { subtitle in HStack { + Button(action: { + scrollTo(subtitle.fromTo.start) + pause = false + }, label: { Text(subtitle.text) .padding(.vertical, 16) .font(Theme.Fonts.bodyMedium) + .multilineTextAlignment(.leading) .foregroundColor(subtitle.fromTo.contains(Date(milliseconds: currentTime)) ? Theme.Colors.textPrimary : Theme.Colors.textSecondary) + .onChange(of: currentTime, perform: { _ in if subtitle.fromTo.contains(Date(milliseconds: currentTime)) { if id != subtitle.id { withAnimation { - scroll.scrollTo(subtitle.id, anchor: .top) + if !pause { + scroll.scrollTo(subtitle.id, anchor: .top) + } } } self.id = subtitle.id } }) + }) }.id(subtitle.id) } } - .introspect(.scrollView, on: .iOS(.v14, .v15, .v16, .v17), customize: { scrollView in - scrollView.isScrollEnabled = false - }) } - } - // Forced disable scrolling for iOS 14, 15 - Color.white.opacity(0) + }.simultaneousGesture( + DragGesture().onChanged({ _ in + pauseScrolling() + })) } }.padding(.horizontal, 24) .padding(.top, 34) } } + + private func pauseScrolling() { + pause = true + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + self.pause = false + } + } } #if DEBUG @@ -103,7 +122,7 @@ struct SubtittlesView_Previews: PreviewProvider { interactor: CourseInteractor(repository: CourseRepositoryMock()), router: CourseRouterMock(), connectivity: Connectivity() - ) + ), scrollTo: {_ in } ) } } diff --git a/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift b/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift index f3a886c72..1afa10778 100644 --- a/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift +++ b/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift @@ -16,7 +16,6 @@ public struct YouTubeVideoPlayer: View { @StateObject private var viewModel: YouTubeVideoPlayerViewModel private var isOnScreen: Bool - @State private var showAlert = false @State @@ -69,7 +68,11 @@ public struct YouTubeVideoPlayer: View { SubtittlesView( languages: viewModel.languages, currentTime: $viewModel.currentTime, - viewModel: viewModel + viewModel: viewModel, scrollTo: { date in + viewModel.youtubePlayer.seek(to: date.secondsSinceMidnight(), allowSeekAhead: true) + viewModel.pauseScrolling() + viewModel.currentTime = date.secondsSinceMidnight() + 1 + } ) } diff --git a/Course/Course/Presentation/Video/YouTubeVideoPlayerViewModel.swift b/Course/Course/Presentation/Video/YouTubeVideoPlayerViewModel.swift index 5aaacf7ff..06bc69f75 100644 --- a/Course/Course/Presentation/Video/YouTubeVideoPlayerViewModel.swift +++ b/Course/Course/Presentation/Video/YouTubeVideoPlayerViewModel.swift @@ -17,6 +17,7 @@ public class YouTubeVideoPlayerViewModel: VideoPlayerViewModel { private (set) var play = false @Published var isLoading: Bool = true @Published var currentTime: Double = 0 + @Published var pause: Bool = false private var subscription = Set() private var duration: Double? @@ -68,6 +69,13 @@ public class YouTubeVideoPlayerViewModel: VideoPlayerViewModel { subscrube(playerStateSubject: playerStateSubject) } + func pauseScrolling() { + pause = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + self.pause = false + } + } + private func subscrube(playerStateSubject: CurrentValueSubject) { playerStateSubject.sink(receiveValue: { [weak self] state in switch state { @@ -84,7 +92,9 @@ public class YouTubeVideoPlayerViewModel: VideoPlayerViewModel { youtubePlayer.currentTimePublisher(updateInterval: 0.1).sink(receiveValue: { [weak self] time in guard let self else { return } - self.currentTime = time + if !self.pause { + self.currentTime = time + } if let duration = self.duration { if (time / duration) >= 0.8 { From 60aa49499c732f63d6cbc633e6ea479baeae452e Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Fri, 13 Oct 2023 12:54:15 +0300 Subject: [PATCH 003/158] Add user profile view (#105) * add Discussion Profile View * add tests * return the old preview for tests fixing * Update ProfileRepository.swift --- Core/Core/Data/Model/Data_UserProfile.swift | 5 +- .../Data/Network/DiscussionRepository.swift | 2 +- .../Comments/Base/CommentCell.swift | 11 ++ .../Comments/Base/ParentCommentView.swift | 8 + .../Comments/Responses/ResponsesView.swift | 9 +- .../Comments/Thread/ThreadView.swift | 9 +- .../Presentation/DiscussionRouter.swift | 4 + .../DiscussionMock.generated.swift | 18 +++ OpenEdX/DI/ScreenAssembly.swift | 10 +- OpenEdX/Router.swift | 10 ++ Profile/Profile.xcodeproj/project.pbxproj | 16 ++ Profile/Profile/Data/ProfileRepository.swift | 23 ++- .../Profile/Domain/ProfileInteractor.swift | 5 + .../Profile/UserProfile/UserProfileView.swift | 146 ++++++++++++++++++ .../UserProfile/UserProfileViewModel.swift | 52 +++++++ .../Profile/ProfileViewModelTests.swift | 80 +++++++++- .../ProfileTests/ProfileMock.generated.swift | 41 +++++ 17 files changed, 430 insertions(+), 19 deletions(-) create mode 100644 Profile/Profile/Presentation/Profile/UserProfile/UserProfileView.swift create mode 100644 Profile/Profile/Presentation/Profile/UserProfile/UserProfileViewModel.swift diff --git a/Core/Core/Data/Model/Data_UserProfile.swift b/Core/Core/Data/Model/Data_UserProfile.swift index d0207ea95..d3541cfd2 100644 --- a/Core/Core/Data/Model/Data_UserProfile.swift +++ b/Core/Core/Data/Model/Data_UserProfile.swift @@ -12,7 +12,7 @@ import Foundation // MARK: - UserProfile public extension DataLayer { struct UserProfile: Codable { - public let id: Int + public let id: Int? public let accountPrivacy: AccountPrivacy? public let profileImage: ProfileImage? public let username: String? @@ -70,12 +70,13 @@ public extension DataLayer { public enum AccountPrivacy: String, Codable { case privateAccess = "private" case allUsers = "all_users" + case allUsersBig = "ALL_USERS" public var boolValue: Bool { switch self { case .privateAccess: return false - case .allUsers: + case .allUsers, .allUsersBig: return true } } diff --git a/Discussion/Discussion/Data/Network/DiscussionRepository.swift b/Discussion/Discussion/Data/Network/DiscussionRepository.swift index 18530e784..7e395da51 100644 --- a/Discussion/Discussion/Data/Network/DiscussionRepository.swift +++ b/Discussion/Discussion/Data/Network/DiscussionRepository.swift @@ -181,7 +181,7 @@ public class DiscussionRepository: DiscussionRepositoryProtocol { #if DEBUG // swiftlint:disable all public class DiscussionRepositoryMock: DiscussionRepositoryProtocol { - + var comments = [ UserComment(authorName: "Bill", authorAvatar: "", diff --git a/Discussion/Discussion/Presentation/Comments/Base/CommentCell.swift b/Discussion/Discussion/Presentation/Comments/Base/CommentCell.swift index f818590d2..a00ecb57f 100644 --- a/Discussion/Discussion/Presentation/Comments/Base/CommentCell.swift +++ b/Discussion/Discussion/Presentation/Comments/Base/CommentCell.swift @@ -13,6 +13,7 @@ public struct CommentCell: View { private let comment: Post private let addCommentAvailable: Bool + private var onAvatarTap: ((String) -> Void) private var onLikeTap: (() -> Void) private var onReportTap: (() -> Void) private var onCommentsTap: (() -> Void) @@ -25,6 +26,7 @@ public struct CommentCell: View { comment: Post, addCommentAvailable: Bool, leftLineEnabled: Bool = false, + onAvatarTap: @escaping (String) -> Void, onLikeTap: @escaping () -> Void, onReportTap: @escaping () -> Void, onCommentsTap: @escaping () -> Void, @@ -33,6 +35,7 @@ public struct CommentCell: View { self.comment = comment self.addCommentAvailable = addCommentAvailable self.leftLineEnabled = leftLineEnabled + self.onAvatarTap = onAvatarTap self.onLikeTap = onLikeTap self.onReportTap = onReportTap self.onCommentsTap = onCommentsTap @@ -42,11 +45,15 @@ public struct CommentCell: View { public var body: some View { VStack(alignment: .leading) { HStack { + Button(action: { + onAvatarTap(comment.authorName) + }, label: { KFImage(URL(string: comment.authorAvatar)) .onFailureImage(KFCrossPlatformImage(systemName: "person.circle")) .resizable() .frame(width: 32, height: 32) .cornerRadius(16) + }) VStack(alignment: .leading) { Text(comment.authorName) @@ -170,6 +177,7 @@ struct CommentView_Previews: PreviewProvider { comment: comment, addCommentAvailable: true, leftLineEnabled: false, + onAvatarTap: {_ in}, onLikeTap: {}, onReportTap: {}, onCommentsTap: {}, @@ -178,6 +186,7 @@ struct CommentView_Previews: PreviewProvider { comment: comment, addCommentAvailable: true, leftLineEnabled: false, + onAvatarTap: {_ in}, onLikeTap: {}, onReportTap: {}, onCommentsTap: {}, @@ -192,6 +201,7 @@ struct CommentView_Previews: PreviewProvider { comment: comment, addCommentAvailable: true, leftLineEnabled: false, + onAvatarTap: {_ in}, onLikeTap: {}, onReportTap: {}, onCommentsTap: {}, @@ -200,6 +210,7 @@ struct CommentView_Previews: PreviewProvider { comment: comment, addCommentAvailable: true, leftLineEnabled: false, + onAvatarTap: {_ in}, onLikeTap: {}, onReportTap: {}, onCommentsTap: {}, diff --git a/Discussion/Discussion/Presentation/Comments/Base/ParentCommentView.swift b/Discussion/Discussion/Presentation/Comments/Base/ParentCommentView.swift index 0e8a35828..75770707a 100644 --- a/Discussion/Discussion/Presentation/Comments/Base/ParentCommentView.swift +++ b/Discussion/Discussion/Presentation/Comments/Base/ParentCommentView.swift @@ -13,6 +13,7 @@ public struct ParentCommentView: View { private let comments: Post private var isThread: Bool + private var onAvatarTap: ((String) -> Void) private var onLikeTap: (() -> Void) private var onReportTap: (() -> Void) private var onFollowTap: (() -> Void) @@ -22,12 +23,14 @@ public struct ParentCommentView: View { public init( comments: Post, isThread: Bool, + onAvatarTap: @escaping (String) -> Void, onLikeTap: @escaping () -> Void, onReportTap: @escaping () -> Void, onFollowTap: @escaping () -> Void ) { self.comments = comments self.isThread = isThread + self.onAvatarTap = onAvatarTap self.onLikeTap = onLikeTap self.onReportTap = onReportTap self.onFollowTap = onFollowTap @@ -36,12 +39,16 @@ public struct ParentCommentView: View { public var body: some View { VStack(alignment: .leading) { HStack { + Button(action: { + onAvatarTap(comments.authorName) + }, label: { KFImage(URL(string: comments.authorAvatar)) .onFailureImage(KFCrossPlatformImage(systemName: "person")) .resizable() .background(Color.gray) .frame(width: 48, height: 48) .cornerRadius(isThread ? 8 : 24) + }) VStack(alignment: .leading) { Text(comments.authorName) .font(Theme.Fonts.titleMedium) @@ -157,6 +164,7 @@ struct ParentCommentView_Previews: PreviewProvider { ParentCommentView( comments: comment, isThread: true, + onAvatarTap: {_ in}, onLikeTap: {}, onReportTap: {}, onFollowTap: {} diff --git a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift index 69e666844..fe0e43058 100644 --- a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift +++ b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift @@ -52,7 +52,9 @@ public struct ResponsesView: View { if let comments = viewModel.postComments { ParentCommentView( comments: comments, - isThread: false, + isThread: false, onAvatarTap: { username in + viewModel.router.showUserDetails(username: username) + }, onLikeTap: { Task { if await viewModel.vote( @@ -93,7 +95,10 @@ public struct ResponsesView: View { ) { index, comment in CommentCell( comment: comment, - addCommentAvailable: false, leftLineEnabled: true, + addCommentAvailable: false, leftLineEnabled: true, + onAvatarTap: { username in + viewModel.router.showUserDetails(username: username) + }, onLikeTap: { Task { await viewModel.vote( diff --git a/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift b/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift index bdc5ae96a..0ca873e41 100644 --- a/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift +++ b/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift @@ -41,7 +41,10 @@ public struct ThreadView: View { if let comments = viewModel.postComments { ParentCommentView( comments: comments, - isThread: true, + isThread: true, + onAvatarTap: { username in + viewModel.router.showUserDetails(username: username) + }, onLikeTap: { Task { if await viewModel.vote( @@ -91,7 +94,9 @@ public struct ThreadView: View { CommentCell( comment: comment, addCommentAvailable: true, - onLikeTap: { + onAvatarTap: { username in + viewModel.router.showUserDetails(username: username) + }, onLikeTap: { Task { await viewModel.vote( id: comment.commentID, diff --git a/Discussion/Discussion/Presentation/DiscussionRouter.swift b/Discussion/Discussion/Presentation/DiscussionRouter.swift index f57e883b9..5cc0fed94 100644 --- a/Discussion/Discussion/Presentation/DiscussionRouter.swift +++ b/Discussion/Discussion/Presentation/DiscussionRouter.swift @@ -12,6 +12,8 @@ import Combine //sourcery: AutoMockable public protocol DiscussionRouter: BaseRouter { + func showUserDetails(username: String) + func showThreads(courseID: String, topics: Topics, title: String, type: ThreadType) func showThread(thread: UserThread, postStateSubject: CurrentValueSubject) @@ -33,6 +35,8 @@ public class DiscussionRouterMock: BaseRouterMock, DiscussionRouter { public override init() {} + public func showUserDetails(username: String) {} + public func showThreads(courseID: String, topics: Topics, title: String, type: ThreadType) {} public func showThread(thread: UserThread, postStateSubject: CurrentValueSubject) {} diff --git a/Discussion/DiscussionTests/DiscussionMock.generated.swift b/Discussion/DiscussionTests/DiscussionMock.generated.swift index bcb9ac4df..424aa9aaf 100644 --- a/Discussion/DiscussionTests/DiscussionMock.generated.swift +++ b/Discussion/DiscussionTests/DiscussionMock.generated.swift @@ -1967,6 +1967,12 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { + open func showUserDetails(username: String) { + addInvocation(.m_showUserDetails__username_username(Parameter.value(`username`))) + let perform = methodPerformValue(.m_showUserDetails__username_username(Parameter.value(`username`))) as? (String) -> Void + perform?(`username`) + } + open func showThreads(courseID: String, topics: Topics, title: String, type: ThreadType) { addInvocation(.m_showThreads__courseID_courseIDtopics_topicstitle_titletype_type(Parameter.value(`courseID`), Parameter.value(`topics`), Parameter.value(`title`), Parameter.value(`type`))) let perform = methodPerformValue(.m_showThreads__courseID_courseIDtopics_topicstitle_titletype_type(Parameter.value(`courseID`), Parameter.value(`topics`), Parameter.value(`title`), Parameter.value(`type`))) as? (String, Topics, String, ThreadType) -> Void @@ -2077,6 +2083,7 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { fileprivate enum MethodType { + case m_showUserDetails__username_username(Parameter) case m_showThreads__courseID_courseIDtopics_topicstitle_titletype_type(Parameter, Parameter, Parameter, Parameter) case m_showThread__thread_threadpostStateSubject_postStateSubject(Parameter, Parameter>) case m_showDiscussionsSearch__courseID_courseID(Parameter) @@ -2098,6 +2105,11 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { switch (lhs, rhs) { + case (.m_showUserDetails__username_username(let lhsUsername), .m_showUserDetails__username_username(let rhsUsername)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsUsername, rhs: rhsUsername, with: matcher), lhsUsername, rhsUsername, "username")) + return Matcher.ComparisonResult(results) + case (.m_showThreads__courseID_courseIDtopics_topicstitle_titletype_type(let lhsCourseid, let lhsTopics, let lhsTitle, let lhsType), .m_showThreads__courseID_courseIDtopics_topicstitle_titletype_type(let rhsCourseid, let rhsTopics, let rhsTitle, let rhsType)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseID")) @@ -2200,6 +2212,7 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { func intValue() -> Int { switch self { + case let .m_showUserDetails__username_username(p0): return p0.intValue case let .m_showThreads__courseID_courseIDtopics_topicstitle_titletype_type(p0, p1, p2, p3): return p0.intValue + p1.intValue + p2.intValue + p3.intValue case let .m_showThread__thread_threadpostStateSubject_postStateSubject(p0, p1): return p0.intValue + p1.intValue case let .m_showDiscussionsSearch__courseID_courseID(p0): return p0.intValue @@ -2222,6 +2235,7 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { } func assertionName() -> String { switch self { + case .m_showUserDetails__username_username: return ".showUserDetails(username:)" case .m_showThreads__courseID_courseIDtopics_topicstitle_titletype_type: return ".showThreads(courseID:topics:title:type:)" case .m_showThread__thread_threadpostStateSubject_postStateSubject: return ".showThread(thread:postStateSubject:)" case .m_showDiscussionsSearch__courseID_courseID: return ".showDiscussionsSearch(courseID:)" @@ -2258,6 +2272,7 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { public struct Verify { fileprivate var method: MethodType + public static func showUserDetails(username: Parameter) -> Verify { return Verify(method: .m_showUserDetails__username_username(`username`))} public static func showThreads(courseID: Parameter, topics: Parameter, title: Parameter, type: Parameter) -> Verify { return Verify(method: .m_showThreads__courseID_courseIDtopics_topicstitle_titletype_type(`courseID`, `topics`, `title`, `type`))} public static func showThread(thread: Parameter, postStateSubject: Parameter>) -> Verify { return Verify(method: .m_showThread__thread_threadpostStateSubject_postStateSubject(`thread`, `postStateSubject`))} public static func showDiscussionsSearch(courseID: Parameter) -> Verify { return Verify(method: .m_showDiscussionsSearch__courseID_courseID(`courseID`))} @@ -2282,6 +2297,9 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { fileprivate var method: MethodType var performs: Any + public static func showUserDetails(username: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_showUserDetails__username_username(`username`), performs: perform) + } public static func showThreads(courseID: Parameter, topics: Parameter, title: Parameter, type: Parameter, perform: @escaping (String, Topics, String, ThreadType) -> Void) -> Perform { return Perform(method: .m_showThreads__courseID_courseIDtopics_topicstitle_titletype_type(`courseID`, `topics`, `title`, `type`), performs: perform) } diff --git a/OpenEdX/DI/ScreenAssembly.swift b/OpenEdX/DI/ScreenAssembly.swift index 1dd9ddddb..c5c52df4c 100644 --- a/OpenEdX/DI/ScreenAssembly.swift +++ b/OpenEdX/DI/ScreenAssembly.swift @@ -134,14 +134,14 @@ class ScreenAssembly: Assembly { config: r.resolve(Config.self)! ) } - container.register(ProfileInteractor.self) { r in + container.register(ProfileInteractorProtocol.self) { r in ProfileInteractor( repository: r.resolve(ProfileRepositoryProtocol.self)! ) } container.register(ProfileViewModel.self) { r in ProfileViewModel( - interactor: r.resolve(ProfileInteractor.self)!, + interactor: r.resolve(ProfileInteractorProtocol.self)!, router: r.resolve(ProfileRouter.self)!, analytics: r.resolve(ProfileAnalytics.self)!, config: r.resolve(Config.self)!, @@ -151,7 +151,7 @@ class ScreenAssembly: Assembly { container.register(EditProfileViewModel.self) { r, userModel in EditProfileViewModel( userModel: userModel, - interactor: r.resolve(ProfileInteractor.self)!, + interactor: r.resolve(ProfileInteractorProtocol.self)!, router: r.resolve(ProfileRouter.self)!, analytics: r.resolve(ProfileAnalytics.self)! @@ -160,14 +160,14 @@ class ScreenAssembly: Assembly { container.register(SettingsViewModel.self) { r in SettingsViewModel( - interactor: r.resolve(ProfileInteractor.self)!, + interactor: r.resolve(ProfileInteractorProtocol.self)!, router: r.resolve(ProfileRouter.self)! ) } container.register(DeleteAccountViewModel.self) { r in DeleteAccountViewModel( - interactor: r.resolve(ProfileInteractor.self)!, + interactor: r.resolve(ProfileInteractorProtocol.self)!, router: r.resolve(ProfileRouter.self)!, connectivity: r.resolve(ConnectivityProtocol.self)! ) diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index 9be51948e..58413b862 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -352,6 +352,16 @@ public class Router: AuthorizationRouter, navigationController.pushViewController(controller, animated: true) } + public func showUserDetails(username: String) { + let interactor = container.resolve(ProfileInteractorProtocol.self)! + + let vm = UserProfileViewModel(interactor: interactor, + username: username) + let view = UserProfileView(viewModel: vm) + let controller = UIHostingController(rootView: view) + navigationController.pushViewController(controller, animated: true) + } + public func showEditProfile( userModel: Core.UserProfile, avatar: UIImage?, diff --git a/Profile/Profile.xcodeproj/project.pbxproj b/Profile/Profile.xcodeproj/project.pbxproj index 3ba3e0531..28dc8a604 100644 --- a/Profile/Profile.xcodeproj/project.pbxproj +++ b/Profile/Profile.xcodeproj/project.pbxproj @@ -30,6 +30,8 @@ 02A9A91E2978194A00B55797 /* Profile.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 020F834A28DB4CCD0062FA70 /* Profile.framework */; platformFilter = ios; }; 02A9A92B29781A6300B55797 /* ProfileMock.generated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A9A92A29781A6300B55797 /* ProfileMock.generated.swift */; }; 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 */; }; 02F175352A4DAD030019CD70 /* ProfileAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F175342A4DAD030019CD70 /* ProfileAnalytics.swift */; }; 02F3BFE7292539850051930C /* ProfileRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F3BFE6292539850051930C /* ProfileRouter.swift */; }; 0796C8C929B7905300444B05 /* ProfileBottomSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0796C8C829B7905300444B05 /* ProfileBottomSheet.swift */; }; @@ -72,6 +74,8 @@ 02A9A91C2978194A00B55797 /* ProfileViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModelTests.swift; sourceTree = ""; }; 02A9A92A29781A6300B55797 /* ProfileMock.generated.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProfileMock.generated.swift; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; @@ -131,6 +135,7 @@ 0203DC3D29AE79F80017BD05 /* Profile */ = { isa = PBXGroup; children = ( + 02D0FD072AD695E10020D752 /* UserProfile */, 021D924528DC634300ACC565 /* ProfileView.swift */, 021D925128DC918D00ACC565 /* ProfileViewModel.swift */, ); @@ -287,6 +292,15 @@ path = Data; sourceTree = ""; }; + 02D0FD072AD695E10020D752 /* UserProfile */ = { + isa = PBXGroup; + children = ( + 02D0FD082AD698380020D752 /* UserProfileView.swift */, + 02D0FD0A2AD6984D0020D752 /* UserProfileViewModel.swift */, + ); + path = UserProfile; + sourceTree = ""; + }; 0766DFD3299AD9D800EBEF6A /* Presentation */ = { isa = PBXGroup; children = ( @@ -551,6 +565,7 @@ 021D924C28DC884A00ACC565 /* ProfileEndpoint.swift in Sources */, 020306C82932B13F000949EA /* EditProfileView.swift in Sources */, 0262149229AE57A1008BD75A /* DeleteAccountView.swift in Sources */, + 02D0FD092AD698380020D752 /* UserProfileView.swift in Sources */, 0259104629C39CCF004B5A55 /* SettingsViewModel.swift in Sources */, 021D925528DC92F800ACC565 /* ProfileInteractor.swift in Sources */, 029301DA2938948500E99AB8 /* ProfileType.swift in Sources */, @@ -562,6 +577,7 @@ 0259104829C3A5F0004B5A55 /* VideoQualityView.swift in Sources */, 021D924628DC634300ACC565 /* ProfileView.swift in Sources */, 02F3BFE7292539850051930C /* ProfileRouter.swift in Sources */, + 02D0FD0B2AD6984D0020D752 /* UserProfileViewModel.swift in Sources */, 02F175352A4DAD030019CD70 /* ProfileAnalytics.swift in Sources */, 0262149429AE57B1008BD75A /* DeleteAccountViewModel.swift in Sources */, ); diff --git a/Profile/Profile/Data/ProfileRepository.swift b/Profile/Profile/Data/ProfileRepository.swift index 9c95c9a7a..bf1a02ec2 100644 --- a/Profile/Profile/Data/ProfileRepository.swift +++ b/Profile/Profile/Data/ProfileRepository.swift @@ -10,6 +10,7 @@ import Core import Alamofire public protocol ProfileRepositoryProtocol { + func getUserProfile(username: String) async throws -> UserProfile func getMyProfile() async throws -> UserProfile func getMyProfileOffline() throws -> UserProfile func logOut() async throws @@ -45,9 +46,15 @@ public class ProfileRepository: ProfileRepositoryProtocol { self.config = config } + public func getUserProfile(username: String) async throws -> UserProfile { + let user = try await api.requestData( + ProfileEndpoint.getUserProfile(username: username) + ).mapResponse(DataLayer.UserProfile.self) + return user.domain + } + public func getMyProfile() async throws -> UserProfile { - let user = - try await api.requestData( + let user = try await api.requestData( ProfileEndpoint.getUserProfile(username: storage.user?.username ?? "") ).mapResponse(DataLayer.UserProfile.self) storage.userProfile = user @@ -154,6 +161,18 @@ public class ProfileRepository: ProfileRepositoryProtocol { #if DEBUG // swiftlint:disable all class ProfileRepositoryMock: ProfileRepositoryProtocol { + + public func getUserProfile(username: String) async throws -> Core.UserProfile { + return Core.UserProfile(avatarUrl: "", + name: "", + username: "", + dateJoined: Date(), + yearOfBirth: 0, + country: "", + shortBiography: "", + isFullProfile: false) + } + func getMyProfileOffline() throws -> Core.UserProfile { return UserProfile( avatarUrl: "", diff --git a/Profile/Profile/Domain/ProfileInteractor.swift b/Profile/Profile/Domain/ProfileInteractor.swift index a29d04ad2..0f8fd4708 100644 --- a/Profile/Profile/Domain/ProfileInteractor.swift +++ b/Profile/Profile/Domain/ProfileInteractor.swift @@ -11,6 +11,7 @@ import UIKit //sourcery: AutoMockable public protocol ProfileInteractorProtocol { + func getUserProfile(username: String) async throws -> UserProfile func getMyProfile() async throws -> UserProfile func getMyProfileOffline() throws -> UserProfile func logOut() async throws @@ -32,6 +33,10 @@ public class ProfileInteractor: ProfileInteractorProtocol { self.repository = repository } + public func getUserProfile(username: String) async throws -> UserProfile { + return try await repository.getUserProfile(username: username) + } + public func getMyProfile() async throws -> UserProfile { return try await repository.getMyProfile() } diff --git a/Profile/Profile/Presentation/Profile/UserProfile/UserProfileView.swift b/Profile/Profile/Presentation/Profile/UserProfile/UserProfileView.swift new file mode 100644 index 000000000..da5a7f9dc --- /dev/null +++ b/Profile/Profile/Presentation/Profile/UserProfile/UserProfileView.swift @@ -0,0 +1,146 @@ +// +// UserProfileView.swift +// Profile +// +// Created by  Stepanok Ivan on 10.10.2023. +// + +import SwiftUI +import Core +import Kingfisher + +public struct UserProfileView: View { + + @ObservedObject private var viewModel: UserProfileViewModel + + public init(viewModel: UserProfileViewModel) { + self.viewModel = viewModel + } + + public var body: some View { + ZStack(alignment: .top) { + // MARK: - Page Body + RefreshableScrollViewCompat(action: { + await viewModel.getUserProfile(withProgress: false) + }) { + VStack { + if viewModel.isShowProgress { + ProgressBar(size: 40, lineWidth: 8) + .padding(.top, 200) + .padding(.horizontal) + } else { + ProfileAvatar(url: viewModel.userModel?.avatarUrl ?? "") + if let name = viewModel.userModel?.name, name != "" { + Text(name) + .font(Theme.Fonts.headlineSmall) + .padding(.top, 20) + } + Text("@\(viewModel.userModel?.username ?? "")") + .font(Theme.Fonts.labelLarge) + .padding(.top, 4) + .foregroundColor(Theme.Colors.textSecondary) + .padding(.bottom, 10) + + // MARK: - Profile Info + if viewModel.userModel?.yearOfBirth != 0 || viewModel.userModel?.shortBiography != "" { + VStack(alignment: .leading, spacing: 14) { + Text(ProfileLocalization.info) + .padding(.horizontal, 24) + .font(Theme.Fonts.labelLarge) + + VStack(alignment: .leading, spacing: 16) { + if viewModel.userModel?.yearOfBirth != 0 { + HStack { + Text(ProfileLocalization.Edit.Fields.yearOfBirth) + .foregroundColor(Theme.Colors.textSecondary) + Text(String(viewModel.userModel?.yearOfBirth ?? 0)) + } + } + if let bio = viewModel.userModel?.shortBiography, bio != "" { + HStack(alignment: .top) { + Text(ProfileLocalization.bio + " ") + .foregroundColor(Theme.Colors.textSecondary) + + Text(bio) + } + } + } + .cardStyle( + bgColor: Theme.Colors.textInputUnfocusedBackground, + strokeColor: .clear + ) + }.padding(.bottom, 16) + } + } + Spacer() + } + }.frameLimit(sizePortrait: 420) + .padding(.top, 8) + .navigationBarHidden(false) + .navigationBarBackButtonHidden(false) + + // MARK: - Error Alert + if viewModel.showError { + VStack { + Spacer() + SnackBarView(message: viewModel.errorMessage) + } + .transition(.move(edge: .bottom)) + .onAppear { + doAfter(Theme.Timeout.snackbarMessageLongTimeout) { + viewModel.errorMessage = nil + } + } + } + } + .onFirstAppear { + Task { + await viewModel.getUserProfile() + } + } + .background( + Theme.Colors.background + .ignoresSafeArea() + ) + } +} + +struct ProfileAvatar: View { + + private var url: URL? + + init(url: String) { + if let rightUrl = URL(string: url) { + self.url = rightUrl + } else { + self.url = nil + } + } + + var body: some View { + ZStack { + Circle() + .foregroundColor(Theme.Colors.avatarStroke) + .frame(width: 104, height: 104) + KFImage(url) + .onFailureImage(CoreAssets.noCourseImage.image) + .resizable() + .scaledToFill() + .frame(width: 100, height: 100) + .cornerRadius(50) + } + } +} + +#if DEBUG +struct UserProfileView_Previews: PreviewProvider { + static var previews: some View { + + let vm = UserProfileViewModel( + interactor: ProfileInteractor.mock, + username: "demo" + ) + + return UserProfileView(viewModel: vm) + } +} +#endif diff --git a/Profile/Profile/Presentation/Profile/UserProfile/UserProfileViewModel.swift b/Profile/Profile/Presentation/Profile/UserProfile/UserProfileViewModel.swift new file mode 100644 index 000000000..6a723c800 --- /dev/null +++ b/Profile/Profile/Presentation/Profile/UserProfile/UserProfileViewModel.swift @@ -0,0 +1,52 @@ +// +// UserProfileViewModel.swift +// Discussion +// +// Created by  Stepanok Ivan on 10.10.2023. +// + +import Core +import SwiftUI + +public class UserProfileViewModel: ObservableObject { + + @Published public var userModel: UserProfile? + @Published private(set) var isShowProgress = false + @Published var showError: Bool = false + var errorMessage: String? { + didSet { + withAnimation { + showError = errorMessage != nil + } + } + } + + private let username: String + + private let interactor: ProfileInteractorProtocol + + public init( + interactor: ProfileInteractorProtocol, + username: String + ) { + self.interactor = interactor + self.username = username + } + + @MainActor + func getUserProfile(withProgress: Bool = true) async { + isShowProgress = withProgress + do { + userModel = try await interactor.getUserProfile(username: username) + isShowProgress = false + } catch let error { + isShowProgress = false + if error.isInternetError { + errorMessage = CoreLocalization.Error.slowOrNoInternetConnection + } else { + errorMessage = CoreLocalization.Error.unknownError + } + + } + } +} diff --git a/Profile/ProfileTests/Presentation/Profile/ProfileViewModelTests.swift b/Profile/ProfileTests/Presentation/Profile/ProfileViewModelTests.swift index ddc0f356f..3c9a7d0f4 100644 --- a/Profile/ProfileTests/Presentation/Profile/ProfileViewModelTests.swift +++ b/Profile/ProfileTests/Presentation/Profile/ProfileViewModelTests.swift @@ -14,6 +14,77 @@ import SwiftUI final class ProfileViewModelTests: XCTestCase { + func testGetUserProfileSuccess() async throws { + let interactor = ProfileInteractorProtocolMock() + + let viewModel = UserProfileViewModel( + interactor: interactor, + username: "Steve" + ) + + let user = UserProfile( + avatarUrl: "", + name: "Steve", + username: "Steve", + dateJoined: Date(), + yearOfBirth: 2000, + country: "Ua", + shortBiography: "Bio", + isFullProfile: false + ) + + Given(interactor, .getUserProfile(username: .value("Steve"), willReturn: user)) + + await viewModel.getUserProfile() + + Verify(interactor, 1, .getUserProfile(username: .value("Steve"))) + + XCTAssertEqual(viewModel.userModel, user) + XCTAssertFalse(viewModel.isShowProgress) + XCTAssertFalse(viewModel.showError) + XCTAssertNil(viewModel.errorMessage) + } + + func testGetUserProfileNoInternetError() async throws { + let interactor = ProfileInteractorProtocolMock() + + let viewModel = UserProfileViewModel( + interactor: interactor, + username: "Steve" + ) + + let noInternetError = AFError.sessionInvalidated(error: URLError(.notConnectedToInternet)) + + Given(interactor, .getUserProfile(username: .value("Steve"), willThrow: noInternetError)) + + await viewModel.getUserProfile() + + Verify(interactor, 1, .getUserProfile(username: .value("Steve"))) + + XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.slowOrNoInternetConnection) + XCTAssertFalse(viewModel.isShowProgress) + XCTAssertTrue(viewModel.showError) + } + + func testGetUserProfileUnknownError() async throws { + let interactor = ProfileInteractorProtocolMock() + + let viewModel = UserProfileViewModel( + interactor: interactor, + username: "Steve" + ) + + Given(interactor, .getUserProfile(username: .value("Steve"), willThrow: NSError())) + + await viewModel.getUserProfile() + + Verify(interactor, 1, .getUserProfile(username: .value("Steve"))) + + XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.unknownError) + XCTAssertFalse(viewModel.isShowProgress) + XCTAssertTrue(viewModel.showError) + } + func testGetMyProfileSuccess() async throws { let interactor = ProfileInteractorProtocolMock() let router = ProfileRouterMock() @@ -102,7 +173,7 @@ final class ProfileViewModelTests: XCTestCase { ) let noInternetError = AFError.sessionInvalidated(error: URLError(.notConnectedToInternet)) - + Given(connectivity, .isInternetAvaliable(getter: true)) Given(interactor, .getMyProfile(willThrow: noInternetError) ) @@ -204,7 +275,7 @@ final class ProfileViewModelTests: XCTestCase { Given(connectivity, .isInternetAvaliable(getter: true)) Given(interactor, .logOut(willThrow: noInternetError)) - + await viewModel.logOut() XCTAssertTrue(viewModel.showError) @@ -223,10 +294,10 @@ final class ProfileViewModelTests: XCTestCase { config: ConfigMock(), connectivity: connectivity ) - + Given(connectivity, .isInternetAvaliable(getter: true)) Given(interactor, .logOut(willThrow: NSError())) - + await viewModel.logOut() XCTAssertTrue(viewModel.showError) @@ -322,5 +393,4 @@ final class ProfileViewModelTests: XCTestCase { Verify(analytics, 1, .profileEditClicked()) } - } diff --git a/Profile/ProfileTests/ProfileMock.generated.swift b/Profile/ProfileTests/ProfileMock.generated.swift index 8ebac614f..e8f39508d 100644 --- a/Profile/ProfileTests/ProfileMock.generated.swift +++ b/Profile/ProfileTests/ProfileMock.generated.swift @@ -1738,6 +1738,22 @@ open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { + open func getUserProfile(username: String) throws -> UserProfile { + addInvocation(.m_getUserProfile__username_username(Parameter.value(`username`))) + let perform = methodPerformValue(.m_getUserProfile__username_username(Parameter.value(`username`))) as? (String) -> Void + perform?(`username`) + var __value: UserProfile + do { + __value = try methodReturnValue(.m_getUserProfile__username_username(Parameter.value(`username`))).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for getUserProfile(username: String). Use given") + Failure("Stub return value not specified for getUserProfile(username: String). Use given") + } catch { + throw error + } + return __value + } + open func getMyProfile() throws -> UserProfile { addInvocation(.m_getMyProfile) let perform = methodPerformValue(.m_getMyProfile) as? () -> Void @@ -1894,6 +1910,7 @@ open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { fileprivate enum MethodType { + case m_getUserProfile__username_username(Parameter) case m_getMyProfile case m_getMyProfileOffline case m_logOut @@ -1908,6 +1925,11 @@ open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { switch (lhs, rhs) { + case (.m_getUserProfile__username_username(let lhsUsername), .m_getUserProfile__username_username(let rhsUsername)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsUsername, rhs: rhsUsername, with: matcher), lhsUsername, rhsUsername, "username")) + return Matcher.ComparisonResult(results) + case (.m_getMyProfile, .m_getMyProfile): return .match case (.m_getMyProfileOffline, .m_getMyProfileOffline): return .match @@ -1947,6 +1969,7 @@ open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { func intValue() -> Int { switch self { + case let .m_getUserProfile__username_username(p0): return p0.intValue case .m_getMyProfile: return 0 case .m_getMyProfileOffline: return 0 case .m_logOut: return 0 @@ -1962,6 +1985,7 @@ open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { } func assertionName() -> String { switch self { + case .m_getUserProfile__username_username: return ".getUserProfile(username:)" case .m_getMyProfile: return ".getMyProfile()" case .m_getMyProfileOffline: return ".getMyProfileOffline()" case .m_logOut: return ".logOut()" @@ -1986,6 +2010,9 @@ open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { } + public static func getUserProfile(username: Parameter, willReturn: UserProfile...) -> MethodStub { + return Given(method: .m_getUserProfile__username_username(`username`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } public static func getMyProfile(willReturn: UserProfile...) -> MethodStub { return Given(method: .m_getMyProfile, products: willReturn.map({ StubProduct.return($0 as Any) })) } @@ -2031,6 +2058,16 @@ open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { willProduce(stubber) return given } + public static func getUserProfile(username: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_getUserProfile__username_username(`username`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func getUserProfile(username: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_getUserProfile__username_username(`username`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (UserProfile).self) + willProduce(stubber) + return given + } public static func getMyProfile(willThrow: Error...) -> MethodStub { return Given(method: .m_getMyProfile, products: willThrow.map({ StubProduct.throw($0) })) } @@ -2106,6 +2143,7 @@ open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { public struct Verify { fileprivate var method: MethodType + public static func getUserProfile(username: Parameter) -> Verify { return Verify(method: .m_getUserProfile__username_username(`username`))} public static func getMyProfile() -> Verify { return Verify(method: .m_getMyProfile)} public static func getMyProfileOffline() -> Verify { return Verify(method: .m_getMyProfileOffline)} public static func logOut() -> Verify { return Verify(method: .m_logOut)} @@ -2123,6 +2161,9 @@ open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { fileprivate var method: MethodType var performs: Any + public static func getUserProfile(username: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_getUserProfile__username_username(`username`), performs: perform) + } public static func getMyProfile(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_getMyProfile, performs: perform) } From c42342ea7d4811ec96426969672209bba7b930f3 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Mon, 16 Oct 2023 16:58:08 +0300 Subject: [PATCH 004/158] Add landscape mode support (#102) * Add landscape mode support * remove commented lines * Update CourseUnitView.swift Add one dot to progress * update design * resolve merge conflicts * update progress dots * bug fixes When a user swipes back from the problem screen, the app numbs and the user cannot tap. Fixed. The component name is in the center of the screen. Fixed. * code style improvements --------- Co-authored-by: stepanokdev <100592747+Stepanokdev@users.noreply.github.com> --- .../Presentation/Login/SignInView.swift | 6 +- .../Registration/SignUpView.swift | 4 + .../Reset Password/ResetPasswordView.swift | 7 +- .../Extensions/UIApplicationExtension.swift | 6 +- Core/Core/Extensions/ViewExtension.swift | 87 +++++- Core/Core/View/Base/AlertView.swift | 216 +++++++++----- .../View/Base/FlexibleKeyboardInputView.swift | 148 +++++----- Core/Core/View/Base/PickerMenu.swift | 13 +- Core/Core/View/Base/UnitButtonView.swift | 5 +- .../Details/CourseDetailsView.swift | 5 +- .../Unit/CourseNavigationView.swift | 8 +- .../Presentation/Unit/CourseUnitView.swift | 273 ++++++++++++------ .../Unit/Subviews/EncodedVideoView.swift | 7 - .../Unit/Subviews/LessonProgressView.swift | 4 +- .../Presentation/Unit/Subviews/WebView.swift | 2 +- .../Unit/Subviews/YouTubeView.swift | 10 +- .../Video/EncodedVideoPlayer.swift | 127 ++++---- .../Presentation/Video/SubtittlesView.swift | 2 +- .../Video/YouTubeVideoPlayer.swift | 93 ++---- .../Comments/Responses/ResponsesView.swift | 3 +- .../Comments/Thread/ThreadView.swift | 3 +- OpenEdX.xcodeproj/project.pbxproj | 18 +- .../xcschemes/OpenEdXDev.xcscheme | 1 + OpenEdX/AppDelegate.swift | 18 +- .../EditProfile/EditProfileView.swift | 2 +- .../EditProfile/ProfileBottomSheet.swift | 9 +- 26 files changed, 632 insertions(+), 445 deletions(-) diff --git a/Authorization/Authorization/Presentation/Login/SignInView.swift b/Authorization/Authorization/Presentation/Login/SignInView.swift index fd98fde7c..3bdab24ca 100644 --- a/Authorization/Authorization/Presentation/Login/SignInView.swift +++ b/Authorization/Authorization/Presentation/Login/SignInView.swift @@ -13,6 +13,8 @@ public struct SignInView: View { @State private var email: String = "" @State private var password: String = "" + @Environment (\.isHorizontal) private var isHorizontal + @ObservedObject private var viewModel: SignInViewModel @@ -32,7 +34,8 @@ public struct SignInView: View { CoreAssets.appLogo.swiftUIImage .resizable() .frame(maxWidth: 189, maxHeight: 54) - .padding(.vertical, 40) + .padding(.top, isHorizontal ? 20 : 40) + .padding(.bottom, isHorizontal ? 10 : 40) ScrollView { VStack { @@ -152,6 +155,7 @@ public struct SignInView: View { .hideNavigationBar() .navigationBarBackButtonHidden(true) .navigationBarHidden(true) + .ignoresSafeArea(.all, edges: .horizontal) .background(Theme.Colors.background.ignoresSafeArea(.all)) } } diff --git a/Authorization/Authorization/Presentation/Registration/SignUpView.swift b/Authorization/Authorization/Presentation/Registration/SignUpView.swift index 2ce5f263c..d4dc67acf 100644 --- a/Authorization/Authorization/Presentation/Registration/SignUpView.swift +++ b/Authorization/Authorization/Presentation/Registration/SignUpView.swift @@ -13,6 +13,8 @@ public struct SignUpView: View { @State private var disclosureGroupOpen: Bool = false + @Environment (\.isHorizontal) private var isHorizontal + @ObservedObject private var viewModel: SignUpViewModel @@ -44,6 +46,7 @@ public struct SignUpView: View { .backButtonStyle(color: .white) }) .foregroundColor(Theme.Colors.styledButtonText) + .padding(.leading, isHorizontal ? 48 : 0) }.frame(minWidth: 0, maxWidth: .infinity, @@ -135,6 +138,7 @@ public struct SignUpView: View { } } } + .ignoresSafeArea(.all, edges: .horizontal) .background(Theme.Colors.background.ignoresSafeArea(.all)) .hideNavigationBar() } diff --git a/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift b/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift index 17f7466c0..ef4d1c7fb 100644 --- a/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift +++ b/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift @@ -14,6 +14,8 @@ public struct ResetPasswordView: View { @State private var isRecovered: Bool = false + @Environment (\.isHorizontal) private var isHorizontal + @ObservedObject private var viewModel: ResetPasswordViewModel @@ -35,7 +37,7 @@ public struct ResetPasswordView: View { leftButtonColor: .white, leftButtonAction: { viewModel.router.back() - }) + }).padding(.leading, isHorizontal ? 48 : 0) ScrollView { VStack { @@ -149,7 +151,10 @@ public struct ResetPasswordView: View { } } } + .ignoresSafeArea(.all, edges: .horizontal) + .background(Theme.Colors.background.ignoresSafeArea(.all)) + .hideNavigationBar() } } diff --git a/Core/Core/Extensions/UIApplicationExtension.swift b/Core/Core/Extensions/UIApplicationExtension.swift index 616b9f466..9c6f88c6f 100644 --- a/Core/Core/Extensions/UIApplicationExtension.swift +++ b/Core/Core/Extensions/UIApplicationExtension.swift @@ -58,6 +58,10 @@ extension UINavigationController: UIGestureRecognizerDelegate { } public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { - return viewControllers.count > 1 + if #available(iOS 17, *) { + return false + } else { + return viewControllers.count > 1 + } } } diff --git a/Core/Core/Extensions/ViewExtension.swift b/Core/Core/Extensions/ViewExtension.swift index d6584cbcf..91a66ed92 100644 --- a/Core/Core/Extensions/ViewExtension.swift +++ b/Core/Core/Extensions/ViewExtension.swift @@ -89,11 +89,59 @@ public extension View { .padding(.horizontal, 48) } + @ViewBuilder func frameLimit(sizePortrait: CGFloat = 560, sizeLandscape: CGFloat = 648) -> some View { - return HStack { - Spacer(minLength: 0) - self.frame(maxWidth: UIDevice.current.orientation == .portrait ? sizePortrait : sizeLandscape) - Spacer(minLength: 0) + if UIDevice.current.userInterfaceIdiom == .pad { + HStack { + Spacer(minLength: 0) + self.frame(maxWidth: UIDevice.current.orientation.isPortrait ? sizePortrait : sizeLandscape) + Spacer(minLength: 0) + } + } else { self } + } + + @ViewBuilder + func adaptiveHStack( + spacing: CGFloat = 0, + currentOrientation: UIInterfaceOrientation, + @ViewBuilder content: () -> Content + ) -> some View { + if currentOrientation.isLandscape && UIDevice.current.userInterfaceIdiom != .pad { + VStack(alignment: .center, spacing: spacing, content: content) + } else if currentOrientation.isPortrait && UIDevice.current.userInterfaceIdiom != .pad { + HStack(spacing: spacing, content: content) + } else if UIDevice.current.userInterfaceIdiom != .phone { + HStack(spacing: spacing, content: content) + } + } + + @ViewBuilder + func adaptiveStack( + spacing: CGFloat = 0, + isHorizontal: Bool, + @ViewBuilder content: () -> Content + ) -> some View { + if isHorizontal, UIDevice.current.userInterfaceIdiom != .pad { + HStack(spacing: spacing, content: content) + } else { + VStack(alignment: .center, spacing: spacing, content: content) + } + } + + @ViewBuilder + func adaptiveNavigationStack( + spacing: CGFloat = 0, + isHorizontal: Bool, + @ViewBuilder content: () -> Content + ) -> some View { + if UIDevice.current.userInterfaceIdiom == .pad { + HStack(spacing: spacing, content: content) + } else { + if isHorizontal { + HStack(alignment: .top, spacing: spacing, content: content) + } else { + VStack(alignment: .center, spacing: spacing, content: content) + } } } @@ -118,6 +166,26 @@ public extension View { } } + func roundedBackgroundWeb( + _ color: Color = Theme.Colors.background, + strokeColor: Color = Theme.Colors.backgroundStroke, + ipadMaxHeight: CGFloat = .infinity, + maxIpadWidth: CGFloat = 420 + ) -> some View { + var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } + return VStack { + VStack {}.frame(height: 1) + ZStack { + self + .frame(maxWidth: maxIpadWidth, maxHeight: idiom == .pad ? ipadMaxHeight : .infinity) + RoundedCorners(tl: 24, tr: 24) + .stroke(style: StrokeStyle(lineWidth: 1)) + .foregroundColor(strokeColor) + .offset(y: -1) + } + } + } + func hideNavigationBar() -> some View { if #available(iOS 16.0, *) { return self.navigationBarHidden(true) @@ -199,3 +267,14 @@ public extension Image { .foregroundColor(color) } } + +public extension EnvironmentValues { + var isHorizontal: Bool { + if UIDevice.current.userInterfaceIdiom != .pad { + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene { + return windowScene.windows.first?.windowScene?.interfaceOrientation.isLandscape ?? true + } + } + return false + } +} diff --git a/Core/Core/View/Base/AlertView.swift b/Core/Core/View/Base/AlertView.swift index f230eba9b..15afa8774 100644 --- a/Core/Core/View/Base/AlertView.swift +++ b/Core/Core/View/Base/AlertView.swift @@ -33,6 +33,8 @@ public struct AlertView: View { private var nextSectionTapped: (() -> Void) = {} private let type: AlertViewType + @Environment(\.isHorizontal) private var isHorizontal + public init( alertTitle: String, alertMessage: String, @@ -68,44 +70,82 @@ public struct AlertView: View { } public var body: some View { - GeometryReader { reader in - ZStack(alignment: .center) { - Color.black.opacity(0.5) - .onTapGesture { - onCloseTapped() - } - VStack(alignment: .center, spacing: 20) { - if case let .action(_, image) = type { - image.padding(.top, 48) - } + ZStack(alignment: .center) { + Color.black.opacity(0.5) + .onTapGesture { + onCloseTapped() + } + ZStack(alignment: .topTrailing) { + adaptiveStack(spacing: isHorizontal ? 10 : 20, isHorizontal: (type == .leaveProfile && isHorizontal)) { if type == .logOut { - CoreAssets.logOut.swiftUIImage - .padding(.top, 54) + HStack { + Spacer(minLength: 100) + CoreAssets.logOut.swiftUIImage + .padding(.top, isHorizontal ? 20 : 54) + Spacer(minLength: 100) + } Text(alertMessage) .font(Theme.Fonts.titleLarge) - .padding(.vertical, 40) + .padding(.vertical, isHorizontal ? 6 : 40) .multilineTextAlignment(.center) .padding(.horizontal, 40) .frame(maxWidth: 250) } else if type == .leaveProfile { - CoreAssets.leaveProfile.swiftUIImage - .padding(.top, 54) - Text(alertTitle) - .font(Theme.Fonts.titleLarge) - .padding(.horizontal, 40) - Text(alertMessage) - .font(Theme.Fonts.bodyMedium) - .multilineTextAlignment(.center) - .padding(.horizontal, 40) + VStack(spacing: 20) { + CoreAssets.leaveProfile.swiftUIImage + .padding(.top, isHorizontal ? 20 : 54) + Text(alertTitle) + .font(Theme.Fonts.titleLarge) + .padding(.horizontal, 40) + Text(alertMessage) + .font(Theme.Fonts.bodyMedium) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + + }.padding(.bottom, 20) } else { - Text(alertTitle) - .font(Theme.Fonts.titleLarge) - .padding(.horizontal, 40) - Text(alertMessage) - .font(Theme.Fonts.bodyMedium) - .multilineTextAlignment(.center) - .padding(.horizontal, 40) - .frame(maxWidth: 250) + HStack { + VStack(alignment: .center, spacing: 10) { + if case let .action(_, image) = type { + image.padding(.top, 48) + } + Text(alertTitle) + .font(Theme.Fonts.titleLarge) + .padding(.horizontal, 40) + Text(alertMessage) + .font(Theme.Fonts.bodyMedium) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + .frame(maxWidth: 250) + } + if isHorizontal { + if case let .action(action, _) = type { + VStack(spacing: 20) { + if nextSectionName != nil { + UnitButtonView(type: .nextSection, action: { nextSectionTapped() }) + .frame(maxWidth: 215) + } + UnitButtonView(type: .custom(action), + bgColor: .clear, + action: { okTapped() }) + .frame(maxWidth: 215) + + if let nextSectionName { + Group { + Text(CoreLocalization.Courseware.nextSectionDescriptionFirst) + + Text(nextSectionName) + + Text(CoreLocalization.Courseware.nextSectionDescriptionLast) + }.frame(maxWidth: 215) + .padding(.horizontal, 40) + .multilineTextAlignment(.center) + .font(Theme.Fonts.labelSmall) + .foregroundColor(CoreAssets.textSecondary.swiftUIColor) + } + }.padding(.top, 70) + .padding(.trailing, 20) + } + } + } } HStack { switch type { @@ -116,28 +156,31 @@ public struct AlertView: View { .frame(maxWidth: 135) .saturation(0) case let .action(action, _): - VStack(spacing: 20) { - if nextSectionName != nil { - UnitButtonView(type: .nextSection, action: { nextSectionTapped() }) - .frame(maxWidth: 215) - } - UnitButtonView(type: .custom(action), - bgColor: .clear, - action: { okTapped() }) + if !isHorizontal { + VStack(spacing: 20) { + if nextSectionName != nil { + UnitButtonView(type: .nextSection, action: { nextSectionTapped() }) + .frame(maxWidth: 215) + } + UnitButtonView(type: .custom(action), + bgColor: .clear, + action: { okTapped() }) .frame(maxWidth: 215) - - if let nextSectionName { - Group { - Text(CoreLocalization.Courseware.nextSectionDescriptionFirst) + - Text(nextSectionName) + - Text(CoreLocalization.Courseware.nextSectionDescriptionLast) - }.frame(maxWidth: 215) - .padding(.horizontal, 40) - .multilineTextAlignment(.center) - .font(Theme.Fonts.labelSmall) - .foregroundColor(Theme.Colors.textSecondary) + + if let nextSectionName { + Group { + Text(CoreLocalization.Courseware.nextSectionDescriptionFirst) + + Text(nextSectionName) + + Text(CoreLocalization.Courseware.nextSectionDescriptionLast) + }.frame(maxWidth: 215) + .padding(.horizontal, 40) + .multilineTextAlignment(.center) + .font(Theme.Fonts.labelSmall) + .foregroundColor(Theme.Colors.textSecondary) + } } - + } else { + EmptyView() } case .logOut: Button(action: { @@ -199,7 +242,7 @@ public struct AlertView: View { .foregroundColor(.clear) ) .frame(maxWidth: 215) - .padding(.bottom, 24) + .padding(.bottom, isHorizontal ? 10 : 24) Button(action: { onCloseTapped() }, label: { @@ -227,33 +270,37 @@ public struct AlertView: View { .foregroundColor(Theme.Colors.textPrimary) ) .frame(maxWidth: 215) - } + }.padding(.trailing, isHorizontal ? 20 : 0) } } .padding(.top, 5) - .padding(.bottom, type.contentPadding) + .padding(.bottom, isHorizontal ? 16 : type.contentPadding) } - .background( - Theme.Shapes.cardShape - .fill(Theme.Colors.cardViewBackground) - .shadow(radius: 24) - .frame(width: reader.size.width < 420 - ? reader.size.width - 80 - : 360) - ) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(style: .init(lineWidth: 1, lineCap: .round, lineJoin: .round, miterLimit: 1)) - .foregroundColor(Theme.Colors.backgroundStroke) - .frame(width: reader.size.width < 420 - ? reader.size.width - 80 - : 360) - ) - .padding() - } - - .ignoresSafeArea() + Button(action: { + onCloseTapped() + }, label: { + Image(systemName: "xmark") + .padding(.trailing, 40) + .padding(.top, 24) + }) + + }.frame(maxWidth: type == .logOut ? 390 : nil) + .background( + Theme.Shapes.cardShape + .fill(Theme.Colors.cardViewBackground) + .shadow(radius: 24) + .fixedSize(horizontal: false, vertical: false) + ) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(style: .init(lineWidth: 1, lineCap: .round, lineJoin: .round, miterLimit: 1)) + .foregroundColor(Theme.Colors.backgroundStroke) + .fixedSize(horizontal: false, vertical: false) + ) + .frame(maxWidth: isHorizontal ? nil : 390) + .padding(40) } + .ignoresSafeArea() } } @@ -261,9 +308,9 @@ public struct AlertView: View { struct AlertView_Previews: PreviewProvider { static var previews: some View { AlertView( - alertTitle: "Warning", - alertMessage: "Something goes wrong. Do you want to exterminate your phone, right now", - nextSectionName: "Ahmad tea is a power", + alertTitle: "Congratulations!", + alertMessage: "You've passed the course", + nextSectionName: "Continue", mainAction: "Back to outline", image: CoreAssets.goodWork.swiftUIImage, onCloseTapped: {}, @@ -272,6 +319,23 @@ struct AlertView_Previews: PreviewProvider { ) .previewLayout(.sizeThatFits) .background(Color.gray) + + AlertView(alertTitle: "Comfirm log out", + alertMessage: "Are you sure you want to log out?", + positiveAction: "Yes", + onCloseTapped: {}, + okTapped: {}, + type: .logOut) + + AlertView(alertTitle: "Leave profile?", + alertMessage: "Changes you have made not be saved.", + positiveAction: "Yes", + onCloseTapped: {}, + okTapped: {}, + type: .leaveProfile) + + .previewLayout(.sizeThatFits) + .background(Color.gray) } } //swiftlint:enable all diff --git a/Core/Core/View/Base/FlexibleKeyboardInputView.swift b/Core/Core/View/Base/FlexibleKeyboardInputView.swift index e8d6d0d8a..8747a260f 100644 --- a/Core/Core/View/Base/FlexibleKeyboardInputView.swift +++ b/Core/Core/View/Base/FlexibleKeyboardInputView.swift @@ -11,6 +11,7 @@ public struct FlexibleKeyboardInputView: View { @State private var commentText: String = "" @State private var commentSize: CGFloat = .init(64) + @Environment (\.isHorizontal) private var isHorizontal public var sendText: ((String) -> Void) private let hint: String @@ -24,87 +25,82 @@ public struct FlexibleKeyboardInputView: View { public var body: some View { VStack { - Spacer() - VStack(alignment: .leading) { - - ScrollView { - HStack(alignment: .top, spacing: 6) { - Text("\(commentText) ").foregroundColor(.clear).padding(8) - .lineLimit(3) - .frame(maxWidth: .infinity) - .background( - GeometryReader { reader in - Color.clear.preference( - key: ViewSizePreferenceKey.self, - value: reader.size - ) + VStack { + Spacer() + VStack(alignment: .leading) { + + ScrollView { + HStack(alignment: .top, spacing: 6) { + Text("\(commentText) ").foregroundColor(.clear).padding(8) + .lineLimit(3) + .frame(maxWidth: .infinity) + .background( + GeometryReader { reader in + Color.clear.preference( + key: ViewSizePreferenceKey.self, + value: reader.size + ) + } + ) + .onPreferenceChange(ViewSizePreferenceKey.self) { size in + commentSize = size.height } - ) - .onPreferenceChange(ViewSizePreferenceKey.self) { size in - commentSize = size.height - } - .overlay( - TextEditor(text: $commentText) - .padding(.horizontal, 8) - .foregroundColor(Theme.Colors.textPrimary) - .hideScrollContentBackground() - .frame(maxHeight: commentSize) - .background( - ZStack(alignment: .leading) { + .overlay( + TextEditor(text: $commentText) + .padding(.horizontal, 8) + .foregroundColor(Theme.Colors.textPrimary) + .hideScrollContentBackground() + .frame(maxHeight: commentSize) + .background( + ZStack(alignment: .leading) { + Theme.Shapes.textInputShape + .fill(Theme.Colors.textInputBackground) + Text(commentText.count == 0 ? hint : "") + .foregroundColor(Theme.Colors.textSecondary) + .font(Theme.Fonts.labelLarge) + .padding(.leading, 14) + } + ) + .overlay( Theme.Shapes.textInputShape - .fill(Theme.Colors.textInputBackground) - Text(commentText.count == 0 ? hint : "") - .foregroundColor(Theme.Colors.textSecondary) - .font(Theme.Fonts.labelLarge) - .padding(.leading, 14) - } - ) - .overlay( - Theme.Shapes.textInputShape - .stroke(lineWidth: 1) - .fill( - Theme.Colors.textInputStroke - ) - ) - ).padding(8) - Button(action: { - if commentText.trimmingCharacters(in: .whitespacesAndNewlines).count > 0 { - sendText(commentText) - self.commentText = "" - } - }, label: { - VStack { + .stroke(lineWidth: 1) + .fill( + Theme.Colors.textInputStroke + ) + ) + ).padding(8) + Button(action: { if commentText.trimmingCharacters(in: .whitespacesAndNewlines).count > 0 { - ZStack { - Circle() - .frame(width: 36, height: 36) - .foregroundColor(.accentColor) - CoreAssets.send.swiftUIImage - .offset(y: 1) - } - } else { - CoreAssets.sendDisabled.swiftUIImage + sendText(commentText) + self.commentText = "" } - } - .frame(width: 36, height: 36) - .foregroundColor(.white) - }).padding(.top, 8) - } - } - .padding(.leading, 6) - .padding(.trailing, 14) - }.frame(maxWidth: .infinity, maxHeight: commentSize + 16) - .background( - Theme.Colors.commentCellBackground - .ignoresSafeArea() - ) - .overlay( - GeometryReader { proxy in - Rectangle() - .size(width: proxy.size.width, height: 1) - .foregroundColor(Theme.Colors.cardViewStroke) + }, label: { + VStack { + commentText.trimmingCharacters(in: .whitespacesAndNewlines).count > 0 + ? CoreAssets.send.swiftUIImage + : CoreAssets.sendDisabled.swiftUIImage + } + .frame(width: 36, height: 36) + .foregroundColor(.white) + }).padding(.top, 8) + + }.padding(.horizontal, isHorizontal ? 50 : 16) + } - ) + .padding(.leading, 6) + .padding(.trailing, 14) + }.frame(maxWidth: .infinity, maxHeight: commentSize + 16) + .background( + Theme.Colors.commentCellBackground + ) + .overlay( + GeometryReader { proxy in + Rectangle() + .size(width: proxy.size.width, height: 1) + .foregroundColor(Theme.Colors.cardViewStroke) + } + ) + } } } } diff --git a/Core/Core/View/Base/PickerMenu.swift b/Core/Core/View/Base/PickerMenu.swift index a967ffdde..09271213c 100644 --- a/Core/Core/View/Base/PickerMenu.swift +++ b/Core/Core/View/Base/PickerMenu.swift @@ -25,6 +25,7 @@ public struct PickerMenu: View { @State private var search: String = "" @State public var selectedItem: PickerItem = PickerItem(key: "", value: "") + @Environment (\.isHorizontal) private var isHorizontal private let ipadPickerWidth: CGFloat = 300 private var items: [PickerItem] private let titleText: String @@ -90,7 +91,11 @@ public struct PickerMenu: View { } .pickerStyle(.wheel) } - .frame(minWidth: 0, maxWidth: idiom == .pad ? ipadPickerWidth : .infinity) + .frame(minWidth: 0, + maxWidth: (idiom == .pad || (idiom == .phone && isHorizontal)) + ? ipadPickerWidth + : .infinity) + .padding() .background(Theme.Colors.textInputBackground.cornerRadius(16)) .padding(.horizontal, 16) @@ -106,13 +111,17 @@ public struct PickerMenu: View { }) { Text(CoreLocalization.Picker.accept) .foregroundColor(Theme.Colors.textPrimary) - .frame(minWidth: 0, maxWidth: idiom == .pad ? ipadPickerWidth : .infinity) + .frame(minWidth: 0, + maxWidth: (idiom == .pad || (idiom == .phone && isHorizontal)) + ? ipadPickerWidth + : .infinity) .padding() .background(Theme.Colors.textInputBackground.cornerRadius(16)) .padding(.horizontal, 16) } .padding(.bottom, 4) .disabled(acceptButtonDisabled) + } .avoidKeyboard(dismissKeyboardByTap: true) .transition(.move(edge: .bottom)) diff --git a/Core/Core/View/Base/UnitButtonView.swift b/Core/Core/View/Base/UnitButtonView.swift index 67a49d0da..e6d658c49 100644 --- a/Core/Core/View/Base/UnitButtonView.swift +++ b/Core/Core/View/Base/UnitButtonView.swift @@ -99,12 +99,13 @@ public struct UnitButtonView: View { HStack { Text(type.stringValue()) .foregroundColor(Theme.Colors.styledButtonText) - .padding(.leading, 16) + .padding(.leading, 8) .font(Theme.Fonts.labelLarge) + .scaledToFit() Spacer() CoreAssets.check.swiftUIImage.renderingMode(.template) .foregroundColor(Theme.Colors.styledButtonText) - .padding(.trailing, 16) + .padding(.trailing, 8) } case .finish: HStack { diff --git a/Course/Course/Presentation/Details/CourseDetailsView.swift b/Course/Course/Presentation/Details/CourseDetailsView.swift index 168dd8de1..2b846d526 100644 --- a/Course/Course/Presentation/Details/CourseDetailsView.swift +++ b/Course/Course/Presentation/Details/CourseDetailsView.swift @@ -14,6 +14,7 @@ public struct CourseDetailsView: View { @ObservedObject private var viewModel: CourseDetailsViewModel @Environment(\.colorScheme) var colorScheme + @Environment(\.isHorizontal) var isHorizontal @State private var isOverviewRendering = true private var title: String private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } @@ -54,7 +55,7 @@ public struct CourseDetailsView: View { if let courseDetails = viewModel.courseDetails { // MARK: - iPad - if idiom == .pad && viewModel.isHorisontal { + if viewModel.isHorisontal { HStack(alignment: .top) { VStack(alignment: .leading) { @@ -303,7 +304,7 @@ private struct CourseBannerView: View { .onFailureImage(CoreAssets.noCourseImage.image) .resizable() .aspectRatio(16/9, contentMode: .fill) - .frame(width: idiom == .pad ? 312 : proxy.size.width - 12) + .frame(width: 312) .opacity(animate ? 1 : 0) .onAppear { withAnimation(.linear(duration: 0.5)) { diff --git a/Course/Course/Presentation/Unit/CourseNavigationView.swift b/Course/Course/Presentation/Unit/CourseNavigationView.swift index ba9d34a5a..505d865e0 100644 --- a/Course/Course/Presentation/Unit/CourseNavigationView.swift +++ b/Course/Course/Presentation/Unit/CourseNavigationView.swift @@ -59,7 +59,7 @@ struct CourseNavigationView: View { if viewModel.verticals.count > viewModel.verticalIndex + 1 { return viewModel.verticals[viewModel.verticalIndex + 1].displayName } else if sequentials.count > viewModel.sequentialIndex + 1 { - return sequentials[viewModel.sequentialIndex + 1].childs.first?.displayName + return sequentials[viewModel.sequentialIndex + 1].childs.first?.displayName ?? "" } else if chapters.count > viewModel.chapterIndex + 1 { return chapters[viewModel.chapterIndex + 1].childs.first?.childs.first?.displayName } else { @@ -72,7 +72,7 @@ struct CourseNavigationView: View { okTapped: { playerStateSubject.send(VideoPlayerState.pause) playerStateSubject.send(VideoPlayerState.kill) - + viewModel.trackFinishVerticalBackToOutlineClicked() viewModel.router.dismiss(animated: false) viewModel.router.back(animated: true) @@ -122,6 +122,7 @@ struct CourseNavigationView: View { sequentialIndex: sequentialIndex) } ) + playerStateSubject.send(VideoPlayerState.pause) viewModel.analytics.finishVerticalClicked( courseId: viewModel.courseID, courseName: viewModel.courseName, @@ -143,8 +144,7 @@ struct CourseNavigationView: View { }) } } - }.frame(minWidth: 0, maxWidth: .infinity) - .padding(.horizontal, 24) + }.padding(.horizontal, 24) } } diff --git a/Course/Course/Presentation/Unit/CourseUnitView.swift b/Course/Course/Presentation/Unit/CourseUnitView.swift index 411d8ac15..484fdda9f 100644 --- a/Course/Course/Presentation/Unit/CourseUnitView.swift +++ b/Course/Course/Presentation/Unit/CourseUnitView.swift @@ -26,7 +26,7 @@ public struct CourseUnitView: View { @State var offsetView: CGFloat = 0 @State var showDiscussion: Bool = false @Environment(\.presentationMode) private var presentationMode - + @Environment(\.isHorizontal) private var isHorizontal private let sectionName: String public let playerStateSubject = CurrentValueSubject(nil) @@ -44,67 +44,74 @@ public struct CourseUnitView: View { ZStack(alignment: .bottom) { GeometryReader { reader in VStack(spacing: 0) { - VStack {}.frame(height: 100) - LazyVStack(spacing: 0) { - let data = Array(viewModel.verticals[viewModel.verticalIndex].childs.enumerated()) - ForEach(data, id: \.offset) { index, block in - VStack(spacing: 0) { - if index >= viewModel.index - 1 && index <= viewModel.index + 1 { - switch LessonType.from(block) { - // MARK: YouTube - case let .youtube(url, blockID): - if viewModel.connectivity.isInternetAvaliable { - YouTubeView( - name: block.displayName, - url: url, - courseID: viewModel.courseID, - blockID: blockID, - playerStateSubject: playerStateSubject, - languages: block.subtitles ?? [], - isOnScreen: index == viewModel.index - ).frameLimit() - Spacer(minLength: 100) - } else { - NoInternetView(playerStateSubject: playerStateSubject) - } - // MARK: Encoded Video - case let .video(encodedUrl, blockID): - let url = viewModel.urlForVideoFileOrFallback( - blockId: blockID, - url: encodedUrl - ) - if viewModel.connectivity.isInternetAvaliable || url?.isFileURL == true { - EncodedVideoView( - name: block.displayName, - url: url, - courseID: viewModel.courseID, - blockID: blockID, - playerStateSubject: playerStateSubject, - languages: block.subtitles ?? [], - isOnScreen: index == viewModel.index - ).frameLimit() - Spacer(minLength: 100) - } else { - NoInternetView(playerStateSubject: playerStateSubject) + VStack {CoreAssets.background.swiftUIColor}.frame(width: reader.size.width, + height: isHorizontal ? 75 : 50) + LazyVStack(alignment: .leading, spacing: 0) { + let data = Array(viewModel.verticals[viewModel.verticalIndex].childs.enumerated()) + ForEach(data, id: \.offset) { index, block in + VStack(spacing: 0) { + if index >= viewModel.index - 1 && index <= viewModel.index + 1 { + switch LessonType.from(block) { + // MARK: YouTube + case let .youtube(url, blockID): + if viewModel.connectivity.isInternetAvaliable { + YouTubeView( + name: block.displayName, + url: url, + courseID: viewModel.courseID, + blockID: blockID, + playerStateSubject: playerStateSubject, + languages: block.subtitles ?? [], + isOnScreen: index == viewModel.index + ).frameLimit() + + if !isHorizontal { + Spacer(minLength: 150) } - // MARK: Web - case .web(let url): - if viewModel.connectivity.isInternetAvaliable { - WebView(url: url, viewModel: viewModel) - } else { - NoInternetView(playerStateSubject: playerStateSubject) + } else { + NoInternetView(playerStateSubject: playerStateSubject) + } + // MARK: Encoded Video + case let .video(encodedUrl, blockID): + let url = viewModel.urlForVideoFileOrFallback( + blockId: blockID, + url: encodedUrl + ) + if viewModel.connectivity.isInternetAvaliable || url?.isFileURL == true { + EncodedVideoView( + name: block.displayName, + url: url, + courseID: viewModel.courseID, + blockID: blockID, + playerStateSubject: playerStateSubject, + languages: block.subtitles ?? [], + isOnScreen: index == viewModel.index + ).frameLimit() + + if !isHorizontal { + Spacer(minLength: 150) } - // MARK: Unknown - case .unknown(let url): - if viewModel.connectivity.isInternetAvaliable { + } else { + NoInternetView(playerStateSubject: playerStateSubject) + } + // MARK: Web + case .web(let url): + if viewModel.connectivity.isInternetAvaliable { + WebView(url: url, viewModel: viewModel) + } else { + NoInternetView(playerStateSubject: playerStateSubject) + } + // MARK: Unknown + case .unknown(let url): + if viewModel.connectivity.isInternetAvaliable { UnknownView(url: url, viewModel: viewModel) Spacer() - } else { - NoInternetView(playerStateSubject: playerStateSubject) - } - // MARK: Discussion - case let .discussion(blockID, blockKey, title): - if viewModel.connectivity.isInternetAvaliable { + } else { + NoInternetView(playerStateSubject: playerStateSubject) + } + // MARK: Discussion + case let .discussion(blockID, blockKey, title): + if viewModel.connectivity.isInternetAvaliable { VStack { if showDiscussion { DiscussionView( @@ -121,40 +128,70 @@ public struct CourseUnitView: View { } } }.frameLimit() - } else { - NoInternetView(playerStateSubject: playerStateSubject) - } + } else { + NoInternetView(playerStateSubject: playerStateSubject) } - } else { - EmptyView() } + } else { + EmptyView() } - .frame(height: reader.size.height) - .id(index) } + .frame( + width: isHorizontal ? reader.size.width - 16 : reader.size.width, + height: reader.size.height + ) + .id(index) } - .offset(y: offsetView) - .clipped() - .onChange(of: viewModel.index, perform: { index in - DispatchQueue.main.async { - withAnimation(Animation.easeInOut(duration: 0.2)) { - offsetView = -(reader.size.height * CGFloat(index)) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - showDiscussion = viewModel.selectedLesson().type == .discussion - } + } + .offset(y: offsetView) + .clipped() + .onAppear { + offsetView = -(reader.size.height * CGFloat(viewModel.index)) + } + .onAppear { + NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillShowNotification, + object: nil, queue: .main) { _ in + offsetView = -(reader.size.height * CGFloat(viewModel.index)) + } + NotificationCenter.default.addObserver(forName: UIResponder.keyboardDidShowNotification, + object: nil, queue: .main) { _ in + offsetView = -(reader.size.height * CGFloat(viewModel.index)) + } + NotificationCenter.default.addObserver(forName: UIResponder.keyboardDidHideNotification, + object: nil, queue: .main) { _ in + offsetView = -(reader.size.height * CGFloat(viewModel.index)) + } + } + .onChange(of: UIDevice.current.orientation, perform: { _ in + offsetView = -(reader.size.height * CGFloat(viewModel.index)) + }) + .onChange(of: viewModel.verticalIndex, perform: { index in + DispatchQueue.main.async { + withAnimation(Animation.easeInOut(duration: 0.2)) { + offsetView = -(reader.size.height * CGFloat(index)) + } + } + + }) + .onChange(of: viewModel.index, perform: { index in + DispatchQueue.main.async { + withAnimation(Animation.easeInOut(duration: 0.2)) { + offsetView = -(reader.size.height * CGFloat(index)) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + showDiscussion = viewModel.selectedLesson().type == .discussion } } - - }) + } + + }) }.frame(maxWidth: .infinity) .clipped() // MARK: Progress Dots - if viewModel.verticals[viewModel.verticalIndex].childs.count > 1 { LessonProgressView(viewModel: viewModel) - } } + // MARK: - Alert if showAlert { ZStack(alignment: .bottomLeading) { @@ -179,17 +216,59 @@ public struct CourseUnitView: View { // MARK: - Course Navigation VStack { - CourseNavigationView( - sectionName: sectionName, - viewModel: viewModel, - playerStateSubject: playerStateSubject - ).padding(.bottom, 30) - .frameLimit(sizePortrait: 420) - }.frame(maxWidth: .infinity) - .onRightSwipeGesture { - playerStateSubject.send(VideoPlayerState.kill) - viewModel.router.back() + ZStack { + GeometryReader { reader in + VStack { + HStack { + let currentBlock = viewModel.verticals[viewModel.verticalIndex] + .childs[viewModel.index] + if currentBlock.type == .video { + let title = currentBlock.displayName + Text(title) + .lineLimit(1) + .font(Theme.Fonts.titleLarge) + .foregroundStyle(Theme.Colors.textPrimary) + .padding(.leading, isHorizontal ? 30 : 42) + .padding(.top, isHorizontal ? 14 : 2) + Spacer() + } + }.frame(maxWidth: isHorizontal ? reader.size.width * 0.5 : nil) + Spacer() + } + } + VStack { + NavigationBar( + title: "", + leftButtonAction: { + viewModel.router.back() + playerStateSubject.send(VideoPlayerState.kill) + }).padding(.top, isHorizontal ? 10 : 0) + .padding(.leading, isHorizontal ? -16 : 0) + Spacer() + } + HStack(alignment: .center) { + if isHorizontal { + Spacer() + } + VStack { + if !isHorizontal { + Spacer() + } + CourseNavigationView( + sectionName: sectionName, + viewModel: viewModel, + playerStateSubject: playerStateSubject + ) + if isHorizontal { + Spacer() + } + }//.frame(height: isHorizontal ? nil : 44) + + .padding(.bottom, isHorizontal ? 0 : 50) + .padding(.top, isHorizontal ? 12 : 0) + }.frameLimit(sizePortrait: 420) } + }.frame(maxWidth: .infinity) } .onDisappear { if !presentationMode.wrappedValue.isPresented { @@ -197,19 +276,23 @@ public struct CourseUnitView: View { } } } + .ignoresSafeArea(.all, edges: .bottom) + .onRightSwipeGesture { + playerStateSubject.send(VideoPlayerState.kill) + viewModel.router.back() + } .onAppear { DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { showDiscussion = viewModel.selectedLesson().type == .discussion } } - .navigationBarHidden(false) - .navigationBarBackButtonHidden(false) + .navigationBarHidden(true) + .navigationBarBackButtonHidden(true) .navigationTitle("") - .ignoresSafeArea() - .background( - Theme.Colors.background - .ignoresSafeArea() - ) + .background( + Theme.Colors.background + .ignoresSafeArea() + ) } } diff --git a/Course/Course/Presentation/Unit/Subviews/EncodedVideoView.swift b/Course/Course/Presentation/Unit/Subviews/EncodedVideoView.swift index 1bdc629fa..d790664cd 100644 --- a/Course/Course/Presentation/Unit/Subviews/EncodedVideoView.swift +++ b/Course/Course/Presentation/Unit/Subviews/EncodedVideoView.swift @@ -21,11 +21,6 @@ struct EncodedVideoView: View { let isOnScreen: Bool var body: some View { - VStack(alignment: .leading, spacing: 8) { - Text(name) - .font(Theme.Fonts.titleLarge) - .padding(.horizontal, 24) - let vm = Container.shared.resolve( EncodedVideoPlayerViewModel.self, arguments: url, @@ -35,7 +30,5 @@ struct EncodedVideoView: View { playerStateSubject )! EncodedVideoPlayer(viewModel: vm, isOnScreen: isOnScreen) - Spacer(minLength: 100) - } } } diff --git a/Course/Course/Presentation/Unit/Subviews/LessonProgressView.swift b/Course/Course/Presentation/Unit/Subviews/LessonProgressView.swift index 57a881589..da2010150 100644 --- a/Course/Course/Presentation/Unit/Subviews/LessonProgressView.swift +++ b/Course/Course/Presentation/Unit/Subviews/LessonProgressView.swift @@ -11,6 +11,8 @@ import Core struct LessonProgressView: View { @ObservedObject var viewModel: CourseUnitViewModel + @Environment (\.isHorizontal) private var isHorizontal + init(viewModel: CourseUnitViewModel) { self.viewModel = viewModel } @@ -36,7 +38,7 @@ struct LessonProgressView: View { } Spacer() } - .padding(.trailing, 6) + .padding(.trailing, isHorizontal ? 0 : 6) } } } diff --git a/Course/Course/Presentation/Unit/Subviews/WebView.swift b/Course/Course/Presentation/Unit/Subviews/WebView.swift index 9cdc59269..5f1d45388 100644 --- a/Course/Course/Presentation/Unit/Subviews/WebView.swift +++ b/Course/Course/Presentation/Unit/Subviews/WebView.swift @@ -18,6 +18,6 @@ struct WebView: View { WebUnitView(url: url, viewModel: Container.shared.resolve(WebUnitViewModel.self)!) Spacer(minLength: 5) } - .roundedBackground(strokeColor: .clear, maxIpadWidth: .infinity) + .roundedBackgroundWeb(strokeColor: Theme.Colors.textInputUnfocusedStroke, maxIpadWidth: .infinity) } } diff --git a/Course/Course/Presentation/Unit/Subviews/YouTubeView.swift b/Course/Course/Presentation/Unit/Subviews/YouTubeView.swift index 94080fc32..49f1cfb3d 100644 --- a/Course/Course/Presentation/Unit/Subviews/YouTubeView.swift +++ b/Course/Course/Presentation/Unit/Subviews/YouTubeView.swift @@ -21,12 +21,6 @@ struct YouTubeView: View { let isOnScreen: Bool var body: some View { - VStack(alignment: .leading, spacing: 8) { - VStack(alignment: .leading) { - Text(name) - .font(Theme.Fonts.titleLarge) - .padding(.horizontal, 24) - let vm = Container.shared.resolve( YouTubeVideoPlayerViewModel.self, arguments: url, @@ -36,8 +30,6 @@ struct YouTubeView: View { playerStateSubject )! YouTubeVideoPlayer(viewModel: vm, isOnScreen: isOnScreen) - Spacer(minLength: 100) - }.background(Theme.Colors.background) - } + .background(Theme.Colors.background) } } diff --git a/Course/Course/Presentation/Video/EncodedVideoPlayer.swift b/Course/Course/Presentation/Video/EncodedVideoPlayer.swift index 0afcfba01..233469250 100644 --- a/Course/Course/Presentation/Video/EncodedVideoPlayer.swift +++ b/Course/Course/Presentation/Video/EncodedVideoPlayer.swift @@ -41,6 +41,8 @@ public struct EncodedVideoPlayer: View { } } + @Environment(\.isHorizontal) private var isHorizontal + public init( viewModel: EncodedVideoPlayerViewModel, isOnScreen: Bool @@ -51,83 +53,70 @@ public struct EncodedVideoPlayer: View { public var body: some View { ZStack { - VStack(alignment: .leading) { - PlayerViewController( - videoURL: viewModel.url, - controller: viewModel.controller, - progress: { progress in - if progress >= 0.8 { - if !isViewedOnce { - Task { - await viewModel.blockCompletionRequest() - } - isViewedOnce = true + GeometryReader {reader in + VStack { + HStack { + VStack { + PlayerViewController( + videoURL: viewModel.url, + controller: viewModel.controller, + progress: { progress in + if progress >= 0.8 { + if !isViewedOnce { + Task { + await viewModel.blockCompletionRequest() + } + isViewedOnce = true + } + } + }, seconds: { seconds in + currentTime = seconds + }) + .aspectRatio(16 / 9, contentMode: .fit) + .frame(minWidth: isHorizontal ? reader.size.width * 0.6 : 380) + .cornerRadius(12) + if isHorizontal { + Spacer() } } - }, seconds: { seconds in - if !pause { - currentTime = seconds - } - }) - .aspectRatio(16 / 9, contentMode: .fit) - .cornerRadius(12) - .padding(.horizontal, 6) - .onReceive(NotificationCenter.Publisher( - center: .default, - name: UIDevice.orientationDidChangeNotification) - ) { _ in - if isOnScreen { - self.orientation = UIDevice.current.orientation - if self.orientation.isLandscape { - viewModel.controller.enterFullScreen(animated: true) - viewModel.controller.player?.play() - isOrientationChanged = true - } else { - if isOrientationChanged { - viewModel.controller.exitFullScreen(animated: true) - viewModel.controller.player?.pause() - isOrientationChanged = false - } + if isHorizontal { + SubtittlesView( + languages: viewModel.languages, + currentTime: $currentTime, + viewModel: viewModel, + scrollTo: { date in + viewModel.controller.player?.seek( + to: CMTime( + seconds: date.secondsSinceMidnight(), + preferredTimescale: 10000 + ) + ) + pauseScrolling() + currentTime = (date.secondsSinceMidnight() + 1) + }) } } - } - SubtittlesView(languages: viewModel.languages, - currentTime: $currentTime, - viewModel: viewModel, scrollTo: { date in - viewModel.controller.player?.seek(to: CMTime(seconds: date.secondsSinceMidnight(), - preferredTimescale: 10000)) - pauseScrolling() - currentTime = (date.secondsSinceMidnight() + 1) - }) - Spacer() - if !orientation.isLandscape || idiom != .pad { - VStack {}.onAppear { - isLoading = false - alertMessage = CourseLocalization.Alert.rotateDevice - } - } - } - - // MARK: - Alert - if showAlert, let alertMessage { - VStack(alignment: .center) { - Spacer() - HStack(spacing: 6) { - CoreAssets.rotateDevice.swiftUIImage.renderingMode(.template) - Text(alertMessage) - }.shadowCardStyle(bgColor: Theme.Colors.snackbarInfoAlert, - textColor: .white) - .transition(.move(edge: .bottom)) - .onAppear { - doAfter(Theme.Timeout.snackbarMessageLongTimeout) { - self.alertMessage = nil - showAlert = false - } + if !isHorizontal { + SubtittlesView( + languages: viewModel.languages, + currentTime: $currentTime, + viewModel: viewModel, + scrollTo: { date in + viewModel.controller.player?.seek( + to: CMTime( + seconds: date.secondsSinceMidnight(), + preferredTimescale: 10000 + ) + ) + pauseScrolling() + currentTime = (date.secondsSinceMidnight() + 1) + }) } } } - } + }.padding(.horizontal, isHorizontal ? 0 : 8) } + private func pauseScrolling() { pause = true DispatchQueue.main.asyncAfter(deadline: .now() + 2) { diff --git a/Course/Course/Presentation/Video/SubtittlesView.swift b/Course/Course/Presentation/Video/SubtittlesView.swift index eb3496bdc..e7dca9735 100644 --- a/Course/Course/Presentation/Video/SubtittlesView.swift +++ b/Course/Course/Presentation/Video/SubtittlesView.swift @@ -96,7 +96,7 @@ public struct SubtittlesView: View { })) } }.padding(.horizontal, 24) - .padding(.top, 34) + .padding(.top, 16) } } diff --git a/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift b/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift index 1afa10778..9d12b3183 100644 --- a/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift +++ b/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift @@ -27,79 +27,50 @@ public struct YouTubeVideoPlayer: View { } } + @Environment(\.isHorizontal) private var isHorizontal + public init(viewModel: YouTubeVideoPlayerViewModel, isOnScreen: Bool) { self._viewModel = StateObject(wrappedValue: { viewModel }()) self.isOnScreen = isOnScreen } public var body: some View { - ZStack { - VStack { - YouTubePlayerView( - viewModel.youtubePlayer, - transaction: .init(animation: .easeIn), - overlay: { _ in }) - .onAppear { - alertMessage = CourseLocalization.Alert.rotateDevice - } - .cornerRadius(12) - .padding(.horizontal, 6) - .aspectRatio(16 / 8.8, contentMode: .fit) - .onReceive(NotificationCenter.Publisher( - center: .default, name: UIDevice.orientationDidChangeNotification - )) { _ in - if isOnScreen { - let orientation = UIDevice.current.orientation - if orientation.isPortrait { - viewModel.youtubePlayer.update(configuration: YouTubePlayer.Configuration(configure: { - $0.playInline = true - $0.autoPlay = viewModel.play - $0.startTime = Int(viewModel.currentTime) - })) - } else { - viewModel.youtubePlayer.update(configuration: YouTubePlayer.Configuration(configure: { - $0.playInline = false - $0.autoPlay = true - $0.startTime = Int(viewModel.currentTime) - })) + ZStack { + GeometryReader { reader in + adaptiveStack(isHorizontal: isHorizontal) { + VStack { + YouTubePlayerView( + viewModel.youtubePlayer, + transaction: .init(animation: .easeIn), + overlay: { _ in }) + .onAppear { + alertMessage = CourseLocalization.Alert.rotateDevice + } + .cornerRadius(12) + .padding(.horizontal, isHorizontal ? 0 : 8) + .aspectRatio(16 / 8.8, contentMode: .fit) + .frame(minWidth: isHorizontal ? reader.size.width * 0.6 : 380) + // Adjust the width based on the horizontal state + if isHorizontal { + Spacer() + } } + SubtittlesView( + languages: viewModel.languages, + currentTime: $viewModel.currentTime, + viewModel: viewModel, scrollTo: { date in + viewModel.youtubePlayer.seek(to: date.secondsSinceMidnight(), allowSeekAhead: true) + viewModel.pauseScrolling() + viewModel.currentTime = date.secondsSinceMidnight() + 1 + } + ) } } - SubtittlesView( - languages: viewModel.languages, - currentTime: $viewModel.currentTime, - viewModel: viewModel, scrollTo: { date in - viewModel.youtubePlayer.seek(to: date.secondsSinceMidnight(), allowSeekAhead: true) - viewModel.pauseScrolling() - viewModel.currentTime = date.secondsSinceMidnight() + 1 - } - ) - } - - if viewModel.isLoading { - ProgressBar(size: 40, lineWidth: 8) - } - - // MARK: - Alert - if showAlert, let alertMessage { - VStack(alignment: .center) { - Spacer() - HStack(spacing: 6) { - CoreAssets.rotateDevice.swiftUIImage.renderingMode(.template) - Text(alertMessage) - }.shadowCardStyle(bgColor: Theme.Colors.snackbarInfoAlert, - textColor: .white) - .transition(.move(edge: .bottom)) - .onAppear { - doAfter(Theme.Timeout.snackbarMessageLongTimeout) { - self.alertMessage = nil - showAlert = false - } - } + if viewModel.isLoading { + ProgressBar(size: 40, lineWidth: 8) } } } - } } #if DEBUG diff --git a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift index fe0e43058..54423455a 100644 --- a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift +++ b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift @@ -162,7 +162,7 @@ public struct ResponsesView: View { } } } - ) + ).ignoresSafeArea(.all, edges: .horizontal) } } } @@ -197,6 +197,7 @@ public struct ResponsesView: View { } } } + .ignoresSafeArea(.all, edges: .horizontal) .navigationBarHidden(false) .navigationBarBackButtonHidden(false) .navigationTitle(title) diff --git a/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift b/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift index 0ca873e41..9be26d613 100644 --- a/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift +++ b/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift @@ -162,7 +162,7 @@ public struct ThreadView: View { } } } - ) + ).ignoresSafeArea(.all, edges: .horizontal) } } .onReceive(viewModel.addPostSubject, perform: { newComment in @@ -217,6 +217,7 @@ public struct ThreadView: View { } } } + .ignoresSafeArea(.all, edges: .horizontal) .navigationBarHidden(false) .navigationBarBackButtonHidden(false) .navigationTitle(title) diff --git a/OpenEdX.xcodeproj/project.pbxproj b/OpenEdX.xcodeproj/project.pbxproj index 8aecb5c84..4fc81e931 100644 --- a/OpenEdX.xcodeproj/project.pbxproj +++ b/OpenEdX.xcodeproj/project.pbxproj @@ -509,8 +509,7 @@ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; - INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -598,8 +597,7 @@ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; - INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -693,8 +691,7 @@ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; - INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -782,8 +779,7 @@ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; - INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -931,8 +927,7 @@ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; - INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -966,8 +961,7 @@ INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; - INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", diff --git a/OpenEdX.xcodeproj/xcshareddata/xcschemes/OpenEdXDev.xcscheme b/OpenEdX.xcodeproj/xcshareddata/xcschemes/OpenEdXDev.xcscheme index 3a38de2f5..c2f6ffa2b 100644 --- a/OpenEdX.xcodeproj/xcshareddata/xcschemes/OpenEdXDev.xcscheme +++ b/OpenEdX.xcodeproj/xcshareddata/xcschemes/OpenEdXDev.xcscheme @@ -103,6 +103,7 @@ buildConfiguration = "DebugDev" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + language = "uk" launchStyle = "0" useCustomWorkingDirectory = "NO" ignoresPersistentStateOnLaunch = "NO" diff --git a/OpenEdX/AppDelegate.swift b/OpenEdX/AppDelegate.swift index 3c3cad303..bb7c92a3b 100644 --- a/OpenEdX/AppDelegate.swift +++ b/OpenEdX/AppDelegate.swift @@ -21,9 +21,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } var window: UIWindow? - - private var orientationLock: UIInterfaceOrientationMask = .portrait - + private var assembler: Assembler? private var lastForceLogoutTime: TimeInterval = 0 @@ -54,19 +52,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { return true } - - func application( - _ application: UIApplication, - supportedInterfaceOrientationsFor window: UIWindow? - ) -> UIInterfaceOrientationMask { - //Allows external windows, such as WebView Player, to work in any orientation - if window == self.window { - return UIDevice.current.userInterfaceIdiom == .phone ? orientationLock : .all - } else { - return UIDevice.current.userInterfaceIdiom == .phone ? .allButUpsideDown : .all - } - } - + private func initDI() { let navigation = UINavigationController() navigation.modalPresentationStyle = .fullScreen diff --git a/Profile/Profile/Presentation/EditProfile/EditProfileView.swift b/Profile/Profile/Presentation/EditProfile/EditProfileView.swift index f0a439d48..5e0530e48 100644 --- a/Profile/Profile/Presentation/EditProfile/EditProfileView.swift +++ b/Profile/Profile/Presentation/EditProfile/EditProfileView.swift @@ -209,7 +209,7 @@ public struct EditProfileView: View { CoreAssets.arrowLeft.swiftUIImage .renderingMode(.template) .foregroundColor(Theme.Colors.accentColor) - }).opacity(viewModel.isChanged ? 1 : 0.3) + }) }) ToolbarItem(placement: .navigationBarTrailing, content: { Button(action: { diff --git a/Profile/Profile/Presentation/EditProfile/ProfileBottomSheet.swift b/Profile/Profile/Presentation/EditProfile/ProfileBottomSheet.swift index 9a3f09330..00a6c13f6 100644 --- a/Profile/Profile/Presentation/EditProfile/ProfileBottomSheet.swift +++ b/Profile/Profile/Presentation/EditProfile/ProfileBottomSheet.swift @@ -38,6 +38,8 @@ struct ProfileBottomSheet: View { private var removePhoto: () -> Void @Binding private var showingBottomSheet: Bool + @Environment (\.isHorizontal) private var isHorizontal + init( showingBottomSheet: Binding, openGallery: @escaping () -> Void, @@ -96,7 +98,12 @@ struct ProfileBottomSheet: View { }).padding(.top, 34) }.padding(.horizontal, 24) - }.frame(maxWidth: idiom == .pad ? 330 : .infinity, maxHeight: 290, alignment: .topLeading) + } + .frame(minWidth: 0, + maxWidth: (idiom == .pad || (idiom == .phone && isHorizontal)) + ? 330 + : .infinity, + maxHeight: 290, alignment: .topLeading) .background(Theme.Colors.cardViewBackground) .cornerRadius(8) .padding(.horizontal, 22) From 9952bb98ba914e0c180756c65a05511bbabbd335 Mon Sep 17 00:00:00 2001 From: Volodymyr Chekyrta <127732735+volodymyr-chekyrta@users.noreply.github.com> Date: Mon, 23 Oct 2023 16:28:20 +0300 Subject: [PATCH 005/158] Added support of JWT tokens. (#104) The type of access token can be changed in the Core.Config class. --- Core/Core/Configuration/Config.swift | 8 ++ .../Core/Data/Repository/AuthRepository.swift | 3 +- Core/Core/Network/AuthEndpoint.swift | 8 +- Core/Core/Network/RequestInterceptor.swift | 85 ++++++++++--------- 4 files changed, 59 insertions(+), 45 deletions(-) diff --git a/Core/Core/Configuration/Config.swift b/Core/Core/Configuration/Config.swift index 7f49fac57..77f5da816 100644 --- a/Core/Core/Configuration/Config.swift +++ b/Core/Core/Configuration/Config.swift @@ -11,6 +11,7 @@ public class Config { public let baseURL: URL public let oAuthClientId: String + public let tokenType: TokenType = .jwt public lazy var termsOfUse: URL? = { URL(string: "\(baseURL.description)/tos") @@ -31,6 +32,13 @@ public class Config { } } +public extension Config { + enum TokenType: String { + case jwt = "JWT" + case bearer = "BEARER" + } +} + // Mark - For testing and SwiftUI preview #if DEBUG public class ConfigMock: Config { diff --git a/Core/Core/Data/Repository/AuthRepository.swift b/Core/Core/Data/Repository/AuthRepository.swift index 1ed62fd68..874159e4c 100644 --- a/Core/Core/Data/Repository/AuthRepository.swift +++ b/Core/Core/Data/Repository/AuthRepository.swift @@ -33,7 +33,8 @@ public class AuthRepository: AuthRepositoryProtocol { let endPoint = AuthEndpoint.getAccessToken( username: username, password: password, - clientId: config.oAuthClientId + clientId: config.oAuthClientId, + tokenType: config.tokenType.rawValue ) let authResponse = try await api.requestData(endPoint).mapResponse(DataLayer.AuthResponse.self) guard let accessToken = authResponse.accessToken, diff --git a/Core/Core/Network/AuthEndpoint.swift b/Core/Core/Network/AuthEndpoint.swift index c477c451c..d92d139c0 100644 --- a/Core/Core/Network/AuthEndpoint.swift +++ b/Core/Core/Network/AuthEndpoint.swift @@ -9,7 +9,7 @@ import Foundation import Alamofire enum AuthEndpoint: EndPointType { - case getAccessToken(username: String, password: String, clientId: String) + case getAccessToken(username: String, password: String, clientId: String, tokenType: String) case getUserInfo case getAuthCookies case getRegisterFields @@ -61,12 +61,14 @@ enum AuthEndpoint: EndPointType { var task: HTTPTask { switch self { - case let .getAccessToken(username, password, clientId): + case let .getAccessToken(username, password, clientId, tokenType): let params: [String: Encodable] = [ "grant_type": Constants.GrantTypePassword, "client_id": clientId, "username": username, - "password": password + "password": password, + "token_type": tokenType, + "asymmetric_jwt": true ] return .requestParameters(parameters: params, encoding: URLEncoding.httpBody) case .getUserInfo: diff --git a/Core/Core/Network/RequestInterceptor.swift b/Core/Core/Network/RequestInterceptor.swift index cecdf0570..49f13a806 100644 --- a/Core/Core/Network/RequestInterceptor.swift +++ b/Core/Core/Network/RequestInterceptor.swift @@ -35,7 +35,7 @@ final public class RequestInterceptor: Alamofire.RequestInterceptor { // Set the Authorization header value using the access token. if let token = storage.accessToken { - urlRequest.setValue("Bearer " + token, forHTTPHeaderField: "Authorization") + urlRequest.setValue("\(config.tokenType.rawValue) \(token)", forHTTPHeaderField: "Authorization") } completion(.success(urlRequest)) @@ -84,49 +84,52 @@ final public class RequestInterceptor: Alamofire.RequestInterceptor { private func refreshToken( refreshToken: String, - completion: @escaping (_ succeeded: Bool) -> Void) { - guard !isRefreshing else { return } - - isRefreshing = true - - let url = config.baseURL.appendingPathComponent("/oauth2/access_token") - - let parameters = [ - "grant_type": Constants.GrantTypeRefreshToken, - "client_id": config.oAuthClientId, - "refresh_token": refreshToken - ] - AF.request( - url, - method: .post, - parameters: parameters, - encoding: URLEncoding.httpBody - ).response { [weak self] response in - guard let self = self else { return } - switch response.result { - case let .success(data): - do { - let json = try JSONSerialization.jsonObject( - with: data!, - options: .mutableContainers - ) as? [String: AnyObject] - guard let json, - let accessToken = json["access_token"] as? String, - let refreshToken = json["refresh_token"] as? String, - accessToken.count > 0, - refreshToken.count > 0 else { - return completion(false) - } - self.storage.accessToken = accessToken - self.storage.refreshToken = refreshToken - completion(true) - } catch { - completion(false) + completion: @escaping (_ succeeded: Bool) -> Void + ) { + guard !isRefreshing else { return } + + isRefreshing = true + + let url = config.baseURL.appendingPathComponent("/oauth2/access_token") + + let parameters: [String: Encodable] = [ + "grant_type": Constants.GrantTypeRefreshToken, + "client_id": config.oAuthClientId, + "refresh_token": refreshToken, + "token_type": config.tokenType.rawValue, + "asymmetric_jwt": true + ] + AF.request( + url, + method: .post, + parameters: parameters, + encoding: URLEncoding.httpBody + ).response { [weak self] response in + guard let self = self else { return } + switch response.result { + case let .success(data): + do { + let json = try JSONSerialization.jsonObject( + with: data!, + options: .mutableContainers + ) as? [String: AnyObject] + guard let json, + let accessToken = json["access_token"] as? String, + let refreshToken = json["refresh_token"] as? String, + accessToken.count > 0, + refreshToken.count > 0 else { + return completion(false) } - case .failure: + self.storage.accessToken = accessToken + self.storage.refreshToken = refreshToken + completion(true) + } catch { completion(false) } - self.isRefreshing = false + case .failure: + completion(false) } + self.isRefreshing = false } + } } From 6fbe09de0099e709e6391e98b8a39918760d17ec Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Fri, 27 Oct 2023 14:16:21 +0300 Subject: [PATCH 006/158] =?UTF-8?q?What=E2=80=99s=20new=20screen=20(After?= =?UTF-8?q?=20Login=20Experience)=20(#131)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add whats new screen * add whats new logic after user login * code style fixes add accessibility support add feature flag to Config * Add tests * change the done button icon and add the fade-in animation to the previous button * Update Config.swift change whatsNewEnabled to false * fix mock files * add test file to project --- .../Presentation/Login/SignInViewModel.swift | 2 +- .../Registration/SignUpViewModel.swift | 2 +- .../AuthorizationMock.generated.swift | 40 +- .../Login/SignInViewModelTests.swift | 14 +- .../Register/SignUpViewModelTests.swift | 10 +- Core/Core/Configuration/BaseRouter.swift | 4 +- Core/Core/Configuration/Config.swift | 2 + Course/CourseTests/CourseMock.generated.swift | 20 +- .../DashboardMock.generated.swift | 20 +- .../DiscoveryMock.generated.swift | 20 +- .../contents.xcworkspacedata | 3 + .../DiscussionMock.generated.swift | 40 +- OpenEdX.xcodeproj/project.pbxproj | 6 + .../xcschemes/OpenEdXDev.xcscheme | 11 +- OpenEdX.xcworkspace/contents.xcworkspacedata | 3 + OpenEdX/DI/AppAssembly.swift | 9 + OpenEdX/Data/AppStorage.swift | 17 +- OpenEdX/RouteController.swift | 28 +- OpenEdX/Router.swift | 25 +- OpenEdX/View/MainScreenView.swift | 1 + Podfile | 9 + Podfile.lock | 2 +- .../ProfileTests/ProfileMock.generated.swift | 40 +- WhatsNew/.gitignore | 99 ++ WhatsNew/Mockfile | 17 + WhatsNew/WhatsNew.xcodeproj/project.pbxproj | 1499 +++++++++++++++++ .../xcshareddata/xcschemes/WhatsNew.xcscheme | 79 + .../Assets.xcassets/1.0/Contents.json | 6 + .../1.0/image1_1.0.imageset/Contents.json | 15 + .../1.0/image1_1.0.imageset/Group 97.png | Bin 0 -> 34041 bytes .../1.0/image2_1.0.imageset/Contents.json | 12 + .../1.0/image2_1.0.imageset/Group 96-2.png | Bin 0 -> 32412 bytes .../1.0/image3_1.0.imageset/Contents.json | 12 + .../1.0/image3_1.0.imageset/globe.png | Bin 0 -> 22904 bytes .../1.0/image4_1.0.imageset/Contents.json | 12 + .../feature screenshot.jpg | Bin 0 -> 208602 bytes .../WhatsNew/Assets.xcassets/Contents.json | 6 + WhatsNew/WhatsNew/Data/WhatsNew.json | 52 + WhatsNew/WhatsNew/Data/WhatsNewModel.swift | 69 + WhatsNew/WhatsNew/Data/WhatsNewStorage.swift | 21 + WhatsNew/WhatsNew/Domain/WhatsNewPage.swift | 14 + WhatsNew/WhatsNew/Info.plist | 12 + .../Presentation/Elements/PageControl.swift | 32 + .../Elements/WhatsNewNavigationButton.swift | 63 + .../Presentation/WhatsNewRouter.swift | 19 + .../WhatsNew/Presentation/WhatsNewView.swift | 160 ++ .../Presentation/WhatsNewViewModel.swift | 73 + WhatsNew/WhatsNew/SwiftGen/Strings.swift | 47 + .../WhatsNew/en.lproj/Localizable.strings | 13 + .../WhatsNew/uk.lproj/Localizable.strings | 12 + .../Presentation/WhatsNewTests.swift | 27 + .../WhatsNewMock.generated.swift | 19 + WhatsNew/swiftgen.yml | 18 + generateAllMocks.sh | 2 + 54 files changed, 2622 insertions(+), 116 deletions(-) create mode 100644 WhatsNew/.gitignore create mode 100644 WhatsNew/Mockfile create mode 100644 WhatsNew/WhatsNew.xcodeproj/project.pbxproj create mode 100644 WhatsNew/WhatsNew.xcodeproj/xcshareddata/xcschemes/WhatsNew.xcscheme create mode 100644 WhatsNew/WhatsNew/Assets.xcassets/1.0/Contents.json create mode 100644 WhatsNew/WhatsNew/Assets.xcassets/1.0/image1_1.0.imageset/Contents.json create mode 100644 WhatsNew/WhatsNew/Assets.xcassets/1.0/image1_1.0.imageset/Group 97.png create mode 100644 WhatsNew/WhatsNew/Assets.xcassets/1.0/image2_1.0.imageset/Contents.json create mode 100644 WhatsNew/WhatsNew/Assets.xcassets/1.0/image2_1.0.imageset/Group 96-2.png create mode 100644 WhatsNew/WhatsNew/Assets.xcassets/1.0/image3_1.0.imageset/Contents.json create mode 100644 WhatsNew/WhatsNew/Assets.xcassets/1.0/image3_1.0.imageset/globe.png create mode 100644 WhatsNew/WhatsNew/Assets.xcassets/1.0/image4_1.0.imageset/Contents.json create mode 100644 WhatsNew/WhatsNew/Assets.xcassets/1.0/image4_1.0.imageset/feature screenshot.jpg create mode 100644 WhatsNew/WhatsNew/Assets.xcassets/Contents.json create mode 100644 WhatsNew/WhatsNew/Data/WhatsNew.json create mode 100644 WhatsNew/WhatsNew/Data/WhatsNewModel.swift create mode 100644 WhatsNew/WhatsNew/Data/WhatsNewStorage.swift create mode 100644 WhatsNew/WhatsNew/Domain/WhatsNewPage.swift create mode 100644 WhatsNew/WhatsNew/Info.plist create mode 100644 WhatsNew/WhatsNew/Presentation/Elements/PageControl.swift create mode 100644 WhatsNew/WhatsNew/Presentation/Elements/WhatsNewNavigationButton.swift create mode 100644 WhatsNew/WhatsNew/Presentation/WhatsNewRouter.swift create mode 100644 WhatsNew/WhatsNew/Presentation/WhatsNewView.swift create mode 100644 WhatsNew/WhatsNew/Presentation/WhatsNewViewModel.swift create mode 100644 WhatsNew/WhatsNew/SwiftGen/Strings.swift create mode 100644 WhatsNew/WhatsNew/en.lproj/Localizable.strings create mode 100644 WhatsNew/WhatsNew/uk.lproj/Localizable.strings create mode 100644 WhatsNew/WhatsNewTests/Presentation/WhatsNewTests.swift create mode 100644 WhatsNew/WhatsNewTests/WhatsNewMock.generated.swift create mode 100644 WhatsNew/swiftgen.yml diff --git a/Authorization/Authorization/Presentation/Login/SignInViewModel.swift b/Authorization/Authorization/Presentation/Login/SignInViewModel.swift index 6d8ebfdee..c97689735 100644 --- a/Authorization/Authorization/Presentation/Login/SignInViewModel.swift +++ b/Authorization/Authorization/Presentation/Login/SignInViewModel.swift @@ -64,7 +64,7 @@ public class SignInViewModel: ObservableObject { let user = try await interactor.login(username: username, password: password) analytics.setUserID("\(user.id)") analytics.userLogin(method: .password) - router.showMainScreen() + router.showMainOrWhatsNewScreen() } catch let error { isShowProgress = false if let validationError = error.validationError, diff --git a/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift b/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift index a2142684f..e882311dd 100644 --- a/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift +++ b/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift @@ -93,7 +93,7 @@ public class SignUpViewModel: ObservableObject { analytics.setUserID("\(user.id)") analytics.registrationSuccess() isShowProgress = false - router.showMainScreen() + router.showMainOrWhatsNewScreen() } catch let error { isShowProgress = false diff --git a/Authorization/AuthorizationTests/AuthorizationMock.generated.swift b/Authorization/AuthorizationTests/AuthorizationMock.generated.swift index ddb4ac259..93016c754 100644 --- a/Authorization/AuthorizationTests/AuthorizationMock.generated.swift +++ b/Authorization/AuthorizationTests/AuthorizationMock.generated.swift @@ -761,9 +761,9 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { perform?(`controllers`) } - open func showMainScreen() { - addInvocation(.m_showMainScreen) - let perform = methodPerformValue(.m_showMainScreen) as? () -> Void + open func showMainOrWhatsNewScreen() { + addInvocation(.m_showMainOrWhatsNewScreen) + let perform = methodPerformValue(.m_showMainOrWhatsNewScreen) as? () -> Void perform?() } @@ -816,7 +816,7 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { case m_backWithFade case m_dismiss__animated_animated(Parameter) case m_removeLastView__controllers_controllers(Parameter) - case m_showMainScreen + case m_showMainOrWhatsNewScreen case m_showLoginScreen case m_showRegisterScreen case m_showForgotPasswordScreen @@ -849,7 +849,7 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsControllers, rhs: rhsControllers, with: matcher), lhsControllers, rhsControllers, "controllers")) return Matcher.ComparisonResult(results) - case (.m_showMainScreen, .m_showMainScreen): return .match + case (.m_showMainOrWhatsNewScreen, .m_showMainOrWhatsNewScreen): return .match case (.m_showLoginScreen, .m_showLoginScreen): return .match @@ -901,7 +901,7 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { case .m_backWithFade: return 0 case let .m_dismiss__animated_animated(p0): return p0.intValue case let .m_removeLastView__controllers_controllers(p0): return p0.intValue - case .m_showMainScreen: return 0 + case .m_showMainOrWhatsNewScreen: return 0 case .m_showLoginScreen: return 0 case .m_showRegisterScreen: return 0 case .m_showForgotPasswordScreen: return 0 @@ -918,7 +918,7 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { case .m_backWithFade: return ".backWithFade()" case .m_dismiss__animated_animated: return ".dismiss(animated:)" case .m_removeLastView__controllers_controllers: return ".removeLastView(controllers:)" - case .m_showMainScreen: return ".showMainScreen()" + case .m_showMainOrWhatsNewScreen: return ".showMainOrWhatsNewScreen()" case .m_showLoginScreen: return ".showLoginScreen()" case .m_showRegisterScreen: return ".showRegisterScreen()" case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" @@ -949,7 +949,7 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { public static func backWithFade() -> Verify { return Verify(method: .m_backWithFade)} public static func dismiss(animated: Parameter) -> Verify { return Verify(method: .m_dismiss__animated_animated(`animated`))} public static func removeLastView(controllers: Parameter) -> Verify { return Verify(method: .m_removeLastView__controllers_controllers(`controllers`))} - public static func showMainScreen() -> Verify { return Verify(method: .m_showMainScreen)} + public static func showMainOrWhatsNewScreen() -> Verify { return Verify(method: .m_showMainOrWhatsNewScreen)} public static func showLoginScreen() -> Verify { return Verify(method: .m_showLoginScreen)} public static func showRegisterScreen() -> Verify { return Verify(method: .m_showRegisterScreen)} public static func showForgotPasswordScreen() -> Verify { return Verify(method: .m_showForgotPasswordScreen)} @@ -978,8 +978,8 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { public static func removeLastView(controllers: Parameter, perform: @escaping (Int) -> Void) -> Perform { return Perform(method: .m_removeLastView__controllers_controllers(`controllers`), performs: perform) } - public static func showMainScreen(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_showMainScreen, performs: perform) + public static func showMainOrWhatsNewScreen(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_showMainOrWhatsNewScreen, performs: perform) } public static func showLoginScreen(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_showLoginScreen, performs: perform) @@ -1151,9 +1151,9 @@ open class BaseRouterMock: BaseRouter, Mock { perform?(`controllers`) } - open func showMainScreen() { - addInvocation(.m_showMainScreen) - let perform = methodPerformValue(.m_showMainScreen) as? () -> Void + open func showMainOrWhatsNewScreen() { + addInvocation(.m_showMainOrWhatsNewScreen) + let perform = methodPerformValue(.m_showMainOrWhatsNewScreen) as? () -> Void perform?() } @@ -1206,7 +1206,7 @@ open class BaseRouterMock: BaseRouter, Mock { case m_backWithFade case m_dismiss__animated_animated(Parameter) case m_removeLastView__controllers_controllers(Parameter) - case m_showMainScreen + case m_showMainOrWhatsNewScreen case m_showLoginScreen case m_showRegisterScreen case m_showForgotPasswordScreen @@ -1239,7 +1239,7 @@ open class BaseRouterMock: BaseRouter, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsControllers, rhs: rhsControllers, with: matcher), lhsControllers, rhsControllers, "controllers")) return Matcher.ComparisonResult(results) - case (.m_showMainScreen, .m_showMainScreen): return .match + case (.m_showMainOrWhatsNewScreen, .m_showMainOrWhatsNewScreen): return .match case (.m_showLoginScreen, .m_showLoginScreen): return .match @@ -1291,7 +1291,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_backWithFade: return 0 case let .m_dismiss__animated_animated(p0): return p0.intValue case let .m_removeLastView__controllers_controllers(p0): return p0.intValue - case .m_showMainScreen: return 0 + case .m_showMainOrWhatsNewScreen: return 0 case .m_showLoginScreen: return 0 case .m_showRegisterScreen: return 0 case .m_showForgotPasswordScreen: return 0 @@ -1308,7 +1308,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_backWithFade: return ".backWithFade()" case .m_dismiss__animated_animated: return ".dismiss(animated:)" case .m_removeLastView__controllers_controllers: return ".removeLastView(controllers:)" - case .m_showMainScreen: return ".showMainScreen()" + case .m_showMainOrWhatsNewScreen: return ".showMainOrWhatsNewScreen()" case .m_showLoginScreen: return ".showLoginScreen()" case .m_showRegisterScreen: return ".showRegisterScreen()" case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" @@ -1339,7 +1339,7 @@ open class BaseRouterMock: BaseRouter, Mock { public static func backWithFade() -> Verify { return Verify(method: .m_backWithFade)} public static func dismiss(animated: Parameter) -> Verify { return Verify(method: .m_dismiss__animated_animated(`animated`))} public static func removeLastView(controllers: Parameter) -> Verify { return Verify(method: .m_removeLastView__controllers_controllers(`controllers`))} - public static func showMainScreen() -> Verify { return Verify(method: .m_showMainScreen)} + public static func showMainOrWhatsNewScreen() -> Verify { return Verify(method: .m_showMainOrWhatsNewScreen)} public static func showLoginScreen() -> Verify { return Verify(method: .m_showLoginScreen)} public static func showRegisterScreen() -> Verify { return Verify(method: .m_showRegisterScreen)} public static func showForgotPasswordScreen() -> Verify { return Verify(method: .m_showForgotPasswordScreen)} @@ -1368,8 +1368,8 @@ open class BaseRouterMock: BaseRouter, Mock { public static func removeLastView(controllers: Parameter, perform: @escaping (Int) -> Void) -> Perform { return Perform(method: .m_removeLastView__controllers_controllers(`controllers`), performs: perform) } - public static func showMainScreen(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_showMainScreen, performs: perform) + public static func showMainOrWhatsNewScreen(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_showMainOrWhatsNewScreen, performs: perform) } public static func showLoginScreen(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_showLoginScreen, performs: perform) diff --git a/Authorization/AuthorizationTests/Presentation/Login/SignInViewModelTests.swift b/Authorization/AuthorizationTests/Presentation/Login/SignInViewModelTests.swift index d478f0f68..aec540570 100644 --- a/Authorization/AuthorizationTests/Presentation/Login/SignInViewModelTests.swift +++ b/Authorization/AuthorizationTests/Presentation/Login/SignInViewModelTests.swift @@ -37,7 +37,7 @@ final class SignInViewModelTests: XCTestCase { await viewModel.login(username: "email", password: "") Verify(interactor, 0, .login(username: .any, password: .any)) - Verify(router, 0, .showMainScreen()) + Verify(router, 0, .showMainOrWhatsNewScreen()) XCTAssertEqual(viewModel.errorMessage, AuthLocalization.Error.invalidEmailAddress) XCTAssertEqual(viewModel.isShowProgress, false) @@ -57,7 +57,7 @@ final class SignInViewModelTests: XCTestCase { await viewModel.login(username: "edxUser@edx.com", password: "") Verify(interactor, 0, .login(username: .any, password: .any)) - Verify(router, 0, .showMainScreen()) + Verify(router, 0, .showMainOrWhatsNewScreen()) XCTAssertEqual(viewModel.errorMessage, AuthLocalization.Error.invalidPasswordLenght) XCTAssertEqual(viewModel.isShowProgress, false) @@ -82,7 +82,7 @@ final class SignInViewModelTests: XCTestCase { Verify(interactor, 1, .login(username: .any, password: .any)) Verify(analytics, .userLogin(method: .any)) - Verify(router, 1, .showMainScreen()) + Verify(router, 1, .showMainOrWhatsNewScreen()) XCTAssertEqual(viewModel.errorMessage, nil) XCTAssertEqual(viewModel.isShowProgress, true) @@ -109,7 +109,7 @@ final class SignInViewModelTests: XCTestCase { await viewModel.login(username: "edxUser@edx.com", password: "password123") Verify(interactor, 1, .login(username: .any, password: .any)) - Verify(router, 0, .showMainScreen()) + Verify(router, 0, .showMainOrWhatsNewScreen()) XCTAssertEqual(viewModel.errorMessage, validationErrorMessage) XCTAssertEqual(viewModel.isShowProgress, false) @@ -132,7 +132,7 @@ final class SignInViewModelTests: XCTestCase { await viewModel.login(username: "edxUser@edx.com", password: "password123") Verify(interactor, 1, .login(username: .any, password: .any)) - Verify(router, 0, .showMainScreen()) + Verify(router, 0, .showMainOrWhatsNewScreen()) XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.invalidCredentials) XCTAssertEqual(viewModel.isShowProgress, false) @@ -155,7 +155,7 @@ final class SignInViewModelTests: XCTestCase { await viewModel.login(username: "edxUser@edx.com", password: "password123") Verify(interactor, 1, .login(username: .any, password: .any)) - Verify(router, 0, .showMainScreen()) + Verify(router, 0, .showMainOrWhatsNewScreen()) XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.unknownError) XCTAssertEqual(viewModel.isShowProgress, false) @@ -180,7 +180,7 @@ final class SignInViewModelTests: XCTestCase { await viewModel.login(username: "edxUser@edx.com", password: "password123") Verify(interactor, 1, .login(username: .any, password: .any)) - Verify(router, 0, .showMainScreen()) + Verify(router, 0, .showMainOrWhatsNewScreen()) XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.slowOrNoInternetConnection) XCTAssertEqual(viewModel.isShowProgress, false) diff --git a/Authorization/AuthorizationTests/Presentation/Register/SignUpViewModelTests.swift b/Authorization/AuthorizationTests/Presentation/Register/SignUpViewModelTests.swift index 8699b79fc..b59519b27 100644 --- a/Authorization/AuthorizationTests/Presentation/Register/SignUpViewModelTests.swift +++ b/Authorization/AuthorizationTests/Presentation/Register/SignUpViewModelTests.swift @@ -128,7 +128,7 @@ final class SignUpViewModelTests: XCTestCase { Verify(interactor, 1, .validateRegistrationFields(fields: .any)) Verify(interactor, 1, .registerUser(fields: .any)) - Verify(router, 1, .showMainScreen()) + Verify(router, 1, .showMainOrWhatsNewScreen()) XCTAssertEqual(viewModel.isShowProgress, false) XCTAssertEqual(viewModel.showError, false) @@ -164,7 +164,7 @@ final class SignUpViewModelTests: XCTestCase { Verify(interactor, 1, .validateRegistrationFields(fields: .any)) Verify(interactor, 0, .registerUser(fields: .any)) - Verify(router, 0, .showMainScreen()) + Verify(router, 0, .showMainOrWhatsNewScreen()) XCTAssertEqual(viewModel.isShowProgress, false) XCTAssertEqual(viewModel.showError, false) @@ -192,7 +192,7 @@ final class SignUpViewModelTests: XCTestCase { Verify(interactor, 1, .validateRegistrationFields(fields: .any)) Verify(interactor, 1, .registerUser(fields: .any)) - Verify(router, 0, .showMainScreen()) + Verify(router, 0, .showMainOrWhatsNewScreen()) XCTAssertEqual(viewModel.isShowProgress, false) XCTAssertEqual(viewModel.showError, true) @@ -220,7 +220,7 @@ final class SignUpViewModelTests: XCTestCase { Verify(interactor, 1, .validateRegistrationFields(fields: .any)) Verify(interactor, 1, .registerUser(fields: .any)) - Verify(router, 0, .showMainScreen()) + Verify(router, 0, .showMainOrWhatsNewScreen()) XCTAssertEqual(viewModel.isShowProgress, false) XCTAssertEqual(viewModel.showError, true) @@ -250,7 +250,7 @@ final class SignUpViewModelTests: XCTestCase { Verify(interactor, 1, .validateRegistrationFields(fields: .any)) Verify(interactor, 1, .registerUser(fields: .any)) - Verify(router, 0, .showMainScreen()) + Verify(router, 0, .showMainOrWhatsNewScreen()) XCTAssertEqual(viewModel.isShowProgress, false) XCTAssertEqual(viewModel.showError, true) diff --git a/Core/Core/Configuration/BaseRouter.swift b/Core/Core/Configuration/BaseRouter.swift index c86b90f62..034855d87 100644 --- a/Core/Core/Configuration/BaseRouter.swift +++ b/Core/Core/Configuration/BaseRouter.swift @@ -21,7 +21,7 @@ public protocol BaseRouter { func removeLastView(controllers: Int) - func showMainScreen() + func showMainOrWhatsNewScreen() func showLoginScreen() @@ -73,7 +73,7 @@ open class BaseRouterMock: BaseRouter { public func dismiss(animated: Bool) {} - public func showMainScreen() {} + public func showMainOrWhatsNewScreen() {} public func showLoginScreen() {} diff --git a/Core/Core/Configuration/Config.swift b/Core/Core/Configuration/Config.swift index 77f5da816..4cb9f3afc 100644 --- a/Core/Core/Configuration/Config.swift +++ b/Core/Core/Configuration/Config.swift @@ -23,6 +23,8 @@ public class Config { public let feedbackEmail = "support@example.com" + public let whatsNewEnabled: Bool = false + public init(baseURL: String, oAuthClientId: String) { guard let url = URL(string: baseURL) else { fatalError("Ivalid baseURL") diff --git a/Course/CourseTests/CourseMock.generated.swift b/Course/CourseTests/CourseMock.generated.swift index a4cf6b418..488ab1a83 100644 --- a/Course/CourseTests/CourseMock.generated.swift +++ b/Course/CourseTests/CourseMock.generated.swift @@ -490,9 +490,9 @@ open class BaseRouterMock: BaseRouter, Mock { perform?(`controllers`) } - open func showMainScreen() { - addInvocation(.m_showMainScreen) - let perform = methodPerformValue(.m_showMainScreen) as? () -> Void + open func showMainOrWhatsNewScreen() { + addInvocation(.m_showMainOrWhatsNewScreen) + let perform = methodPerformValue(.m_showMainOrWhatsNewScreen) as? () -> Void perform?() } @@ -545,7 +545,7 @@ open class BaseRouterMock: BaseRouter, Mock { case m_backWithFade case m_dismiss__animated_animated(Parameter) case m_removeLastView__controllers_controllers(Parameter) - case m_showMainScreen + case m_showMainOrWhatsNewScreen case m_showLoginScreen case m_showRegisterScreen case m_showForgotPasswordScreen @@ -578,7 +578,7 @@ open class BaseRouterMock: BaseRouter, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsControllers, rhs: rhsControllers, with: matcher), lhsControllers, rhsControllers, "controllers")) return Matcher.ComparisonResult(results) - case (.m_showMainScreen, .m_showMainScreen): return .match + case (.m_showMainOrWhatsNewScreen, .m_showMainOrWhatsNewScreen): return .match case (.m_showLoginScreen, .m_showLoginScreen): return .match @@ -630,7 +630,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_backWithFade: return 0 case let .m_dismiss__animated_animated(p0): return p0.intValue case let .m_removeLastView__controllers_controllers(p0): return p0.intValue - case .m_showMainScreen: return 0 + case .m_showMainOrWhatsNewScreen: return 0 case .m_showLoginScreen: return 0 case .m_showRegisterScreen: return 0 case .m_showForgotPasswordScreen: return 0 @@ -647,7 +647,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_backWithFade: return ".backWithFade()" case .m_dismiss__animated_animated: return ".dismiss(animated:)" case .m_removeLastView__controllers_controllers: return ".removeLastView(controllers:)" - case .m_showMainScreen: return ".showMainScreen()" + case .m_showMainOrWhatsNewScreen: return ".showMainOrWhatsNewScreen()" case .m_showLoginScreen: return ".showLoginScreen()" case .m_showRegisterScreen: return ".showRegisterScreen()" case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" @@ -678,7 +678,7 @@ open class BaseRouterMock: BaseRouter, Mock { public static func backWithFade() -> Verify { return Verify(method: .m_backWithFade)} public static func dismiss(animated: Parameter) -> Verify { return Verify(method: .m_dismiss__animated_animated(`animated`))} public static func removeLastView(controllers: Parameter) -> Verify { return Verify(method: .m_removeLastView__controllers_controllers(`controllers`))} - public static func showMainScreen() -> Verify { return Verify(method: .m_showMainScreen)} + public static func showMainOrWhatsNewScreen() -> Verify { return Verify(method: .m_showMainOrWhatsNewScreen)} public static func showLoginScreen() -> Verify { return Verify(method: .m_showLoginScreen)} public static func showRegisterScreen() -> Verify { return Verify(method: .m_showRegisterScreen)} public static func showForgotPasswordScreen() -> Verify { return Verify(method: .m_showForgotPasswordScreen)} @@ -707,8 +707,8 @@ open class BaseRouterMock: BaseRouter, Mock { public static func removeLastView(controllers: Parameter, perform: @escaping (Int) -> Void) -> Perform { return Perform(method: .m_removeLastView__controllers_controllers(`controllers`), performs: perform) } - public static func showMainScreen(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_showMainScreen, performs: perform) + public static func showMainOrWhatsNewScreen(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_showMainOrWhatsNewScreen, performs: perform) } public static func showLoginScreen(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_showLoginScreen, performs: perform) diff --git a/Dashboard/DashboardTests/DashboardMock.generated.swift b/Dashboard/DashboardTests/DashboardMock.generated.swift index 27aebe250..efbb553e6 100644 --- a/Dashboard/DashboardTests/DashboardMock.generated.swift +++ b/Dashboard/DashboardTests/DashboardMock.generated.swift @@ -490,9 +490,9 @@ open class BaseRouterMock: BaseRouter, Mock { perform?(`controllers`) } - open func showMainScreen() { - addInvocation(.m_showMainScreen) - let perform = methodPerformValue(.m_showMainScreen) as? () -> Void + open func showMainOrWhatsNewScreen() { + addInvocation(.m_showMainOrWhatsNewScreen) + let perform = methodPerformValue(.m_showMainOrWhatsNewScreen) as? () -> Void perform?() } @@ -545,7 +545,7 @@ open class BaseRouterMock: BaseRouter, Mock { case m_backWithFade case m_dismiss__animated_animated(Parameter) case m_removeLastView__controllers_controllers(Parameter) - case m_showMainScreen + case m_showMainOrWhatsNewScreen case m_showLoginScreen case m_showRegisterScreen case m_showForgotPasswordScreen @@ -578,7 +578,7 @@ open class BaseRouterMock: BaseRouter, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsControllers, rhs: rhsControllers, with: matcher), lhsControllers, rhsControllers, "controllers")) return Matcher.ComparisonResult(results) - case (.m_showMainScreen, .m_showMainScreen): return .match + case (.m_showMainOrWhatsNewScreen, .m_showMainOrWhatsNewScreen): return .match case (.m_showLoginScreen, .m_showLoginScreen): return .match @@ -630,7 +630,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_backWithFade: return 0 case let .m_dismiss__animated_animated(p0): return p0.intValue case let .m_removeLastView__controllers_controllers(p0): return p0.intValue - case .m_showMainScreen: return 0 + case .m_showMainOrWhatsNewScreen: return 0 case .m_showLoginScreen: return 0 case .m_showRegisterScreen: return 0 case .m_showForgotPasswordScreen: return 0 @@ -647,7 +647,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_backWithFade: return ".backWithFade()" case .m_dismiss__animated_animated: return ".dismiss(animated:)" case .m_removeLastView__controllers_controllers: return ".removeLastView(controllers:)" - case .m_showMainScreen: return ".showMainScreen()" + case .m_showMainOrWhatsNewScreen: return ".showMainOrWhatsNewScreen()" case .m_showLoginScreen: return ".showLoginScreen()" case .m_showRegisterScreen: return ".showRegisterScreen()" case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" @@ -678,7 +678,7 @@ open class BaseRouterMock: BaseRouter, Mock { public static func backWithFade() -> Verify { return Verify(method: .m_backWithFade)} public static func dismiss(animated: Parameter) -> Verify { return Verify(method: .m_dismiss__animated_animated(`animated`))} public static func removeLastView(controllers: Parameter) -> Verify { return Verify(method: .m_removeLastView__controllers_controllers(`controllers`))} - public static func showMainScreen() -> Verify { return Verify(method: .m_showMainScreen)} + public static func showMainOrWhatsNewScreen() -> Verify { return Verify(method: .m_showMainOrWhatsNewScreen)} public static func showLoginScreen() -> Verify { return Verify(method: .m_showLoginScreen)} public static func showRegisterScreen() -> Verify { return Verify(method: .m_showRegisterScreen)} public static func showForgotPasswordScreen() -> Verify { return Verify(method: .m_showForgotPasswordScreen)} @@ -707,8 +707,8 @@ open class BaseRouterMock: BaseRouter, Mock { public static func removeLastView(controllers: Parameter, perform: @escaping (Int) -> Void) -> Perform { return Perform(method: .m_removeLastView__controllers_controllers(`controllers`), performs: perform) } - public static func showMainScreen(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_showMainScreen, performs: perform) + public static func showMainOrWhatsNewScreen(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_showMainOrWhatsNewScreen, performs: perform) } public static func showLoginScreen(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_showLoginScreen, performs: perform) diff --git a/Discovery/DiscoveryTests/DiscoveryMock.generated.swift b/Discovery/DiscoveryTests/DiscoveryMock.generated.swift index 1eb44a322..f55ce38ed 100644 --- a/Discovery/DiscoveryTests/DiscoveryMock.generated.swift +++ b/Discovery/DiscoveryTests/DiscoveryMock.generated.swift @@ -490,9 +490,9 @@ open class BaseRouterMock: BaseRouter, Mock { perform?(`controllers`) } - open func showMainScreen() { - addInvocation(.m_showMainScreen) - let perform = methodPerformValue(.m_showMainScreen) as? () -> Void + open func showMainOrWhatsNewScreen() { + addInvocation(.m_showMainOrWhatsNewScreen) + let perform = methodPerformValue(.m_showMainOrWhatsNewScreen) as? () -> Void perform?() } @@ -545,7 +545,7 @@ open class BaseRouterMock: BaseRouter, Mock { case m_backWithFade case m_dismiss__animated_animated(Parameter) case m_removeLastView__controllers_controllers(Parameter) - case m_showMainScreen + case m_showMainOrWhatsNewScreen case m_showLoginScreen case m_showRegisterScreen case m_showForgotPasswordScreen @@ -578,7 +578,7 @@ open class BaseRouterMock: BaseRouter, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsControllers, rhs: rhsControllers, with: matcher), lhsControllers, rhsControllers, "controllers")) return Matcher.ComparisonResult(results) - case (.m_showMainScreen, .m_showMainScreen): return .match + case (.m_showMainOrWhatsNewScreen, .m_showMainOrWhatsNewScreen): return .match case (.m_showLoginScreen, .m_showLoginScreen): return .match @@ -630,7 +630,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_backWithFade: return 0 case let .m_dismiss__animated_animated(p0): return p0.intValue case let .m_removeLastView__controllers_controllers(p0): return p0.intValue - case .m_showMainScreen: return 0 + case .m_showMainOrWhatsNewScreen: return 0 case .m_showLoginScreen: return 0 case .m_showRegisterScreen: return 0 case .m_showForgotPasswordScreen: return 0 @@ -647,7 +647,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_backWithFade: return ".backWithFade()" case .m_dismiss__animated_animated: return ".dismiss(animated:)" case .m_removeLastView__controllers_controllers: return ".removeLastView(controllers:)" - case .m_showMainScreen: return ".showMainScreen()" + case .m_showMainOrWhatsNewScreen: return ".showMainOrWhatsNewScreen()" case .m_showLoginScreen: return ".showLoginScreen()" case .m_showRegisterScreen: return ".showRegisterScreen()" case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" @@ -678,7 +678,7 @@ open class BaseRouterMock: BaseRouter, Mock { public static func backWithFade() -> Verify { return Verify(method: .m_backWithFade)} public static func dismiss(animated: Parameter) -> Verify { return Verify(method: .m_dismiss__animated_animated(`animated`))} public static func removeLastView(controllers: Parameter) -> Verify { return Verify(method: .m_removeLastView__controllers_controllers(`controllers`))} - public static func showMainScreen() -> Verify { return Verify(method: .m_showMainScreen)} + public static func showMainOrWhatsNewScreen() -> Verify { return Verify(method: .m_showMainOrWhatsNewScreen)} public static func showLoginScreen() -> Verify { return Verify(method: .m_showLoginScreen)} public static func showRegisterScreen() -> Verify { return Verify(method: .m_showRegisterScreen)} public static func showForgotPasswordScreen() -> Verify { return Verify(method: .m_showForgotPasswordScreen)} @@ -707,8 +707,8 @@ open class BaseRouterMock: BaseRouter, Mock { public static func removeLastView(controllers: Parameter, perform: @escaping (Int) -> Void) -> Perform { return Perform(method: .m_removeLastView__controllers_controllers(`controllers`), performs: perform) } - public static func showMainScreen(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_showMainScreen, performs: perform) + public static func showMainOrWhatsNewScreen(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_showMainOrWhatsNewScreen, performs: perform) } public static func showLoginScreen(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_showLoginScreen, performs: perform) diff --git a/Discussion/Discussion.xcodeproj.xcworkspace/contents.xcworkspacedata b/Discussion/Discussion.xcodeproj.xcworkspace/contents.xcworkspacedata index 85b36c90c..d64d30457 100644 --- a/Discussion/Discussion.xcodeproj.xcworkspace/contents.xcworkspacedata +++ b/Discussion/Discussion.xcodeproj.xcworkspace/contents.xcworkspacedata @@ -28,4 +28,7 @@ + + diff --git a/Discussion/DiscussionTests/DiscussionMock.generated.swift b/Discussion/DiscussionTests/DiscussionMock.generated.swift index 424aa9aaf..5303b1525 100644 --- a/Discussion/DiscussionTests/DiscussionMock.generated.swift +++ b/Discussion/DiscussionTests/DiscussionMock.generated.swift @@ -490,9 +490,9 @@ open class BaseRouterMock: BaseRouter, Mock { perform?(`controllers`) } - open func showMainScreen() { - addInvocation(.m_showMainScreen) - let perform = methodPerformValue(.m_showMainScreen) as? () -> Void + open func showMainOrWhatsNewScreen() { + addInvocation(.m_showMainOrWhatsNewScreen) + let perform = methodPerformValue(.m_showMainOrWhatsNewScreen) as? () -> Void perform?() } @@ -545,7 +545,7 @@ open class BaseRouterMock: BaseRouter, Mock { case m_backWithFade case m_dismiss__animated_animated(Parameter) case m_removeLastView__controllers_controllers(Parameter) - case m_showMainScreen + case m_showMainOrWhatsNewScreen case m_showLoginScreen case m_showRegisterScreen case m_showForgotPasswordScreen @@ -578,7 +578,7 @@ open class BaseRouterMock: BaseRouter, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsControllers, rhs: rhsControllers, with: matcher), lhsControllers, rhsControllers, "controllers")) return Matcher.ComparisonResult(results) - case (.m_showMainScreen, .m_showMainScreen): return .match + case (.m_showMainOrWhatsNewScreen, .m_showMainOrWhatsNewScreen): return .match case (.m_showLoginScreen, .m_showLoginScreen): return .match @@ -630,7 +630,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_backWithFade: return 0 case let .m_dismiss__animated_animated(p0): return p0.intValue case let .m_removeLastView__controllers_controllers(p0): return p0.intValue - case .m_showMainScreen: return 0 + case .m_showMainOrWhatsNewScreen: return 0 case .m_showLoginScreen: return 0 case .m_showRegisterScreen: return 0 case .m_showForgotPasswordScreen: return 0 @@ -647,7 +647,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_backWithFade: return ".backWithFade()" case .m_dismiss__animated_animated: return ".dismiss(animated:)" case .m_removeLastView__controllers_controllers: return ".removeLastView(controllers:)" - case .m_showMainScreen: return ".showMainScreen()" + case .m_showMainOrWhatsNewScreen: return ".showMainOrWhatsNewScreen()" case .m_showLoginScreen: return ".showLoginScreen()" case .m_showRegisterScreen: return ".showRegisterScreen()" case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" @@ -678,7 +678,7 @@ open class BaseRouterMock: BaseRouter, Mock { public static func backWithFade() -> Verify { return Verify(method: .m_backWithFade)} public static func dismiss(animated: Parameter) -> Verify { return Verify(method: .m_dismiss__animated_animated(`animated`))} public static func removeLastView(controllers: Parameter) -> Verify { return Verify(method: .m_removeLastView__controllers_controllers(`controllers`))} - public static func showMainScreen() -> Verify { return Verify(method: .m_showMainScreen)} + public static func showMainOrWhatsNewScreen() -> Verify { return Verify(method: .m_showMainOrWhatsNewScreen)} public static func showLoginScreen() -> Verify { return Verify(method: .m_showLoginScreen)} public static func showRegisterScreen() -> Verify { return Verify(method: .m_showRegisterScreen)} public static func showForgotPasswordScreen() -> Verify { return Verify(method: .m_showForgotPasswordScreen)} @@ -707,8 +707,8 @@ open class BaseRouterMock: BaseRouter, Mock { public static func removeLastView(controllers: Parameter, perform: @escaping (Int) -> Void) -> Perform { return Perform(method: .m_removeLastView__controllers_controllers(`controllers`), performs: perform) } - public static func showMainScreen(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_showMainScreen, performs: perform) + public static func showMainOrWhatsNewScreen(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_showMainOrWhatsNewScreen, performs: perform) } public static func showLoginScreen(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_showLoginScreen, performs: perform) @@ -2033,9 +2033,9 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { perform?(`controllers`) } - open func showMainScreen() { - addInvocation(.m_showMainScreen) - let perform = methodPerformValue(.m_showMainScreen) as? () -> Void + open func showMainOrWhatsNewScreen() { + addInvocation(.m_showMainOrWhatsNewScreen) + let perform = methodPerformValue(.m_showMainOrWhatsNewScreen) as? () -> Void perform?() } @@ -2094,7 +2094,7 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { case m_backWithFade case m_dismiss__animated_animated(Parameter) case m_removeLastView__controllers_controllers(Parameter) - case m_showMainScreen + case m_showMainOrWhatsNewScreen case m_showLoginScreen case m_showRegisterScreen case m_showForgotPasswordScreen @@ -2165,7 +2165,7 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsControllers, rhs: rhsControllers, with: matcher), lhsControllers, rhsControllers, "controllers")) return Matcher.ComparisonResult(results) - case (.m_showMainScreen, .m_showMainScreen): return .match + case (.m_showMainOrWhatsNewScreen, .m_showMainOrWhatsNewScreen): return .match case (.m_showLoginScreen, .m_showLoginScreen): return .match @@ -2223,7 +2223,7 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { case .m_backWithFade: return 0 case let .m_dismiss__animated_animated(p0): return p0.intValue case let .m_removeLastView__controllers_controllers(p0): return p0.intValue - case .m_showMainScreen: return 0 + case .m_showMainOrWhatsNewScreen: return 0 case .m_showLoginScreen: return 0 case .m_showRegisterScreen: return 0 case .m_showForgotPasswordScreen: return 0 @@ -2246,7 +2246,7 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { case .m_backWithFade: return ".backWithFade()" case .m_dismiss__animated_animated: return ".dismiss(animated:)" case .m_removeLastView__controllers_controllers: return ".removeLastView(controllers:)" - case .m_showMainScreen: return ".showMainScreen()" + case .m_showMainOrWhatsNewScreen: return ".showMainOrWhatsNewScreen()" case .m_showLoginScreen: return ".showLoginScreen()" case .m_showRegisterScreen: return ".showRegisterScreen()" case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" @@ -2283,7 +2283,7 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { public static func backWithFade() -> Verify { return Verify(method: .m_backWithFade)} public static func dismiss(animated: Parameter) -> Verify { return Verify(method: .m_dismiss__animated_animated(`animated`))} public static func removeLastView(controllers: Parameter) -> Verify { return Verify(method: .m_removeLastView__controllers_controllers(`controllers`))} - public static func showMainScreen() -> Verify { return Verify(method: .m_showMainScreen)} + public static func showMainOrWhatsNewScreen() -> Verify { return Verify(method: .m_showMainOrWhatsNewScreen)} public static func showLoginScreen() -> Verify { return Verify(method: .m_showLoginScreen)} public static func showRegisterScreen() -> Verify { return Verify(method: .m_showRegisterScreen)} public static func showForgotPasswordScreen() -> Verify { return Verify(method: .m_showForgotPasswordScreen)} @@ -2330,8 +2330,8 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { public static func removeLastView(controllers: Parameter, perform: @escaping (Int) -> Void) -> Perform { return Perform(method: .m_removeLastView__controllers_controllers(`controllers`), performs: perform) } - public static func showMainScreen(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_showMainScreen, performs: perform) + public static func showMainOrWhatsNewScreen(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_showMainOrWhatsNewScreen, performs: perform) } public static func showLoginScreen(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_showLoginScreen, performs: perform) diff --git a/OpenEdX.xcodeproj/project.pbxproj b/OpenEdX.xcodeproj/project.pbxproj index 4fc81e931..08ffca434 100644 --- a/OpenEdX.xcodeproj/project.pbxproj +++ b/OpenEdX.xcodeproj/project.pbxproj @@ -17,6 +17,8 @@ 025DE1A528DB4DAE0053E0F4 /* Profile.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 025DE1A328DB4DAE0053E0F4 /* Profile.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 027DB33028D8A063002B6862 /* Dashboard.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 027DB32F28D8A063002B6862 /* Dashboard.framework */; }; 027DB33128D8A063002B6862 /* Dashboard.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 027DB32F28D8A063002B6862 /* Dashboard.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 028A37362ADFF404008CA604 /* WhatsNew.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 028A37352ADFF404008CA604 /* WhatsNew.framework */; }; + 028A37372ADFF404008CA604 /* WhatsNew.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 028A37352ADFF404008CA604 /* WhatsNew.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 0293A2032A6FCA590090A336 /* CorePersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0293A2022A6FCA590090A336 /* CorePersistence.swift */; }; 0293A2052A6FCD430090A336 /* CoursePersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0293A2042A6FCD430090A336 /* CoursePersistence.swift */; }; 0293A2072A6FCDA30090A336 /* DiscoveryPersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0293A2062A6FCDA30090A336 /* DiscoveryPersistence.swift */; }; @@ -59,6 +61,7 @@ 0219C67828F4347600D64452 /* Course.framework in Embed Frameworks */, 025DE1A528DB4DAE0053E0F4 /* Profile.framework in Embed Frameworks */, 027DB33128D8A063002B6862 /* Dashboard.framework in Embed Frameworks */, + 028A37372ADFF404008CA604 /* WhatsNew.framework in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; @@ -75,6 +78,7 @@ 025DE1A328DB4DAE0053E0F4 /* Profile.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Profile.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 025EF2F7297177F300B838AB /* OpenEdX.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = OpenEdX.entitlements; sourceTree = ""; }; 027DB32F28D8A063002B6862 /* Dashboard.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Dashboard.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 028A37352ADFF404008CA604 /* WhatsNew.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = WhatsNew.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 0293A2022A6FCA590090A336 /* CorePersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CorePersistence.swift; sourceTree = ""; }; 0293A2042A6FCD430090A336 /* CoursePersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoursePersistence.swift; sourceTree = ""; }; 0293A2062A6FCDA30090A336 /* DiscoveryPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryPersistence.swift; sourceTree = ""; }; @@ -118,6 +122,7 @@ buildActionMask = 2147483647; files = ( 07A7D78F28F5C9060000BE81 /* Core.framework in Frameworks */, + 028A37362ADFF404008CA604 /* WhatsNew.framework in Frameworks */, 025DE1A428DB4DAE0053E0F4 /* Profile.framework in Frameworks */, 0770DE4B28D0A462006D8A5D /* Authorization.framework in Frameworks */, 072787B128D34D83002E9142 /* Discovery.framework in Frameworks */, @@ -206,6 +211,7 @@ 4E6FB43543890E90BB88D64D /* Frameworks */ = { isa = PBXGroup; children = ( + 028A37352ADFF404008CA604 /* WhatsNew.framework */, 0218196328F734FA00202564 /* Discussion.framework */, 07A7D78E28F5C9060000BE81 /* Core.framework */, 0219C67628F4347600D64452 /* Course.framework */, diff --git a/OpenEdX.xcodeproj/xcshareddata/xcschemes/OpenEdXDev.xcscheme b/OpenEdX.xcodeproj/xcshareddata/xcschemes/OpenEdXDev.xcscheme index c2f6ffa2b..55135d8fa 100644 --- a/OpenEdX.xcodeproj/xcshareddata/xcschemes/OpenEdXDev.xcscheme +++ b/OpenEdX.xcodeproj/xcshareddata/xcschemes/OpenEdXDev.xcscheme @@ -97,13 +97,22 @@ ReferencedContainer = "container:Profile/Profile.xcodeproj"> + + + + + + diff --git a/OpenEdX/DI/AppAssembly.swift b/OpenEdX/DI/AppAssembly.swift index edc74bd1f..9c2bff47e 100644 --- a/OpenEdX/DI/AppAssembly.swift +++ b/OpenEdX/DI/AppAssembly.swift @@ -15,6 +15,7 @@ import Course import Discussion import Authorization import Profile +import WhatsNew // swiftlint:disable function_body_length class AppAssembly: Assembly { @@ -112,6 +113,10 @@ class AppAssembly: Assembly { r.resolve(Router.self)! }.inObjectScope(.container) + container.register(WhatsNewRouter.self) { r in + r.resolve(Router.self)! + }.inObjectScope(.container) + container.register(Config.self) { _ in Config(baseURL: BuildConfiguration.shared.baseURL, oAuthClientId: BuildConfiguration.shared.clientId) }.inObjectScope(.container) @@ -139,6 +144,10 @@ class AppAssembly: Assembly { r.resolve(AppStorage.self)! }.inObjectScope(.container) + container.register(WhatsNewStorage.self) { r in + r.resolve(AppStorage.self)! + }.inObjectScope(.container) + container.register(ProfileStorage.self) { r in r.resolve(AppStorage.self)! }.inObjectScope(.container) diff --git a/OpenEdX/Data/AppStorage.swift b/OpenEdX/Data/AppStorage.swift index 99144be00..0815b4b7f 100644 --- a/OpenEdX/Data/AppStorage.swift +++ b/OpenEdX/Data/AppStorage.swift @@ -9,8 +9,9 @@ import Foundation import KeychainSwift import Core import Profile +import WhatsNew -public class AppStorage: CoreStorage, ProfileStorage { +public class AppStorage: CoreStorage, ProfileStorage, WhatsNewStorage { private let keychain: KeychainSwift private let userDefaults: UserDefaults @@ -58,6 +59,19 @@ public class AppStorage: CoreStorage, ProfileStorage { } } } + + public var whatsNewVersion: String? { + get { + return userDefaults.string(forKey: KEY_WHATSNEW_VERSION) + } + set(newValue) { + if let newValue { + userDefaults.set(newValue, forKey: KEY_WHATSNEW_VERSION) + } else { + userDefaults.removeObject(forKey: KEY_WHATSNEW_VERSION) + } + } + } public var userProfile: DataLayer.UserProfile? { get { @@ -134,4 +148,5 @@ public class AppStorage: CoreStorage, ProfileStorage { private let KEY_USER_PROFILE = "userProfile" private let KEY_USER = "refreshToken" private let KEY_SETTINGS = "userSettings" + private let KEY_WHATSNEW_VERSION = "whatsNewVersion" } diff --git a/OpenEdX/RouteController.swift b/OpenEdX/RouteController.swift index 1aa036dc1..367569cf6 100644 --- a/OpenEdX/RouteController.swift +++ b/OpenEdX/RouteController.swift @@ -9,6 +9,8 @@ import UIKit import SwiftUI import Core import Authorization +import WhatsNew +import Swinject class RouteController: UIViewController { @@ -30,7 +32,7 @@ class RouteController: UIViewController { if let user = appStorage.user, appStorage.accessToken != nil { analytics.setUserID("\(user.id)") DispatchQueue.main.async { - self.showMainScreen() + self.showMainOrWhatsNewScreen() } } else { DispatchQueue.main.async { @@ -47,9 +49,27 @@ class RouteController: UIViewController { present(navigation, animated: false) } - private func showMainScreen() { - let controller = UIHostingController(rootView: MainScreenView()) - navigation.viewControllers = [controller] + private func showMainOrWhatsNewScreen() { + var storage = Container.shared.resolve(WhatsNewStorage.self)! + let config = Container.shared.resolve(Config.self)! + + let viewModel = WhatsNewViewModel(storage: storage) + let shouldShowWhatsNew = viewModel.shouldShowWhatsNew() + + if shouldShowWhatsNew && config.whatsNewEnabled { + if let jsonVersion = viewModel.getVersion() { + storage.whatsNewVersion = jsonVersion + } + let whatsNewView = WhatsNewView( + router: Container.shared.resolve(WhatsNewRouter.self)!, + viewModel: viewModel + ) + let controller = UIHostingController(rootView: whatsNewView) + navigation.viewControllers = [controller] + } else { + let controller = UIHostingController(rootView: MainScreenView()) + navigation.viewControllers = [controller] + } present(navigation, animated: false) } } diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index 58413b862..1dbb690d8 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -16,9 +16,11 @@ import Discussion import Discovery import Dashboard import Profile +import WhatsNew import Combine public class Router: AuthorizationRouter, + WhatsNewRouter, DiscoveryRouter, ProfileRouter, DashboardRouter, @@ -56,10 +58,27 @@ public class Router: AuthorizationRouter, navigationController.setViewControllers(viewControllers, animated: true) } - public func showMainScreen() { + public func showMainOrWhatsNewScreen() { showToolBar() - let controller = UIHostingController(rootView: MainScreenView()) - navigationController.setViewControllers([controller], animated: true) + var storage = Container.shared.resolve(WhatsNewStorage.self)! + let config = Container.shared.resolve(Config.self)! + + let viewModel = WhatsNewViewModel(storage: storage) + let whatsNew = WhatsNewView(router: Container.shared.resolve(WhatsNewRouter.self)!, viewModel: viewModel) + let shouldShowWhatsNew = viewModel.shouldShowWhatsNew() + + if shouldShowWhatsNew && config.whatsNewEnabled { + if let jsonVersion = viewModel.getVersion() { + storage.whatsNewVersion = jsonVersion + } + let controller = UIHostingController(rootView: whatsNew) + navigationController.viewControllers = [controller] + navigationController.setViewControllers([controller], animated: true) + } else { + let controller = UIHostingController(rootView: MainScreenView()) + navigationController.viewControllers = [controller] + navigationController.setViewControllers([controller], animated: true) + } } public func showLoginScreen() { diff --git a/OpenEdX/View/MainScreenView.swift b/OpenEdX/View/MainScreenView.swift index c27d7ea3f..e05451782 100644 --- a/OpenEdX/View/MainScreenView.swift +++ b/OpenEdX/View/MainScreenView.swift @@ -11,6 +11,7 @@ import Core import Swinject import Dashboard import Profile +import WhatsNew import SwiftUIIntrospect struct MainScreenView: View { diff --git a/Podfile b/Podfile index 1d97fa467..c3658683d 100644 --- a/Podfile +++ b/Podfile @@ -48,6 +48,15 @@ abstract_target "App" do end end + target "WhatsNew" do + project './WhatsNew/WhatsNew.xcodeproj' + workspace './WhatsNew/WhatsNew.xcodeproj' + + target 'WhatsNewTests' do + pod 'SwiftyMocky', :git => 'https://github.com/MakeAWishFoundation/SwiftyMocky.git', :tag => '4.2.0' + end + end + target "Dashboard" do project './Dashboard/Dashboard.xcodeproj' workspace './Dashboard/Dashboard.xcodeproj' diff --git a/Podfile.lock b/Podfile.lock index 5b998cd5e..cf6c60e94 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -180,6 +180,6 @@ SPEC CHECKSUMS: SwiftyMocky: c5e96e4ff76ec6dbf5a5941aeb039b5a546954a0 Swinject: 893c9a543000ac2f10ee4cbaf0933c6992c935d5 -PODFILE CHECKSUM: 1639b311802f5d36686512914067b7221ff97a64 +PODFILE CHECKSUM: a44d8de5a5803eb3e3c995134c79c3dad959dbf7 COCOAPODS: 1.12.1 diff --git a/Profile/ProfileTests/ProfileMock.generated.swift b/Profile/ProfileTests/ProfileMock.generated.swift index e8f39508d..58466c37c 100644 --- a/Profile/ProfileTests/ProfileMock.generated.swift +++ b/Profile/ProfileTests/ProfileMock.generated.swift @@ -490,9 +490,9 @@ open class BaseRouterMock: BaseRouter, Mock { perform?(`controllers`) } - open func showMainScreen() { - addInvocation(.m_showMainScreen) - let perform = methodPerformValue(.m_showMainScreen) as? () -> Void + open func showMainOrWhatsNewScreen() { + addInvocation(.m_showMainOrWhatsNewScreen) + let perform = methodPerformValue(.m_showMainOrWhatsNewScreen) as? () -> Void perform?() } @@ -545,7 +545,7 @@ open class BaseRouterMock: BaseRouter, Mock { case m_backWithFade case m_dismiss__animated_animated(Parameter) case m_removeLastView__controllers_controllers(Parameter) - case m_showMainScreen + case m_showMainOrWhatsNewScreen case m_showLoginScreen case m_showRegisterScreen case m_showForgotPasswordScreen @@ -578,7 +578,7 @@ open class BaseRouterMock: BaseRouter, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsControllers, rhs: rhsControllers, with: matcher), lhsControllers, rhsControllers, "controllers")) return Matcher.ComparisonResult(results) - case (.m_showMainScreen, .m_showMainScreen): return .match + case (.m_showMainOrWhatsNewScreen, .m_showMainOrWhatsNewScreen): return .match case (.m_showLoginScreen, .m_showLoginScreen): return .match @@ -630,7 +630,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_backWithFade: return 0 case let .m_dismiss__animated_animated(p0): return p0.intValue case let .m_removeLastView__controllers_controllers(p0): return p0.intValue - case .m_showMainScreen: return 0 + case .m_showMainOrWhatsNewScreen: return 0 case .m_showLoginScreen: return 0 case .m_showRegisterScreen: return 0 case .m_showForgotPasswordScreen: return 0 @@ -647,7 +647,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_backWithFade: return ".backWithFade()" case .m_dismiss__animated_animated: return ".dismiss(animated:)" case .m_removeLastView__controllers_controllers: return ".removeLastView(controllers:)" - case .m_showMainScreen: return ".showMainScreen()" + case .m_showMainOrWhatsNewScreen: return ".showMainOrWhatsNewScreen()" case .m_showLoginScreen: return ".showLoginScreen()" case .m_showRegisterScreen: return ".showRegisterScreen()" case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" @@ -678,7 +678,7 @@ open class BaseRouterMock: BaseRouter, Mock { public static func backWithFade() -> Verify { return Verify(method: .m_backWithFade)} public static func dismiss(animated: Parameter) -> Verify { return Verify(method: .m_dismiss__animated_animated(`animated`))} public static func removeLastView(controllers: Parameter) -> Verify { return Verify(method: .m_removeLastView__controllers_controllers(`controllers`))} - public static func showMainScreen() -> Verify { return Verify(method: .m_showMainScreen)} + public static func showMainOrWhatsNewScreen() -> Verify { return Verify(method: .m_showMainOrWhatsNewScreen)} public static func showLoginScreen() -> Verify { return Verify(method: .m_showLoginScreen)} public static func showRegisterScreen() -> Verify { return Verify(method: .m_showRegisterScreen)} public static func showForgotPasswordScreen() -> Verify { return Verify(method: .m_showForgotPasswordScreen)} @@ -707,8 +707,8 @@ open class BaseRouterMock: BaseRouter, Mock { public static func removeLastView(controllers: Parameter, perform: @escaping (Int) -> Void) -> Perform { return Perform(method: .m_removeLastView__controllers_controllers(`controllers`), performs: perform) } - public static func showMainScreen(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_showMainScreen, performs: perform) + public static func showMainOrWhatsNewScreen(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_showMainOrWhatsNewScreen, performs: perform) } public static func showLoginScreen(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_showLoginScreen, performs: perform) @@ -2370,9 +2370,9 @@ open class ProfileRouterMock: ProfileRouter, Mock { perform?(`controllers`) } - open func showMainScreen() { - addInvocation(.m_showMainScreen) - let perform = methodPerformValue(.m_showMainScreen) as? () -> Void + open func showMainOrWhatsNewScreen() { + addInvocation(.m_showMainOrWhatsNewScreen) + let perform = methodPerformValue(.m_showMainOrWhatsNewScreen) as? () -> Void perform?() } @@ -2429,7 +2429,7 @@ open class ProfileRouterMock: ProfileRouter, Mock { case m_backWithFade case m_dismiss__animated_animated(Parameter) case m_removeLastView__controllers_controllers(Parameter) - case m_showMainScreen + case m_showMainOrWhatsNewScreen case m_showLoginScreen case m_showRegisterScreen case m_showForgotPasswordScreen @@ -2478,7 +2478,7 @@ open class ProfileRouterMock: ProfileRouter, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsControllers, rhs: rhsControllers, with: matcher), lhsControllers, rhsControllers, "controllers")) return Matcher.ComparisonResult(results) - case (.m_showMainScreen, .m_showMainScreen): return .match + case (.m_showMainOrWhatsNewScreen, .m_showMainOrWhatsNewScreen): return .match case (.m_showLoginScreen, .m_showLoginScreen): return .match @@ -2534,7 +2534,7 @@ open class ProfileRouterMock: ProfileRouter, Mock { case .m_backWithFade: return 0 case let .m_dismiss__animated_animated(p0): return p0.intValue case let .m_removeLastView__controllers_controllers(p0): return p0.intValue - case .m_showMainScreen: return 0 + case .m_showMainOrWhatsNewScreen: return 0 case .m_showLoginScreen: return 0 case .m_showRegisterScreen: return 0 case .m_showForgotPasswordScreen: return 0 @@ -2555,7 +2555,7 @@ open class ProfileRouterMock: ProfileRouter, Mock { case .m_backWithFade: return ".backWithFade()" case .m_dismiss__animated_animated: return ".dismiss(animated:)" case .m_removeLastView__controllers_controllers: return ".removeLastView(controllers:)" - case .m_showMainScreen: return ".showMainScreen()" + case .m_showMainOrWhatsNewScreen: return ".showMainOrWhatsNewScreen()" case .m_showLoginScreen: return ".showLoginScreen()" case .m_showRegisterScreen: return ".showRegisterScreen()" case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" @@ -2590,7 +2590,7 @@ open class ProfileRouterMock: ProfileRouter, Mock { public static func backWithFade() -> Verify { return Verify(method: .m_backWithFade)} public static func dismiss(animated: Parameter) -> Verify { return Verify(method: .m_dismiss__animated_animated(`animated`))} public static func removeLastView(controllers: Parameter) -> Verify { return Verify(method: .m_removeLastView__controllers_controllers(`controllers`))} - public static func showMainScreen() -> Verify { return Verify(method: .m_showMainScreen)} + public static func showMainOrWhatsNewScreen() -> Verify { return Verify(method: .m_showMainOrWhatsNewScreen)} public static func showLoginScreen() -> Verify { return Verify(method: .m_showLoginScreen)} public static func showRegisterScreen() -> Verify { return Verify(method: .m_showRegisterScreen)} public static func showForgotPasswordScreen() -> Verify { return Verify(method: .m_showForgotPasswordScreen)} @@ -2631,8 +2631,8 @@ open class ProfileRouterMock: ProfileRouter, Mock { public static func removeLastView(controllers: Parameter, perform: @escaping (Int) -> Void) -> Perform { return Perform(method: .m_removeLastView__controllers_controllers(`controllers`), performs: perform) } - public static func showMainScreen(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_showMainScreen, performs: perform) + public static func showMainOrWhatsNewScreen(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_showMainOrWhatsNewScreen, performs: perform) } public static func showLoginScreen(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_showLoginScreen, performs: perform) diff --git a/WhatsNew/.gitignore b/WhatsNew/.gitignore new file mode 100644 index 000000000..9c22c8b85 --- /dev/null +++ b/WhatsNew/.gitignore @@ -0,0 +1,99 @@ +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## User settings +xcuserdata/* +/WhatsNew.xcodeproj/xcuserdata/ +/WhatsNew.xcworkspace/xcuserdata/ + +## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) +*.xcscmblueprint +*.xccheckout + +## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) +build/ +DerivedData/ +*.moved-aside +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 + +## Obj-C/Swift specific +*.hmap + +## App packaging +*.ipa +*.dSYM.zip +*.dSYM + +## R.swift +R.generated.swift + +## Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Swift Package Manager +# +# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. +# Packages/ +# Package.pins +# Package.resolved +# *.xcodeproj +# +# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata +# hence it is not needed unless you have added a package configuration file to your project +# .swiftpm + +.build/ + +# CocoaPods +# +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# +Pods/ +# +# Add this line if you want to avoid checking in source code from the Xcode workspace +# *.xcworkspace + +# Carthage +# +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build/ + +# Accio dependency management +Dependencies/ +.accio/ + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. +# Instead, use fastlane to re-generate the screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/#source-control + +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output + +# Code Injection +# +# After new code Injection tools there's a generated folder /iOSInjectionProject +# https://github.com/johnno1962/injectionforxcode + +iOSInjectionProject/ + +.DS_Store +.idea +xcode-frameworks diff --git a/WhatsNew/Mockfile b/WhatsNew/Mockfile new file mode 100644 index 000000000..0b15b3b93 --- /dev/null +++ b/WhatsNew/Mockfile @@ -0,0 +1,17 @@ +sourceryCommand: null +sourceryTemplate: null +unit.tests.mock: + sources: + include: + - ./../WhatsNew + - ./WhatsNew + exclude: [] + output: ./WhatsNewTests/WhatsNewMock.generated.swift + targets: + - MyAppUnitTests + import: + - Core + - WhatsNew + - Foundation + - SwiftUI + - Combine \ No newline at end of file diff --git a/WhatsNew/WhatsNew.xcodeproj/project.pbxproj b/WhatsNew/WhatsNew.xcodeproj/project.pbxproj new file mode 100644 index 000000000..dad830243 --- /dev/null +++ b/WhatsNew/WhatsNew.xcodeproj/project.pbxproj @@ -0,0 +1,1499 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 55; + objects = { + +/* Begin PBXBuildFile section */ + 020A7B5F2AE131A9000BAF70 /* WhatsNewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 020A7B5E2AE131A9000BAF70 /* WhatsNewModel.swift */; }; + 020A7B612AE136D2000BAF70 /* WhatsNew.json in Resources */ = {isa = PBXBuildFile; fileRef = 020A7B602AE136D2000BAF70 /* WhatsNew.json */; }; + 020AC2692AEBB69E0086E975 /* WhatsNewMock.generated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 020AC2682AEBB69E0086E975 /* WhatsNewMock.generated.swift */; }; + 028A37262ADFF3F8008CA604 /* WhatsNew.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 028A371D2ADFF3F7008CA604 /* WhatsNew.framework */; }; + 028A372B2ADFF3F8008CA604 /* WhatsNewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 028A372A2ADFF3F8008CA604 /* WhatsNewTests.swift */; }; + 028A373A2ADFF425008CA604 /* Core.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 028A37392ADFF425008CA604 /* Core.framework */; }; + 02B54E0D2AE0331F00C56962 /* WhatsNewNavigationButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B54E0C2AE0331F00C56962 /* WhatsNewNavigationButton.swift */; }; + 02B54E0F2AE0337800C56962 /* PageControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B54E0E2AE0337800C56962 /* PageControl.swift */; }; + 02B54E112AE061C100C56962 /* WhatsNewRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B54E102AE061C100C56962 /* WhatsNewRouter.swift */; }; + 02E640792ADFF5920079AEDA /* WhatsNewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02E640782ADFF5920079AEDA /* WhatsNewView.swift */; }; + 02E6407C2ADFF6250079AEDA /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 02E6407E2ADFF6250079AEDA /* Localizable.strings */; }; + 02E640812ADFFE440079AEDA /* WhatsNewViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02E640802ADFFE440079AEDA /* WhatsNewViewModel.swift */; }; + 02E640862ADFFF380079AEDA /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 02E640852ADFFF380079AEDA /* Assets.xcassets */; }; + 02E6408A2AE004300079AEDA /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02E640892AE004300079AEDA /* Strings.swift */; }; + 02E6408C2AE006680079AEDA /* swiftgen.yml in Resources */ = {isa = PBXBuildFile; fileRef = 02E6408B2AE006680079AEDA /* swiftgen.yml */; }; + 02EC90AA2AE904E1007DE1E0 /* WhatsNewStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02EC90A92AE904E1007DE1E0 /* WhatsNewStorage.swift */; }; + 02EC90AC2AE90C64007DE1E0 /* WhatsNewPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02EC90AB2AE90C64007DE1E0 /* WhatsNewPage.swift */; }; + B3BB9B06B226989A619C6440 /* Pods_App_WhatsNew.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 05AC45C7050E30F8394E0C76 /* Pods_App_WhatsNew.framework */; }; + EF5CA11A55CB49F2DA030D25 /* Pods_App_WhatsNew_WhatsNewTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F8D1A5DF016EC4630637336C /* Pods_App_WhatsNew_WhatsNewTests.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 028A37272ADFF3F8008CA604 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 028A37142ADFF3F7008CA604 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 028A371C2ADFF3F7008CA604; + remoteInfo = WhatsNew; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 020A7B5E2AE131A9000BAF70 /* WhatsNewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhatsNewModel.swift; sourceTree = ""; }; + 020A7B602AE136D2000BAF70 /* WhatsNew.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; name = WhatsNew.json; path = WhatsNew/Data/WhatsNew.json; sourceTree = SOURCE_ROOT; }; + 020AC2682AEBB69E0086E975 /* WhatsNewMock.generated.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = WhatsNewMock.generated.swift; path = WhatsNewTests/WhatsNewMock.generated.swift; sourceTree = SOURCE_ROOT; }; + 028A371D2ADFF3F7008CA604 /* WhatsNew.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = WhatsNew.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 028A37252ADFF3F7008CA604 /* WhatsNewTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = WhatsNewTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 028A372A2ADFF3F8008CA604 /* WhatsNewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhatsNewTests.swift; sourceTree = ""; }; + 028A37392ADFF425008CA604 /* Core.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Core.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 02B54E0C2AE0331F00C56962 /* WhatsNewNavigationButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhatsNewNavigationButton.swift; sourceTree = ""; }; + 02B54E0E2AE0337800C56962 /* PageControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageControl.swift; sourceTree = ""; }; + 02B54E102AE061C100C56962 /* WhatsNewRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhatsNewRouter.swift; sourceTree = ""; }; + 02E640782ADFF5920079AEDA /* WhatsNewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhatsNewView.swift; sourceTree = ""; }; + 02E6407D2ADFF6250079AEDA /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; + 02E6407F2ADFF6270079AEDA /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = ""; }; + 02E640802ADFFE440079AEDA /* WhatsNewViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhatsNewViewModel.swift; sourceTree = ""; }; + 02E640852ADFFF380079AEDA /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 02E640892AE004300079AEDA /* Strings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Strings.swift; sourceTree = ""; }; + 02E6408B2AE006680079AEDA /* swiftgen.yml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.yaml; path = swiftgen.yml; sourceTree = ""; }; + 02EC90A92AE904E1007DE1E0 /* WhatsNewStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhatsNewStorage.swift; sourceTree = ""; }; + 02EC90AB2AE90C64007DE1E0 /* WhatsNewPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhatsNewPage.swift; sourceTree = ""; }; + 02EC90B12AE91BF1007DE1E0 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 05AC45C7050E30F8394E0C76 /* Pods_App_WhatsNew.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App_WhatsNew.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 0C01007F0E8CEDCD293E0A68 /* Pods-App-WhatsNew.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-WhatsNew.debug.xcconfig"; path = "Target Support Files/Pods-App-WhatsNew/Pods-App-WhatsNew.debug.xcconfig"; sourceTree = ""; }; + 1E3F4487E7D3A48F5FD12DDA /* Pods-App-WhatsNew-WhatsNewTests.releasestage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-WhatsNew-WhatsNewTests.releasestage.xcconfig"; path = "Target Support Files/Pods-App-WhatsNew-WhatsNewTests/Pods-App-WhatsNew-WhatsNewTests.releasestage.xcconfig"; sourceTree = ""; }; + 34C1F2BEAF7F0DCB8E630F33 /* Pods-App-WhatsNew.releasestage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-WhatsNew.releasestage.xcconfig"; path = "Target Support Files/Pods-App-WhatsNew/Pods-App-WhatsNew.releasestage.xcconfig"; sourceTree = ""; }; + 365FD817D70DFBCBDE2EAE5F /* Pods-App-WhatsNew-WhatsNewTests.releaseprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-WhatsNew-WhatsNewTests.releaseprod.xcconfig"; path = "Target Support Files/Pods-App-WhatsNew-WhatsNewTests/Pods-App-WhatsNew-WhatsNewTests.releaseprod.xcconfig"; sourceTree = ""; }; + 4CB92C9DBA730A1B06B076BA /* Pods-App-WhatsNew.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-WhatsNew.release.xcconfig"; path = "Target Support Files/Pods-App-WhatsNew/Pods-App-WhatsNew.release.xcconfig"; sourceTree = ""; }; + 58DF8140E3B3436F58C4C8B9 /* Pods-App-WhatsNew-WhatsNewTests.releasedev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-WhatsNew-WhatsNewTests.releasedev.xcconfig"; path = "Target Support Files/Pods-App-WhatsNew-WhatsNewTests/Pods-App-WhatsNew-WhatsNewTests.releasedev.xcconfig"; sourceTree = ""; }; + 6F50A409FBCCC7C08712A25E /* Pods-App-WhatsNew.debugdev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-WhatsNew.debugdev.xcconfig"; path = "Target Support Files/Pods-App-WhatsNew/Pods-App-WhatsNew.debugdev.xcconfig"; sourceTree = ""; }; + 9176CDC000731B73D2F10372 /* Pods-App-WhatsNew.debugstage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-WhatsNew.debugstage.xcconfig"; path = "Target Support Files/Pods-App-WhatsNew/Pods-App-WhatsNew.debugstage.xcconfig"; sourceTree = ""; }; + 9844714991FA40ECDC228CC9 /* Pods-App-WhatsNew.releasedev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-WhatsNew.releasedev.xcconfig"; path = "Target Support Files/Pods-App-WhatsNew/Pods-App-WhatsNew.releasedev.xcconfig"; sourceTree = ""; }; + A2CABA11F5E7F89EFD9A05AC /* Pods-App-WhatsNew-WhatsNewTests.debugstage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-WhatsNew-WhatsNewTests.debugstage.xcconfig"; path = "Target Support Files/Pods-App-WhatsNew-WhatsNewTests/Pods-App-WhatsNew-WhatsNewTests.debugstage.xcconfig"; sourceTree = ""; }; + A557A3CED4D6327AAE6AA02C /* Pods-App-WhatsNew-WhatsNewTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-WhatsNew-WhatsNewTests.release.xcconfig"; path = "Target Support Files/Pods-App-WhatsNew-WhatsNewTests/Pods-App-WhatsNew-WhatsNewTests.release.xcconfig"; sourceTree = ""; }; + A76975E21FF282D59CEC4452 /* Pods-App-WhatsNew.debugprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-WhatsNew.debugprod.xcconfig"; path = "Target Support Files/Pods-App-WhatsNew/Pods-App-WhatsNew.debugprod.xcconfig"; sourceTree = ""; }; + AB8156676C9C771D691ADE07 /* Pods-App-WhatsNew.releaseprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-WhatsNew.releaseprod.xcconfig"; path = "Target Support Files/Pods-App-WhatsNew/Pods-App-WhatsNew.releaseprod.xcconfig"; sourceTree = ""; }; + B7EAC5E8F0ED2F1F81050C30 /* Pods-App-WhatsNew-WhatsNewTests.debugprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-WhatsNew-WhatsNewTests.debugprod.xcconfig"; path = "Target Support Files/Pods-App-WhatsNew-WhatsNewTests/Pods-App-WhatsNew-WhatsNewTests.debugprod.xcconfig"; sourceTree = ""; }; + E905D28DCAA1940E08C96896 /* Pods-App-WhatsNew-WhatsNewTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-WhatsNew-WhatsNewTests.debug.xcconfig"; path = "Target Support Files/Pods-App-WhatsNew-WhatsNewTests/Pods-App-WhatsNew-WhatsNewTests.debug.xcconfig"; sourceTree = ""; }; + F71207762CEF1763A08C7151 /* Pods-App-WhatsNew-WhatsNewTests.debugdev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-WhatsNew-WhatsNewTests.debugdev.xcconfig"; path = "Target Support Files/Pods-App-WhatsNew-WhatsNewTests/Pods-App-WhatsNew-WhatsNewTests.debugdev.xcconfig"; sourceTree = ""; }; + F8D1A5DF016EC4630637336C /* Pods_App_WhatsNew_WhatsNewTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App_WhatsNew_WhatsNewTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 028A371A2ADFF3F7008CA604 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 028A373A2ADFF425008CA604 /* Core.framework in Frameworks */, + B3BB9B06B226989A619C6440 /* Pods_App_WhatsNew.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 028A37222ADFF3F7008CA604 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 028A37262ADFF3F8008CA604 /* WhatsNew.framework in Frameworks */, + EF5CA11A55CB49F2DA030D25 /* Pods_App_WhatsNew_WhatsNewTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 020A7B5D2AE1317E000BAF70 /* Domain */ = { + isa = PBXGroup; + children = ( + 02EC90AB2AE90C64007DE1E0 /* WhatsNewPage.swift */, + ); + path = Domain; + sourceTree = ""; + }; + 028A37132ADFF3F7008CA604 = { + isa = PBXGroup; + children = ( + 02E6408B2AE006680079AEDA /* swiftgen.yml */, + 028A371F2ADFF3F7008CA604 /* WhatsNew */, + 028A37292ADFF3F8008CA604 /* WhatsNewTests */, + 028A371E2ADFF3F7008CA604 /* Products */, + 028A37382ADFF425008CA604 /* Frameworks */, + 3397DFC72A3A62728BCA5367 /* Pods */, + ); + sourceTree = ""; + }; + 028A371E2ADFF3F7008CA604 /* Products */ = { + isa = PBXGroup; + children = ( + 028A371D2ADFF3F7008CA604 /* WhatsNew.framework */, + 028A37252ADFF3F7008CA604 /* WhatsNewTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 028A371F2ADFF3F7008CA604 /* WhatsNew */ = { + isa = PBXGroup; + children = ( + 02E640722ADFF54E0079AEDA /* SwiftGen */, + 02E640822ADFFEB00079AEDA /* Data */, + 020A7B5D2AE1317E000BAF70 /* Domain */, + 02E640752ADFF5700079AEDA /* Presentation */, + 02E6407E2ADFF6250079AEDA /* Localizable.strings */, + 02E640852ADFFF380079AEDA /* Assets.xcassets */, + 02EC90B12AE91BF1007DE1E0 /* Info.plist */, + ); + path = WhatsNew; + sourceTree = ""; + }; + 028A37292ADFF3F8008CA604 /* WhatsNewTests */ = { + isa = PBXGroup; + children = ( + 02EC90B52AE92AEB007DE1E0 /* Presentation */, + 020AC2682AEBB69E0086E975 /* WhatsNewMock.generated.swift */, + ); + path = WhatsNewTests; + sourceTree = ""; + }; + 028A37382ADFF425008CA604 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 028A37392ADFF425008CA604 /* Core.framework */, + 05AC45C7050E30F8394E0C76 /* Pods_App_WhatsNew.framework */, + F8D1A5DF016EC4630637336C /* Pods_App_WhatsNew_WhatsNewTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 02B54E0B2AE0330F00C56962 /* Elements */ = { + isa = PBXGroup; + children = ( + 02B54E0C2AE0331F00C56962 /* WhatsNewNavigationButton.swift */, + 02B54E0E2AE0337800C56962 /* PageControl.swift */, + ); + path = Elements; + sourceTree = ""; + }; + 02E640722ADFF54E0079AEDA /* SwiftGen */ = { + isa = PBXGroup; + children = ( + 02E640892AE004300079AEDA /* Strings.swift */, + ); + path = SwiftGen; + sourceTree = ""; + }; + 02E640752ADFF5700079AEDA /* Presentation */ = { + isa = PBXGroup; + children = ( + 02B54E0B2AE0330F00C56962 /* Elements */, + 02E640782ADFF5920079AEDA /* WhatsNewView.swift */, + 02E640802ADFFE440079AEDA /* WhatsNewViewModel.swift */, + 02B54E102AE061C100C56962 /* WhatsNewRouter.swift */, + ); + path = Presentation; + sourceTree = ""; + }; + 02E640822ADFFEB00079AEDA /* Data */ = { + isa = PBXGroup; + children = ( + 020A7B5E2AE131A9000BAF70 /* WhatsNewModel.swift */, + 020A7B602AE136D2000BAF70 /* WhatsNew.json */, + 02EC90A92AE904E1007DE1E0 /* WhatsNewStorage.swift */, + ); + path = Data; + sourceTree = ""; + }; + 02EC90B52AE92AEB007DE1E0 /* Presentation */ = { + isa = PBXGroup; + children = ( + 028A372A2ADFF3F8008CA604 /* WhatsNewTests.swift */, + ); + path = Presentation; + sourceTree = ""; + }; + 3397DFC72A3A62728BCA5367 /* Pods */ = { + isa = PBXGroup; + children = ( + 0C01007F0E8CEDCD293E0A68 /* Pods-App-WhatsNew.debug.xcconfig */, + 4CB92C9DBA730A1B06B076BA /* Pods-App-WhatsNew.release.xcconfig */, + 9176CDC000731B73D2F10372 /* Pods-App-WhatsNew.debugstage.xcconfig */, + A76975E21FF282D59CEC4452 /* Pods-App-WhatsNew.debugprod.xcconfig */, + 6F50A409FBCCC7C08712A25E /* Pods-App-WhatsNew.debugdev.xcconfig */, + 34C1F2BEAF7F0DCB8E630F33 /* Pods-App-WhatsNew.releasestage.xcconfig */, + AB8156676C9C771D691ADE07 /* Pods-App-WhatsNew.releaseprod.xcconfig */, + 9844714991FA40ECDC228CC9 /* Pods-App-WhatsNew.releasedev.xcconfig */, + E905D28DCAA1940E08C96896 /* Pods-App-WhatsNew-WhatsNewTests.debug.xcconfig */, + A2CABA11F5E7F89EFD9A05AC /* Pods-App-WhatsNew-WhatsNewTests.debugstage.xcconfig */, + B7EAC5E8F0ED2F1F81050C30 /* Pods-App-WhatsNew-WhatsNewTests.debugprod.xcconfig */, + F71207762CEF1763A08C7151 /* Pods-App-WhatsNew-WhatsNewTests.debugdev.xcconfig */, + A557A3CED4D6327AAE6AA02C /* Pods-App-WhatsNew-WhatsNewTests.release.xcconfig */, + 1E3F4487E7D3A48F5FD12DDA /* Pods-App-WhatsNew-WhatsNewTests.releasestage.xcconfig */, + 365FD817D70DFBCBDE2EAE5F /* Pods-App-WhatsNew-WhatsNewTests.releaseprod.xcconfig */, + 58DF8140E3B3436F58C4C8B9 /* Pods-App-WhatsNew-WhatsNewTests.releasedev.xcconfig */, + ); + name = Pods; + path = ../Pods; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXHeadersBuildPhase section */ + 028A37182ADFF3F7008CA604 /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXHeadersBuildPhase section */ + +/* Begin PBXNativeTarget section */ + 028A371C2ADFF3F7008CA604 /* WhatsNew */ = { + isa = PBXNativeTarget; + buildConfigurationList = 028A372F2ADFF3F8008CA604 /* Build configuration list for PBXNativeTarget "WhatsNew" */; + buildPhases = ( + E5055BD989FEEC50EF87C814 /* [CP] Check Pods Manifest.lock */, + 02E6408E2AE007090079AEDA /* SwiftGen */, + 028A37182ADFF3F7008CA604 /* Headers */, + 028A37192ADFF3F7008CA604 /* Sources */, + 028A371A2ADFF3F7008CA604 /* Frameworks */, + 028A371B2ADFF3F7008CA604 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = WhatsNew; + productName = WhatsNew; + productReference = 028A371D2ADFF3F7008CA604 /* WhatsNew.framework */; + productType = "com.apple.product-type.framework"; + }; + 028A37242ADFF3F7008CA604 /* WhatsNewTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 028A37322ADFF3F8008CA604 /* Build configuration list for PBXNativeTarget "WhatsNewTests" */; + buildPhases = ( + 8685C1ADA448B11AB167C40E /* [CP] Check Pods Manifest.lock */, + 028A37212ADFF3F7008CA604 /* Sources */, + 028A37222ADFF3F7008CA604 /* Frameworks */, + 028A37232ADFF3F7008CA604 /* Resources */, + 8A74692D666D8FF13F7BA64F /* [CP] Copy Pods Resources */, + ); + buildRules = ( + ); + dependencies = ( + 028A37282ADFF3F8008CA604 /* PBXTargetDependency */, + ); + name = WhatsNewTests; + productName = WhatsNewTests; + productReference = 028A37252ADFF3F7008CA604 /* WhatsNewTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 028A37142ADFF3F7008CA604 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1500; + LastUpgradeCheck = 1500; + TargetAttributes = { + 028A371C2ADFF3F7008CA604 = { + CreatedOnToolsVersion = 15.0; + LastSwiftMigration = 1500; + }; + 028A37242ADFF3F7008CA604 = { + CreatedOnToolsVersion = 15.0; + }; + }; + }; + buildConfigurationList = 028A37172ADFF3F7008CA604 /* Build configuration list for PBXProject "WhatsNew" */; + compatibilityVersion = "Xcode 13.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + uk, + ); + mainGroup = 028A37132ADFF3F7008CA604; + productRefGroup = 028A371E2ADFF3F7008CA604 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 028A371C2ADFF3F7008CA604 /* WhatsNew */, + 028A37242ADFF3F7008CA604 /* WhatsNewTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 028A371B2ADFF3F7008CA604 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 02E640862ADFFF380079AEDA /* Assets.xcassets in Resources */, + 02E6407C2ADFF6250079AEDA /* Localizable.strings in Resources */, + 020A7B612AE136D2000BAF70 /* WhatsNew.json in Resources */, + 02E6408C2AE006680079AEDA /* swiftgen.yml in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 028A37232ADFF3F7008CA604 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 02E6408E2AE007090079AEDA /* SwiftGen */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = SwiftGen; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if [[ -f \"${PODS_ROOT}/SwiftGen/bin/swiftgen\" ]]; then\n \"${PODS_ROOT}/SwiftGen/bin/swiftgen\"\nelse\n echo \"warning: SwiftGen is not installed. Run 'pod install --repo-update' to install it.\"\nfi\n"; + }; + 8685C1ADA448B11AB167C40E /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-App-WhatsNew-WhatsNewTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 8A74692D666D8FF13F7BA64F /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-App-WhatsNew-WhatsNewTests/Pods-App-WhatsNew-WhatsNewTests-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-App-WhatsNew-WhatsNewTests/Pods-App-WhatsNew-WhatsNewTests-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-App-WhatsNew-WhatsNewTests/Pods-App-WhatsNew-WhatsNewTests-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + E5055BD989FEEC50EF87C814 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-App-WhatsNew-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 028A37192ADFF3F7008CA604 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 02E640812ADFFE440079AEDA /* WhatsNewViewModel.swift in Sources */, + 02B54E112AE061C100C56962 /* WhatsNewRouter.swift in Sources */, + 020A7B5F2AE131A9000BAF70 /* WhatsNewModel.swift in Sources */, + 02B54E0F2AE0337800C56962 /* PageControl.swift in Sources */, + 02EC90AC2AE90C64007DE1E0 /* WhatsNewPage.swift in Sources */, + 02EC90AA2AE904E1007DE1E0 /* WhatsNewStorage.swift in Sources */, + 02E6408A2AE004300079AEDA /* Strings.swift in Sources */, + 02E640792ADFF5920079AEDA /* WhatsNewView.swift in Sources */, + 02B54E0D2AE0331F00C56962 /* WhatsNewNavigationButton.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 028A37212ADFF3F7008CA604 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 028A372B2ADFF3F8008CA604 /* WhatsNewTests.swift in Sources */, + 020AC2692AEBB69E0086E975 /* WhatsNewMock.generated.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 028A37282ADFF3F8008CA604 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 028A371C2ADFF3F7008CA604 /* WhatsNew */; + targetProxy = 028A37272ADFF3F8008CA604 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 02E6407E2ADFF6250079AEDA /* Localizable.strings */ = { + isa = PBXVariantGroup; + children = ( + 02E6407D2ADFF6250079AEDA /* en */, + 02E6407F2ADFF6270079AEDA /* uk */, + ); + name = Localizable.strings; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 028A372D2ADFF3F8008CA604 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Debug; + }; + 028A372E2ADFF3F8008CA604 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Release; + }; + 028A37302ADFF3F8008CA604 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 0C01007F0E8CEDCD293E0A68 /* Pods-App-WhatsNew.debug.xcconfig */; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = L8PG7LC3Y3; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = WhatsNew/Info.plist; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.WhatsNew; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 028A37312ADFF3F8008CA604 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 4CB92C9DBA730A1B06B076BA /* Pods-App-WhatsNew.release.xcconfig */; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = L8PG7LC3Y3; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = WhatsNew/Info.plist; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.WhatsNew; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 028A37332ADFF3F8008CA604 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = E905D28DCAA1940E08C96896 /* Pods-App-WhatsNew-WhatsNewTests.debug.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = L8PG7LC3Y3; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.app.WhatsNewTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 028A37342ADFF3F8008CA604 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A557A3CED4D6327AAE6AA02C /* Pods-App-WhatsNew-WhatsNewTests.release.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = L8PG7LC3Y3; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.app.WhatsNewTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 02E6405E2ADFF4DE0079AEDA /* DebugDev */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = DebugDev; + }; + 02E6405F2ADFF4DE0079AEDA /* DebugDev */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 6F50A409FBCCC7C08712A25E /* Pods-App-WhatsNew.debugdev.xcconfig */; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = L8PG7LC3Y3; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = WhatsNew/Info.plist; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.WhatsNew; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = DebugDev; + }; + 02E640602ADFF4DE0079AEDA /* DebugDev */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = F71207762CEF1763A08C7151 /* Pods-App-WhatsNew-WhatsNewTests.debugdev.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = L8PG7LC3Y3; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.app.WhatsNewTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = DebugDev; + }; + 02E640612ADFF4E50079AEDA /* DebugProd */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = DebugProd; + }; + 02E640622ADFF4E50079AEDA /* DebugProd */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A76975E21FF282D59CEC4452 /* Pods-App-WhatsNew.debugprod.xcconfig */; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = L8PG7LC3Y3; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = WhatsNew/Info.plist; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.WhatsNew; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = DebugProd; + }; + 02E640632ADFF4E50079AEDA /* DebugProd */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = B7EAC5E8F0ED2F1F81050C30 /* Pods-App-WhatsNew-WhatsNewTests.debugprod.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = L8PG7LC3Y3; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.app.WhatsNewTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = DebugProd; + }; + 02E640642ADFF4EA0079AEDA /* DebugStage */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = DebugStage; + }; + 02E640652ADFF4EA0079AEDA /* DebugStage */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9176CDC000731B73D2F10372 /* Pods-App-WhatsNew.debugstage.xcconfig */; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = L8PG7LC3Y3; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = WhatsNew/Info.plist; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.WhatsNew; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = DebugStage; + }; + 02E640662ADFF4EA0079AEDA /* DebugStage */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A2CABA11F5E7F89EFD9A05AC /* Pods-App-WhatsNew-WhatsNewTests.debugstage.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = L8PG7LC3Y3; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.app.WhatsNewTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = DebugStage; + }; + 02E640672ADFF4F10079AEDA /* ReleaseDev */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = ReleaseDev; + }; + 02E640682ADFF4F10079AEDA /* ReleaseDev */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9844714991FA40ECDC228CC9 /* Pods-App-WhatsNew.releasedev.xcconfig */; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = L8PG7LC3Y3; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = WhatsNew/Info.plist; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.WhatsNew; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = ReleaseDev; + }; + 02E640692ADFF4F10079AEDA /* ReleaseDev */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 58DF8140E3B3436F58C4C8B9 /* Pods-App-WhatsNew-WhatsNewTests.releasedev.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = L8PG7LC3Y3; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.app.WhatsNewTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = ReleaseDev; + }; + 02E6406A2ADFF4F70079AEDA /* ReleaseProd */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = ReleaseProd; + }; + 02E6406B2ADFF4F70079AEDA /* ReleaseProd */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = AB8156676C9C771D691ADE07 /* Pods-App-WhatsNew.releaseprod.xcconfig */; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = L8PG7LC3Y3; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = WhatsNew/Info.plist; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.WhatsNew; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = ReleaseProd; + }; + 02E6406C2ADFF4F70079AEDA /* ReleaseProd */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 365FD817D70DFBCBDE2EAE5F /* Pods-App-WhatsNew-WhatsNewTests.releaseprod.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = L8PG7LC3Y3; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.app.WhatsNewTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = ReleaseProd; + }; + 02E6406D2ADFF4FD0079AEDA /* ReleaseStage */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = ReleaseStage; + }; + 02E6406E2ADFF4FD0079AEDA /* ReleaseStage */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 34C1F2BEAF7F0DCB8E630F33 /* Pods-App-WhatsNew.releasestage.xcconfig */; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = L8PG7LC3Y3; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = WhatsNew/Info.plist; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.WhatsNew; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = ReleaseStage; + }; + 02E6406F2ADFF4FD0079AEDA /* ReleaseStage */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 1E3F4487E7D3A48F5FD12DDA /* Pods-App-WhatsNew-WhatsNewTests.releasestage.xcconfig */; + buildSettings = { + ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = L8PG7LC3Y3; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.openedx.app.WhatsNewTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = ReleaseStage; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 028A37172ADFF3F7008CA604 /* Build configuration list for PBXProject "WhatsNew" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 028A372D2ADFF3F8008CA604 /* Debug */, + 02E640642ADFF4EA0079AEDA /* DebugStage */, + 02E640612ADFF4E50079AEDA /* DebugProd */, + 02E6405E2ADFF4DE0079AEDA /* DebugDev */, + 028A372E2ADFF3F8008CA604 /* Release */, + 02E6406D2ADFF4FD0079AEDA /* ReleaseStage */, + 02E6406A2ADFF4F70079AEDA /* ReleaseProd */, + 02E640672ADFF4F10079AEDA /* ReleaseDev */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 028A372F2ADFF3F8008CA604 /* Build configuration list for PBXNativeTarget "WhatsNew" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 028A37302ADFF3F8008CA604 /* Debug */, + 02E640652ADFF4EA0079AEDA /* DebugStage */, + 02E640622ADFF4E50079AEDA /* DebugProd */, + 02E6405F2ADFF4DE0079AEDA /* DebugDev */, + 028A37312ADFF3F8008CA604 /* Release */, + 02E6406E2ADFF4FD0079AEDA /* ReleaseStage */, + 02E6406B2ADFF4F70079AEDA /* ReleaseProd */, + 02E640682ADFF4F10079AEDA /* ReleaseDev */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 028A37322ADFF3F8008CA604 /* Build configuration list for PBXNativeTarget "WhatsNewTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 028A37332ADFF3F8008CA604 /* Debug */, + 02E640662ADFF4EA0079AEDA /* DebugStage */, + 02E640632ADFF4E50079AEDA /* DebugProd */, + 02E640602ADFF4DE0079AEDA /* DebugDev */, + 028A37342ADFF3F8008CA604 /* Release */, + 02E6406F2ADFF4FD0079AEDA /* ReleaseStage */, + 02E6406C2ADFF4F70079AEDA /* ReleaseProd */, + 02E640692ADFF4F10079AEDA /* ReleaseDev */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 028A37142ADFF3F7008CA604 /* Project object */; +} diff --git a/WhatsNew/WhatsNew.xcodeproj/xcshareddata/xcschemes/WhatsNew.xcscheme b/WhatsNew/WhatsNew.xcodeproj/xcshareddata/xcschemes/WhatsNew.xcscheme new file mode 100644 index 000000000..c7efb774a --- /dev/null +++ b/WhatsNew/WhatsNew.xcodeproj/xcshareddata/xcschemes/WhatsNew.xcscheme @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WhatsNew/WhatsNew/Assets.xcassets/1.0/Contents.json b/WhatsNew/WhatsNew/Assets.xcassets/1.0/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/WhatsNew/WhatsNew/Assets.xcassets/1.0/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WhatsNew/WhatsNew/Assets.xcassets/1.0/image1_1.0.imageset/Contents.json b/WhatsNew/WhatsNew/Assets.xcassets/1.0/image1_1.0.imageset/Contents.json new file mode 100644 index 000000000..52b4c6b95 --- /dev/null +++ b/WhatsNew/WhatsNew/Assets.xcassets/1.0/image1_1.0.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Group 97.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "localizable" : true + } +} diff --git a/WhatsNew/WhatsNew/Assets.xcassets/1.0/image1_1.0.imageset/Group 97.png b/WhatsNew/WhatsNew/Assets.xcassets/1.0/image1_1.0.imageset/Group 97.png new file mode 100644 index 0000000000000000000000000000000000000000..1853bf8ab974208c185af4b82f5929f45b4a2683 GIT binary patch literal 34041 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%0Aoi=V$vAOLu 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 literal 0 HcmV?d00001 diff --git a/WhatsNew/WhatsNew/Assets.xcassets/Contents.json b/WhatsNew/WhatsNew/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/WhatsNew/WhatsNew/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WhatsNew/WhatsNew/Data/WhatsNew.json b/WhatsNew/WhatsNew/Data/WhatsNew.json new file mode 100644 index 000000000..355c720b6 --- /dev/null +++ b/WhatsNew/WhatsNew/Data/WhatsNew.json @@ -0,0 +1,52 @@ +[ + { + "version": "1.0", + "messages": [ + { + "image": "image1_1.0", + "title": "Improved language support", + "message": "We have added more translations throughout the app so you can learn on edX your way!" + }, + { + "image": "image2_1.0", + "title": "Download videos offline", + "message": "Easily download videos without having an internet connection, so you can keep learning when there isn’t a network around" + }, + { + "image": "image3_1.0", + "title": "Reduced Network Usage", + "message": "Now you can download your content faster to get right into your next lesson!" + }, + { + "image": "image4_1.0", + "title": "Learning Site Switching", + "message": "Switch more easily between multiple learning sites. Find the new options within account settings and easily manage your accounts" + } + ] + }, + { + "version": "0.9", + "messages": [ + { + "image": "image1_1.0", + "title": "1.3 Improved language support", + "message": "We have added more translations throughout the app so you can learn on edX your way!" + }, + { + "image": "image2_1.0", + "title": "Download videos offline", + "message": "Easily download videos without having an internet connection, so you can keep learning when there isn’t a network around" + }, + { + "image": "image3_1.0", + "title": "Reduced Network Usage", + "message": "Now you can download your content faster to get right into your next lesson!" + }, + { + "image": "image4_1.0", + "title": "Learning Site Switching", + "message": "Switch more easily between multiple learning sites. Find the new options within account settings and easily manage your accounts" + } + ] + } +] diff --git a/WhatsNew/WhatsNew/Data/WhatsNewModel.swift b/WhatsNew/WhatsNew/Data/WhatsNewModel.swift new file mode 100644 index 000000000..48302fb62 --- /dev/null +++ b/WhatsNew/WhatsNew/Data/WhatsNewModel.swift @@ -0,0 +1,69 @@ +// +// WhatsNewModel.swift +// WhatsNew +// +// Created by  Stepanok Ivan on 19.10.2023. +// + +import Foundation + +// MARK: - WhatsNewModelElement +public struct WhatsNewModelElement: Codable { + public let version: String + public let messages: [Message] + + public init(version: String, messages: [Message]) { + self.version = version + self.messages = messages + } +} + +// MARK: - Message +public struct Message: Codable { + public let image: String + public let title: String + public let message: String + + public init(image: String, title: String, message: String) { + self.image = image + self.title = title + self.message = message + } +} + +public typealias WhatsNewModel = [WhatsNewModelElement] + +extension WhatsNewModel { + + private func compareVersions(_ version1: String, _ version2: String) -> ComparisonResult { + let v1 = version1.split(separator: ".").compactMap { Int($0) } + let v2 = version2.split(separator: ".").compactMap { Int($0) } + + for (a, b) in zip(v1, v2) { + if a != b { + return a < b ? .orderedAscending : .orderedDescending + } + } + + return v1.count < v2.count ? .orderedAscending : (v1.count > v2.count ? .orderedDescending : .orderedSame) + } + + private func findLatestVersion(_ versions: [String]) -> String? { + guard let latestVersion = versions.max(by: { compareVersions($0, $1) == .orderedAscending }) else { + return nil + } + return latestVersion + } + + + var domain: [WhatsNewPage] { + guard let latestVersion = findLatestVersion(self.map { $0.version }) else { return [] } + return self.first(where: { $0.version == latestVersion })?.messages.map { + WhatsNewPage( + image: $0.image, + title: $0.title, + description: $0.message + ) + } ?? [] + } +} diff --git a/WhatsNew/WhatsNew/Data/WhatsNewStorage.swift b/WhatsNew/WhatsNew/Data/WhatsNewStorage.swift new file mode 100644 index 000000000..35d35d998 --- /dev/null +++ b/WhatsNew/WhatsNew/Data/WhatsNewStorage.swift @@ -0,0 +1,21 @@ +// +// WhatsNewStorage.swift +// WhatsNew +// +// Created by  Stepanok Ivan on 25.10.2023. +// + +import Foundation + +public protocol WhatsNewStorage { + var whatsNewVersion: String? {get set} +} + +#if DEBUG +public class WhatsNewStorageMock: WhatsNewStorage { + + public var whatsNewVersion: String? + + public init() {} +} +#endif diff --git a/WhatsNew/WhatsNew/Domain/WhatsNewPage.swift b/WhatsNew/WhatsNew/Domain/WhatsNewPage.swift new file mode 100644 index 000000000..8172ee037 --- /dev/null +++ b/WhatsNew/WhatsNew/Domain/WhatsNewPage.swift @@ -0,0 +1,14 @@ +// +// WhatsNewPage.swift +// WhatsNew +// +// Created by  Stepanok Ivan on 25.10.2023. +// + +import Foundation + +struct WhatsNewPage { + let image: String + let title: String + let description: String +} diff --git a/WhatsNew/WhatsNew/Info.plist b/WhatsNew/WhatsNew/Info.plist new file mode 100644 index 000000000..f72a0f657 --- /dev/null +++ b/WhatsNew/WhatsNew/Info.plist @@ -0,0 +1,12 @@ + + + + + + diff --git a/WhatsNew/WhatsNew/Presentation/Elements/PageControl.swift b/WhatsNew/WhatsNew/Presentation/Elements/PageControl.swift new file mode 100644 index 000000000..d6050b6ec --- /dev/null +++ b/WhatsNew/WhatsNew/Presentation/Elements/PageControl.swift @@ -0,0 +1,32 @@ +// +// PageControl.swift +// WhatsNew +// +// Created by  Stepanok Ivan on 18.10.2023. +// + +import SwiftUI +import Core + +struct PageControl: View { + let numberOfPages: Int + var currentPage: Int + + private var dots: some View { + HStack(spacing: 8) { + ForEach(0 ..< numberOfPages) { page in + RoundedRectangle(cornerRadius: 4) + .frame(width: page == currentPage ? 24 : 8, height: 8) + .foregroundColor(page == currentPage ? Theme.Colors.accentColor : Theme.Colors.textSecondary) + } + } + } + + var body: some View { + VStack { + Spacer() + dots + Spacer() + } + } +} diff --git a/WhatsNew/WhatsNew/Presentation/Elements/WhatsNewNavigationButton.swift b/WhatsNew/WhatsNew/Presentation/Elements/WhatsNewNavigationButton.swift new file mode 100644 index 000000000..03206348e --- /dev/null +++ b/WhatsNew/WhatsNew/Presentation/Elements/WhatsNewNavigationButton.swift @@ -0,0 +1,63 @@ +// +// CustomButton.swift +// WhatsNew +// +// Created by  Stepanok Ivan on 18.10.2023. +// + +import SwiftUI +import Core + +struct WhatsNewNavigationButton: View { + let type: ButtonType + let action: () -> Void + + enum ButtonType { + case previous, next, done + } + + var body: some View { + Group { + HStack(spacing: 4) { + if type == .previous { + CoreAssets.arrowLeft.swiftUIImage + .renderingMode(.template) + .foregroundColor(Theme.Colors.accentColor) + } + + Text(type == .previous ? WhatsNewLocalization.buttonPrevious + : (type == .next ? WhatsNewLocalization.buttonNext : WhatsNewLocalization.buttonDone )) + .foregroundColor(type == .previous ? Theme.Colors.accentColor : Color.white) + .font(Theme.Fonts.labelLarge) + + if type == .next { + CoreAssets.arrowLeft.swiftUIImage + .renderingMode(.template) + .rotationEffect(Angle(degrees: 180)) + .foregroundColor(Color.white) + } + + if type == .done { + CoreAssets.checkmark.swiftUIImage + .renderingMode(.template) + .foregroundColor(Color.white) + } + }.padding(.horizontal, 20) + .padding(.vertical, 9) + }.fixedSize() + .background(type == .previous + ? Theme.Colors.background + : Theme.Colors.accentColor) + .accessibilityElement(children: .ignore) + .accessibilityLabel(type == .previous ? WhatsNewLocalization.buttonPrevious + : (type == .next ? WhatsNewLocalization.buttonNext : WhatsNewLocalization.buttonDone )) + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(type == .previous + ? Theme.Colors.accentColor + : Theme.Colors.background, lineWidth: 1) + ) + .onTapGesture { action() } + } +} diff --git a/WhatsNew/WhatsNew/Presentation/WhatsNewRouter.swift b/WhatsNew/WhatsNew/Presentation/WhatsNewRouter.swift new file mode 100644 index 000000000..4416e24fd --- /dev/null +++ b/WhatsNew/WhatsNew/Presentation/WhatsNewRouter.swift @@ -0,0 +1,19 @@ +// +// WhatsNewRouter.swift +// WhatsNew +// +// Created by  Stepanok Ivan on 18.10.2023. +// + +import Foundation +import Core + +public protocol WhatsNewRouter: BaseRouter { +} + +// Mark - For testing and SwiftUI preview +#if DEBUG +public class WhatsNewRouterMock: BaseRouterMock, WhatsNewRouter { + public override init() {} +} +#endif diff --git a/WhatsNew/WhatsNew/Presentation/WhatsNewView.swift b/WhatsNew/WhatsNew/Presentation/WhatsNewView.swift new file mode 100644 index 000000000..bc419cb0a --- /dev/null +++ b/WhatsNew/WhatsNew/Presentation/WhatsNewView.swift @@ -0,0 +1,160 @@ +// +// WhatsNewView.swift +// WhatsNew +// +// Created by  Stepanok Ivan on 18.10.2023. +// + +import SwiftUI +import Core + +public struct WhatsNewView: View { + + private let router: WhatsNewRouter + + @ObservedObject + private var viewModel: WhatsNewViewModel + + @Environment (\.isHorizontal) + private var isHorizontal + + @State var index = 0 + + public init(router: WhatsNewRouter, viewModel: WhatsNewViewModel) { + self.router = router + self.viewModel = viewModel + } + + public var body: some View { + GeometryReader { reader in + ZStack(alignment: isHorizontal ? .center : .bottom) { + Theme.Colors.background + .ignoresSafeArea() + adaptiveStack(isHorizontal: isHorizontal) { + TabView(selection: $index) { + ForEach(Array(viewModel.newItems.enumerated()), id: \.offset) { _, new in + adaptiveStack(isHorizontal: isHorizontal) { + ZStack(alignment: .center) { + Image(new.image, bundle: Bundle(for: BundleToken.self)) + .resizable() + .scaledToFit() + .frame(minWidth: 250, maxWidth: 300) + .padding(24) + }.frame(minHeight: 250, maxHeight: 416) + Spacer() + } + } + }.tabViewStyle(.page(indexDisplayMode: .never)) + } + if isHorizontal { + HStack { + Spacer() + + Rectangle() + .foregroundColor(Theme.Colors.background) + .frame(width: reader.size.width / 1.9) + .ignoresSafeArea() + .mask( + LinearGradient( + gradient: Gradient(colors: [ + .clear, + .black, + .black, + .black, + .black, + .black, + .black, + .black, + .black]), + startPoint: .leading, + endPoint: .trailing + ) + ) + } .allowsHitTesting(false) + } + HStack { + if isHorizontal { + Spacer() + } + VStack(spacing: 16) { + VStack { + if !viewModel.newItems.isEmpty { + Text(viewModel.newItems[viewModel.index].title) + .font(Theme.Fonts.titleMedium) + Text(viewModel.newItems[viewModel.index].description) + .font(Theme.Fonts.bodyMedium) + .multilineTextAlignment(.center) + } + }.frame(height: 100) + .allowsHitTesting(false) + + HStack(spacing: 36) { + WhatsNewNavigationButton(type: .previous, action: { + if index != 0 { + withAnimation(.linear(duration: 0.3)) { + index -= 1 + } + } + }).opacity(viewModel.index != 0 ? 1 : 0) + WhatsNewNavigationButton( + type: viewModel.index < viewModel.newItems.count - 1 ? .next : .done, + action: { + if index < viewModel.newItems.count - 1 { + withAnimation(.linear(duration: 0.3)) { + index += 1 + } + } else { + router.showMainOrWhatsNewScreen() + } + } + ) + } + } + .padding(.bottom, isHorizontal ? 0 : 52) + .padding(.horizontal, 24) + .frame(width: isHorizontal ? reader.size.width / 1.9 : nil) + } + VStack { + if isHorizontal { + Spacer() + } + PageControl(numberOfPages: viewModel.newItems.count, currentPage: viewModel.index) + .frame(height: isHorizontal ? 8 : nil) + .allowsHitTesting(false) + .padding(.top, isHorizontal ? 0 : 170) + .padding(.bottom, 8) + } + + }.onChange(of: index) { ind in + withAnimation(.linear(duration: 0.3)) { + viewModel.index = ind + } + } + .navigationTitle(WhatsNewLocalization.title) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing, content: { + Button(action: { + router.showMainOrWhatsNewScreen() + }, label: { + Image(systemName: "xmark") + .foregroundColor(Theme.Colors.accentColor) + }) + }) + } + } + } + + class BundleToken {} +} + +#if DEBUG +struct WhatsNewView_Previews: PreviewProvider { + static var previews: some View { + WhatsNewView( + router: WhatsNewRouterMock(), + viewModel: WhatsNewViewModel(storage: WhatsNewStorageMock()) + ) + .loadFonts() + } +} +#endif diff --git a/WhatsNew/WhatsNew/Presentation/WhatsNewViewModel.swift b/WhatsNew/WhatsNew/Presentation/WhatsNewViewModel.swift new file mode 100644 index 000000000..170472088 --- /dev/null +++ b/WhatsNew/WhatsNew/Presentation/WhatsNewViewModel.swift @@ -0,0 +1,73 @@ +// +// WhatsNewViewModel.swift +// WhatsNew +// +// Created by  Stepanok Ivan on 18.10.2023. +// + +import SwiftUI +import Core +import Swinject + +public class WhatsNewViewModel: ObservableObject { + @Published var index: Int = 0 + @Published var newItems: [WhatsNewPage] = [] + private let storage: WhatsNewStorage + + public init(storage: WhatsNewStorage) { + self.storage = storage + newItems = loadWhatsNew() + } + + public func getVersion() -> String? { + guard let model = loadWhatsNewModel() else { return nil } + return model.first?.version + } + + public func shouldShowWhatsNew() -> Bool { + guard let currentVersion = getVersion() else { return false } + + // If there is no saved version in storage, we always show WhatsNew + guard let savedVersion = storage.whatsNewVersion else { return true } + + // We break down the versions into components major, minor, patch + let savedComponents = savedVersion.components(separatedBy: ".") + let currentComponents = currentVersion.components(separatedBy: ".") + + // Checking major and minor components + if savedComponents.count >= 2 && currentComponents.count >= 2 { + let savedMajor = savedComponents[0] + let savedMinor = savedComponents[1] + + let currentMajor = currentComponents[0] + let currentMinor = currentComponents[1] + + // If major or minor are different, show WhatsNew + if savedMajor != currentMajor || savedMinor != currentMinor { + return true + } + } + return false + } + + func loadWhatsNew() -> [WhatsNewPage] { + guard let domain = loadWhatsNewModel()?.domain else { return [] } + return domain + } + + private func loadWhatsNewModel() -> WhatsNewModel? { + guard let fileUrl = Bundle(for: Self.self).url(forResource: "WhatsNew", withExtension: "json") else { + print("Unable to locate WhatsNew.json") + return nil + } + + do { + let data = try Data(contentsOf: fileUrl) + let decoder = JSONDecoder() + return try decoder.decode(WhatsNewModel.self, from: data) + } catch { + print("Error decoding WhatsNew.json: \(error)") + return nil + } + } +} diff --git a/WhatsNew/WhatsNew/SwiftGen/Strings.swift b/WhatsNew/WhatsNew/SwiftGen/Strings.swift new file mode 100644 index 000000000..8b483b7b4 --- /dev/null +++ b/WhatsNew/WhatsNew/SwiftGen/Strings.swift @@ -0,0 +1,47 @@ +// swiftlint:disable all +// Generated using SwiftGen — https://github.com/SwiftGen/SwiftGen + +import Foundation + +// swiftlint:disable superfluous_disable_command file_length implicit_return prefer_self_in_static_references + +// MARK: - Strings + +// swiftlint:disable explicit_type_interface function_parameter_count identifier_name line_length +// swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces +public enum WhatsNewLocalization { + /// Done + public static let buttonDone = WhatsNewLocalization.tr("Localizable", "BUTTON_DONE", fallback: "Done") + /// Next + public static let buttonNext = WhatsNewLocalization.tr("Localizable", "BUTTON_NEXT", fallback: "Next") + /// Previous + public static let buttonPrevious = WhatsNewLocalization.tr("Localizable", "BUTTON_PREVIOUS", fallback: "Previous") + /// Localizable.strings + /// WhatsNew + /// + /// Created by  Stepanok Ivan on 18.10.2023. + public static let title = WhatsNewLocalization.tr("Localizable", "TITLE", fallback: "What's New") +} +// swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length +// swiftlint:enable nesting type_body_length type_name vertical_whitespace_opening_braces + +// MARK: - Implementation Details + +extension WhatsNewLocalization { + private static func tr(_ table: String, _ key: String, _ args: CVarArg..., fallback value: String) -> String { + let format = BundleToken.bundle.localizedString(forKey: key, value: value, table: table) + return String(format: format, locale: Locale.current, arguments: args) + } +} + +// swiftlint:disable convenience_type +private final class BundleToken { + static let bundle: Bundle = { + #if SWIFT_PACKAGE + return Bundle.module + #else + return Bundle(for: BundleToken.self) + #endif + }() +} +// swiftlint:enable convenience_type diff --git a/WhatsNew/WhatsNew/en.lproj/Localizable.strings b/WhatsNew/WhatsNew/en.lproj/Localizable.strings new file mode 100644 index 000000000..08fffcb7b --- /dev/null +++ b/WhatsNew/WhatsNew/en.lproj/Localizable.strings @@ -0,0 +1,13 @@ +/* + Localizable.strings + WhatsNew + + Created by  Stepanok Ivan on 18.10.2023. + +*/ + +"TITLE" = "What's New"; +"BUTTON_PREVIOUS" = "Previous"; +"BUTTON_NEXT" = "Next"; +"BUTTON_DONE" = "Done"; + diff --git a/WhatsNew/WhatsNew/uk.lproj/Localizable.strings b/WhatsNew/WhatsNew/uk.lproj/Localizable.strings new file mode 100644 index 000000000..a0194425c --- /dev/null +++ b/WhatsNew/WhatsNew/uk.lproj/Localizable.strings @@ -0,0 +1,12 @@ +/* + Localizable.strings + WhatsNew + + Created by  Stepanok Ivan on 18.10.2023. + +*/ + +"TITLE" = "Що нового"; +"BUTTON_PREVIOUS" = "Назад"; +"BUTTON_NEXT" = "Далі"; +"BUTTON_DONE" = "Завершити"; diff --git a/WhatsNew/WhatsNewTests/Presentation/WhatsNewTests.swift b/WhatsNew/WhatsNewTests/Presentation/WhatsNewTests.swift new file mode 100644 index 000000000..4f43a51dd --- /dev/null +++ b/WhatsNew/WhatsNewTests/Presentation/WhatsNewTests.swift @@ -0,0 +1,27 @@ +// +// WhatsNewTests.swift +// WhatsNewTests +// +// Created by  Stepanok Ivan on 18.10.2023. +// + +import XCTest +@testable import WhatsNew + +final class WhatsNewTests: XCTestCase { + + func testGetVersion() throws { + let viewModel = WhatsNewViewModel(storage: WhatsNewStorageMock()) + let version = viewModel.getVersion() + XCTAssertNotNil(version) + XCTAssertTrue(version == "1.0") + } + + func testshouldShowWhatsNew() throws { + let viewModel = WhatsNewViewModel(storage: WhatsNewStorageMock()) + let version = viewModel.getVersion() + + XCTAssertNotNil(version) + XCTAssertTrue(viewModel.shouldShowWhatsNew()) + } +} diff --git a/WhatsNew/WhatsNewTests/WhatsNewMock.generated.swift b/WhatsNew/WhatsNewTests/WhatsNewMock.generated.swift new file mode 100644 index 000000000..f61f1556d --- /dev/null +++ b/WhatsNew/WhatsNewTests/WhatsNewMock.generated.swift @@ -0,0 +1,19 @@ +// Generated using Sourcery 1.8.0 — https://github.com/krzysztofzablocki/Sourcery +// DO NOT EDIT + + +// Generated with SwiftyMocky 4.2.0 +// Required Sourcery: 1.8.0 + + +import SwiftyMocky +import XCTest +import Core +import WhatsNew +import Foundation +import SwiftUI +import Combine + + +// SwiftyMocky: no AutoMockable found. +// Please define and inherit from AutoMockable, or annotate protocols to be mocked diff --git a/WhatsNew/swiftgen.yml b/WhatsNew/swiftgen.yml new file mode 100644 index 000000000..9aa2d1c3b --- /dev/null +++ b/WhatsNew/swiftgen.yml @@ -0,0 +1,18 @@ +strings: + inputs: + - WhatsNew/en.lproj + outputs: + - templateName: structured-swift5 + params: + publicAccess: true + enumName: WhatsNewLocalization + output: WhatsNew/SwiftGen/Strings.swift +#xcassets: +# inputs: +# - WhatsNew/Assets.xcassets +# outputs: +# templateName: swift5 +# params: +# publicAccess: true +# enumName: WhatsNewAssets +# output: WhatsNew/SwiftGen/Assets.swift diff --git a/generateAllMocks.sh b/generateAllMocks.sh index ac1fe0dd6..0c4b5cfc7 100755 --- a/generateAllMocks.sh +++ b/generateAllMocks.sh @@ -12,4 +12,6 @@ cd ../Discovery cd ../Discussion ./../Pods/SwiftyMocky/bin/swiftymocky generate cd ../Profile +./../Pods/SwiftyMocky/bin/swiftymocky generate +cd ../WhatsNew ./../Pods/SwiftyMocky/bin/swiftymocky generate \ No newline at end of file From 1abdb5375e4144e0ea9d055a80ac98c8d9a034fd Mon Sep 17 00:00:00 2001 From: Saeed Bashir Date: Wed, 1 Nov 2023 14:18:13 +0500 Subject: [PATCH 007/158] chore: update Sourcery version for SwiftyMocky (#142) --- .../AuthorizationMock.generated.swift | 2 +- Authorization/Mockfile | 4 +- Course/CourseTests/CourseMock.generated.swift | 2 +- Course/Mockfile | 4 +- .../DashboardMock.generated.swift | 2 +- Dashboard/Mockfile | 4 +- .../DiscoveryMock.generated.swift | 2 +- Discovery/Mockfile | 4 +- .../DiscussionMock.generated.swift | 2 +- Discussion/Mockfile | 4 +- MockTemplate.swifttemplate | 2133 +++++++++++++++++ Profile/Mockfile | 4 +- .../ProfileTests/ProfileMock.generated.swift | 2 +- WhatsNew/Mockfile | 4 +- .../WhatsNewMock.generated.swift | 2 +- 15 files changed, 2154 insertions(+), 21 deletions(-) create mode 100644 MockTemplate.swifttemplate diff --git a/Authorization/AuthorizationTests/AuthorizationMock.generated.swift b/Authorization/AuthorizationTests/AuthorizationMock.generated.swift index 93016c754..7c0c4707d 100644 --- a/Authorization/AuthorizationTests/AuthorizationMock.generated.swift +++ b/Authorization/AuthorizationTests/AuthorizationMock.generated.swift @@ -1,4 +1,4 @@ -// Generated using Sourcery 1.8.0 — https://github.com/krzysztofzablocki/Sourcery +// Generated using Sourcery 2.1.2 — https://github.com/krzysztofzablocki/Sourcery // DO NOT EDIT diff --git a/Authorization/Mockfile b/Authorization/Mockfile index da0f47ddb..5e0805e28 100644 --- a/Authorization/Mockfile +++ b/Authorization/Mockfile @@ -1,5 +1,5 @@ -sourceryCommand: null -sourceryTemplate: null +sourceryCommand: mint run krzysztofzablocki/Sourcery@2.1.2 sourcery +sourceryTemplate: ../MockTemplate.swifttemplate unit.tests.mock: sources: include: diff --git a/Course/CourseTests/CourseMock.generated.swift b/Course/CourseTests/CourseMock.generated.swift index 488ab1a83..122385f57 100644 --- a/Course/CourseTests/CourseMock.generated.swift +++ b/Course/CourseTests/CourseMock.generated.swift @@ -1,4 +1,4 @@ -// Generated using Sourcery 1.8.0 — https://github.com/krzysztofzablocki/Sourcery +// Generated using Sourcery 2.1.2 — https://github.com/krzysztofzablocki/Sourcery // DO NOT EDIT diff --git a/Course/Mockfile b/Course/Mockfile index 504794e7d..58cd4b263 100644 --- a/Course/Mockfile +++ b/Course/Mockfile @@ -1,5 +1,5 @@ -sourceryCommand: null -sourceryTemplate: null +sourceryCommand: mint run krzysztofzablocki/Sourcery@2.1.2 sourcery +sourceryTemplate: ../MockTemplate.swifttemplate unit.tests.mock: sources: include: diff --git a/Dashboard/DashboardTests/DashboardMock.generated.swift b/Dashboard/DashboardTests/DashboardMock.generated.swift index efbb553e6..08f998ce9 100644 --- a/Dashboard/DashboardTests/DashboardMock.generated.swift +++ b/Dashboard/DashboardTests/DashboardMock.generated.swift @@ -1,4 +1,4 @@ -// Generated using Sourcery 1.8.0 — https://github.com/krzysztofzablocki/Sourcery +// Generated using Sourcery 2.1.2 — https://github.com/krzysztofzablocki/Sourcery // DO NOT EDIT diff --git a/Dashboard/Mockfile b/Dashboard/Mockfile index 053c2899f..f747b41e0 100644 --- a/Dashboard/Mockfile +++ b/Dashboard/Mockfile @@ -1,5 +1,5 @@ -sourceryCommand: null -sourceryTemplate: null +sourceryCommand: mint run krzysztofzablocki/Sourcery@2.1.2 sourcery +sourceryTemplate: ../MockTemplate.swifttemplate unit.tests.mock: sources: include: diff --git a/Discovery/DiscoveryTests/DiscoveryMock.generated.swift b/Discovery/DiscoveryTests/DiscoveryMock.generated.swift index f55ce38ed..d49e7424f 100644 --- a/Discovery/DiscoveryTests/DiscoveryMock.generated.swift +++ b/Discovery/DiscoveryTests/DiscoveryMock.generated.swift @@ -1,4 +1,4 @@ -// Generated using Sourcery 1.8.0 — https://github.com/krzysztofzablocki/Sourcery +// Generated using Sourcery 2.1.2 — https://github.com/krzysztofzablocki/Sourcery // DO NOT EDIT diff --git a/Discovery/Mockfile b/Discovery/Mockfile index 3940f8cf2..638dccd32 100644 --- a/Discovery/Mockfile +++ b/Discovery/Mockfile @@ -1,5 +1,5 @@ -sourceryCommand: null -sourceryTemplate: null +sourceryCommand: mint run krzysztofzablocki/Sourcery@2.1.2 sourcery +sourceryTemplate: ../MockTemplate.swifttemplate unit.tests.mock: sources: include: diff --git a/Discussion/DiscussionTests/DiscussionMock.generated.swift b/Discussion/DiscussionTests/DiscussionMock.generated.swift index 5303b1525..775ffc794 100644 --- a/Discussion/DiscussionTests/DiscussionMock.generated.swift +++ b/Discussion/DiscussionTests/DiscussionMock.generated.swift @@ -1,4 +1,4 @@ -// Generated using Sourcery 1.8.0 — https://github.com/krzysztofzablocki/Sourcery +// Generated using Sourcery 2.1.2 — https://github.com/krzysztofzablocki/Sourcery // DO NOT EDIT diff --git a/Discussion/Mockfile b/Discussion/Mockfile index 4b981a270..dc4c39594 100644 --- a/Discussion/Mockfile +++ b/Discussion/Mockfile @@ -1,5 +1,5 @@ -sourceryCommand: null -sourceryTemplate: null +sourceryCommand: mint run krzysztofzablocki/Sourcery@2.1.2 sourcery +sourceryTemplate: ../MockTemplate.swifttemplate unit.tests.mock: sources: include: diff --git a/MockTemplate.swifttemplate b/MockTemplate.swifttemplate new file mode 100644 index 000000000..95d0d857a --- /dev/null +++ b/MockTemplate.swifttemplate @@ -0,0 +1,2133 @@ +<%_ +let mockTypeName = "Mock" +func swiftLintRules(_ arguments: [String: Any]) -> [String] { + return stringArray(fromArguments: arguments, forKey: "excludedSwiftLintRules").map { rule in + return "//swiftlint:disable \(rule)" + } +} + +func projectImports(_ arguments: [String: Any]) -> [String] { + return imports(arguments) + testableImports(arguments) +} + +func imports(_ arguments: [String: Any]) -> [String] { + return stringArray(fromArguments: arguments, forKey: "import") + .map { return "import \($0)" } +} + +func testableImports(_ arguments: [String: Any]) -> [String] { + return stringArray(fromArguments: arguments, forKey: "testable") + .map { return "@testable import \($0)" } +} + +/// [Internal] Get value from dictionary +/// - Parameters: +/// - fromArguments: dictionary +/// - forKey: dictionary key +/// - Returns: array of strings, if key not found, returns empty array. +/// - Note: If sourcery arguments containts only one element, then single value is stored, otherwise array of elements. This method always gets array of elements. +func stringArray(fromArguments arguments: [String: Any], forKey key: String) -> [String] { + + if let argument = arguments[key] as? String { + return [argument] + } else if let manyArguments = arguments[key] as? [String] { + return manyArguments + } else { + return [] + } +} +_%> +// Generated with SwiftyMocky 4.2.0 +// Required Sourcery: 1.8.0 + +<%_ for rule in swiftLintRules(argument) { -%> + <%_ %><%= rule %> +<%_ } -%> + +import SwiftyMocky +import XCTest +<%# ================================================== IMPORTS -%><%_ -%> + <%_ for projectImport in projectImports(argument) { -%> + <%_ %><%= projectImport %> + <%_ } -%> + <%# ============================ IMPORTS InAPP (aggregated argument) -%><%_ -%> + <%_ if let swiftyMockyArgs = argument["swiftyMocky"] as? [String: Any] { -%> + <%_ for projectImport in projectImports(swiftyMockyArgs) { -%> + <%_ %><%= projectImport %> + <%_ } -%> + <%_ } -%> +<%_ +class Current { + static var selfType: String = "Self" + static var accessModifier: String = "open" +} +// Collision management +func areThereCollisions(between methods: [MethodWrapper]) -> Bool { + let givenSet = Set(methods.map({ $0.givenConstructorName(prefix: "") })) + guard givenSet.count == methods.count else { return true } // there would be conflicts in Given + let verifySet = Set(methods.map({ $0.verificationProxyConstructorName(prefix: "") })) + guard verifySet.count == methods.count else { return true } // there would be conflicts in Verify + return false +} + +// herlpers +func uniques(methods: [SourceryRuntime.Method]) -> [SourceryRuntime.Method] { + func returnTypeStripped(_ method: SourceryRuntime.Method) -> String { + let returnTypeRaw = "\(method.returnTypeName)" + var stripped: String = { + guard let range = returnTypeRaw.range(of: "where") else { return returnTypeRaw } + var stripped = returnTypeRaw + stripped.removeSubrange((range.lowerBound)...) + return stripped + }() + stripped = stripped.trimmingCharacters(in: CharacterSet(charactersIn: " ")) + return stripped + } + + func areSameParams(_ p1: SourceryRuntime.MethodParameter, _ p2: SourceryRuntime.MethodParameter) -> Bool { + guard p1.argumentLabel == p2.argumentLabel else { return false } + guard p1.name == p2.name else { return false } + guard p1.argumentLabel == p2.argumentLabel else { return false } + guard p1.typeName.name == p2.typeName.name else { return false } + guard p1.actualTypeName?.name == p2.actualTypeName?.name else { return false } + return true + } + + func areSameMethods(_ m1: SourceryRuntime.Method, _ m2: SourceryRuntime.Method) -> Bool { + guard m1.name != m2.name else { return m1.returnTypeName == m2.returnTypeName } + guard m1.selectorName == m2.selectorName else { return false } + guard m1.parameters.count == m2.parameters.count else { return false } + + let p1 = m1.parameters + let p2 = m2.parameters + + for i in 0.. [SourceryRuntime.Method] in + guard !result.contains(where: { areSameMethods($0,element) }) else { return result } + return result + [element] + }) +} + +func uniquesWithoutGenericConstraints(methods: [SourceryRuntime.Method]) -> [SourceryRuntime.Method] { + func returnTypeStripped(_ method: SourceryRuntime.Method) -> String { + let returnTypeRaw = "\(method.returnTypeName)" + var stripped: String = { + guard let range = returnTypeRaw.range(of: "where") else { return returnTypeRaw } + var stripped = returnTypeRaw + stripped.removeSubrange((range.lowerBound)...) + return stripped + }() + stripped = stripped.trimmingCharacters(in: CharacterSet(charactersIn: " ")) + return stripped + } + + func areSameParams(_ p1: SourceryRuntime.MethodParameter, _ p2: SourceryRuntime.MethodParameter) -> Bool { + guard p1.argumentLabel == p2.argumentLabel else { return false } + guard p1.name == p2.name else { return false } + guard p1.argumentLabel == p2.argumentLabel else { return false } + guard p1.typeName.name == p2.typeName.name else { return false } + guard p1.actualTypeName?.name == p2.actualTypeName?.name else { return false } + return true + } + + func areSameMethods(_ m1: SourceryRuntime.Method, _ m2: SourceryRuntime.Method) -> Bool { + guard m1.name != m2.name else { return returnTypeStripped(m1) == returnTypeStripped(m2) } + guard m1.selectorName == m2.selectorName else { return false } + guard m1.parameters.count == m2.parameters.count else { return false } + + let p1 = m1.parameters + let p2 = m2.parameters + + for i in 0.. [SourceryRuntime.Method] in + guard !result.contains(where: { areSameMethods($0,element) }) else { return result } + return result + [element] + }) +} + +func uniques(variables: [SourceryRuntime.Variable]) -> [SourceryRuntime.Variable] { + return variables.reduce([], { (result, element) -> [SourceryRuntime.Variable] in + guard !result.contains(where: { $0.name == element.name }) else { return result } + return result + [element] + }) +} + +func wrapMethod(_ method: SourceryRuntime.Method) -> MethodWrapper { + return MethodWrapper(method) +} + +func wrapSubscript(_ wrapped: SourceryRuntime.Subscript) -> SubscriptWrapper { + return SubscriptWrapper(wrapped) +} + +func justWrap(_ variable: SourceryRuntime.Variable) -> VariableWrapper { return wrapProperty(variable) } +func wrapProperty(_ variable: SourceryRuntime.Variable, _ scope: String = "") -> VariableWrapper { + return VariableWrapper(variable, scope: scope) +} + +func stubProperty(_ variable: SourceryRuntime.Variable, _ scope: String) -> String { + let wrapper = VariableWrapper(variable, scope: scope) + return "\(wrapper.prototype)\n\t\(wrapper.privatePrototype)" +} + +func propertyTypes(_ variable: SourceryRuntime.Variable) -> String { + let wrapper = VariableWrapper(variable, scope: "scope") + return "\(wrapper.propertyGet())" + (wrapper.readonly ? "" : "\n\t\t\(wrapper.propertySet())") +} + +func propertyMethodTypes(_ variable: SourceryRuntime.Variable) -> String { + let wrapper = VariableWrapper(variable, scope: "") + return "\(wrapper.propertyCaseGet())" + (wrapper.readonly ? "" : "\n\t\t\(wrapper.propertyCaseSet())") +} + +func propertyMethodTypesIntValue(_ variable: SourceryRuntime.Variable) -> String { + let wrapper = VariableWrapper(variable, scope: "") + return "\(wrapper.propertyCaseGetIntValue())" + (wrapper.readonly ? "" : "\n\t\t\t\(wrapper.propertyCaseSetIntValue())") +} + +func propertyRegister(_ variable: SourceryRuntime.Variable) { + let wrapper = VariableWrapper(variable, scope: "") + MethodWrapper.register(wrapper.propertyCaseGetName,wrapper.propertyCaseGetName,wrapper.propertyCaseGetName) + guard !wrapper.readonly else { return } + MethodWrapper.register(wrapper.propertyCaseSetName,wrapper.propertyCaseSetName,wrapper.propertyCaseGetName) +} +class Helpers { + static func split(_ string: String, byFirstOccurenceOf word: String) -> (String, String) { + guard let wordRange = string.range(of: word) else { return (string, "") } + let selfRange = string.range(of: string)! + let before = String(string[selfRange.lowerBound.. [String]? { + if let types = annotated.annotations["associatedtype"] as? [String] { + return types.reversed() + } else if let type = annotated.annotations["associatedtype"] as? String { + return [type] + } else { + return nil + } + } + static func extractWhereClause(from annotated: SourceryRuntime.Annotated) -> String? { + if let constraints = annotated.annotations["where"] as? [String] { + return " where \(constraints.reversed().joined(separator: ", "))" + } else if let constraint = annotated.annotations["where"] as? String { + return " where \(constraint)" + } else { + return nil + } + } + /// Extract all typealiases from "annotations" + static func extractTypealiases(from annotated: SourceryRuntime.Annotated) -> [String] { + if let types = annotated.annotations["typealias"] as? [String] { + return types.reversed() + } else if let type = annotated.annotations["typealias"] as? String { + return [type] + } else { + return [] + } + } + static func extractGenericsList(_ associatedTypes: [String]?) -> [String] { + return associatedTypes?.flatMap { + split($0, byFirstOccurenceOf: " where ").0.replacingOccurrences(of: " ", with: "").split(separator: ":").map(String.init).first + }.map { "\($0)" } ?? [] + } + static func extractGenericTypesModifier(_ associatedTypes: [String]?) -> String { + let all = extractGenericsList(associatedTypes) + guard !all.isEmpty else { return "" } + return "<\(all.joined(separator: ","))>" + } + static func extractGenericTypesConstraints(_ associatedTypes: [String]?) -> String { + guard let all = associatedTypes else { return "" } + let constraints = all.flatMap { t -> String? in + let splitted = split(t, byFirstOccurenceOf: " where ") + let constraint = splitted.0.replacingOccurrences(of: " ", with: "").split(separator: ":").map(String.init) + guard constraint.count == 2 else { return nil } + let adopts = constraint[1].split(separator: ",").map(String.init) + var mapped = adopts.map { "\(constraint[0]): \($0)" } + if !splitted.1.isEmpty { + mapped.append(splitted.1) + } + return mapped.joined(separator: ", ") + } + .joined(separator: ", ") + guard !constraints.isEmpty else { return "" } + return " where \(constraints)" + } + static func extractAttributes( + from attributes: [String: [SourceryRuntime.Attribute]], + filterOutStartingWith disallowedPrefixes: [String] = [] + ) -> String { + return attributes + .reduce([SourceryRuntime.Attribute]()) { $0 + $1.1 } + .map { $0.description } + .filter { !["private", "internal", "public", "open", "optional"].contains($0) } + .filter { element in + !disallowedPrefixes.contains(where: element.hasPrefix) + } + .sorted() + .joined(separator: " ") + } +} +class ParameterWrapper { + let parameter: MethodParameter + + var isVariadic = false + + var wrappedForCall: String { + let typeString = "\(type.actualTypeName ?? type)" + let isEscaping = typeString.contains("@escaping") + let isOptional = (type.actualTypeName ?? type).isOptional + if parameter.isClosure && !isEscaping && !isOptional { + return "\(nestedType).any" + } else { + return "\(nestedType).value(\(escapedName))" + } + } + var nestedType: String { + return "\(TypeWrapper(type, isVariadic).nestedParameter)" + } + var justType: String { + return "\(TypeWrapper(type, isVariadic).replacingSelf())" + } + var justPerformType: String { + return "\(TypeWrapper(type, isVariadic).replacingSelfRespectingVariadic())".replacingOccurrences(of: "!", with: "?") + } + var genericType: String { + return isVariadic ? "Parameter<[GenericAttribute]>" : "Parameter" + } + var typeErasedType: String { + return isVariadic ? "Parameter<[TypeErasedAttribute]>" : "Parameter" + } + var type: SourceryRuntime.TypeName { + return parameter.typeName + } + var name: String { + return parameter.name + } + var escapedName: String { + return "`\(parameter.name)`" + } + var comparator: String { + return "guard Parameter.compare(lhs: lhs\(parameter.name.capitalized), rhs: rhs\(parameter.name.capitalized), with: matcher) else { return false }" + } + func comparatorResult() -> String { + let lhsName = "lhs\(parameter.name.capitalized)" + let rhsName = "rhs\(parameter.name.capitalized)" + return "results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: \(lhsName), rhs: \(rhsName), with: matcher), \(lhsName), \(rhsName), \"\(labelAndName())\"))" + } + + init(_ parameter: SourceryRuntime.MethodParameter, _ variadics: [String] = []) { + self.parameter = parameter + self.isVariadic = !variadics.isEmpty && variadics.contains(parameter.name) + } + + func isGeneric(_ types: [String]) -> Bool { + return TypeWrapper(type).isGeneric(types) + } + + func wrappedForProxy(_ generics: [String], _ availability: Bool = false) -> String { + if isGeneric(generics) { + return "\(escapedName).wrapAsGeneric()" + } + if (availability) { + return "\(escapedName).typeErasedAttribute()" + } + return "\(escapedName)" + } + func wrappedForCalls(_ generics: [String], _ availability: Bool = false) -> String { + if isGeneric(generics) { + return "\(wrappedForCall).wrapAsGeneric()" + } + if (availability) { + return "\(wrappedForCall).typeErasedAttribute()" + } + return "\(wrappedForCall)" + } + + func asMethodArgument() -> String { + if parameter.argumentLabel != parameter.name { + return "\(parameter.argumentLabel ?? "_") \(parameter.name): \(parameter.typeName)" + } else { + return "\(parameter.name): \(parameter.typeName)" + } + } + func labelAndName() -> String { + let label = parameter.argumentLabel ?? "_" + return label != parameter.name ? "\(label) \(parameter.name)" : label + } + func sanitizedForEnumCaseName() -> String { + if let label = parameter.argumentLabel, label != parameter.name { + return "\(label)_\(parameter.name)".replacingOccurrences(of: "`", with: "") + } else { + return "\(parameter.name)".replacingOccurrences(of: "`", with: "") + } + } +} +class TypeWrapper { + let type: SourceryRuntime.TypeName + let isVariadic: Bool + + var vPref: String { return isVariadic ? "[" : "" } + var vSuff: String { return isVariadic ? "]" : "" } + + var unwrapped: String { + return type.unwrappedTypeName + } + var unwrappedReplacingSelf: String { + return replacingSelf(unwrap: true) + } + var stripped: String { + if type.isImplicitlyUnwrappedOptional { + return "\(vPref)\(unwrappedReplacingSelf)?\(vSuff)" + } else if type.isOptional { + return "\(vPref)\(unwrappedReplacingSelf)?\(vSuff)" + } else { + return "\(vPref)\(unwrappedReplacingSelf)\(vSuff)" + } + } + var nestedParameter: String { + if type.isImplicitlyUnwrappedOptional { + return "Parameter<\(vPref)\(unwrappedReplacingSelf)?\(vSuff)>" + } else if type.isOptional { + return "Parameter<\(vPref)\(unwrappedReplacingSelf)?\(vSuff)>" + } else { + return "Parameter<\(vPref)\(unwrappedReplacingSelf)\(vSuff)>" + } + } + var isSelfType: Bool { + return unwrapped == "Self" + } + func isSelfTypeRecursive() -> Bool { + if let tuple = type.tuple { + for element in tuple.elements { + guard !TypeWrapper(element.typeName).isSelfTypeRecursive() else { return true } + } + } else if let array = type.array { + return TypeWrapper(array.elementTypeName).isSelfTypeRecursive() + } else if let dictionary = type.dictionary { + guard !TypeWrapper(dictionary.valueTypeName).isSelfTypeRecursive() else { return true } + guard !TypeWrapper(dictionary.keyTypeName).isSelfTypeRecursive() else { return true } + } else if let closure = type.closure { + guard !TypeWrapper(closure.actualReturnTypeName).isSelfTypeRecursive() else { return true } + for parameter in closure.parameters { + guard !TypeWrapper(parameter.typeName).isSelfTypeRecursive() else { return true } + } + } + + return isSelfType + } + + init(_ type: SourceryRuntime.TypeName, _ isVariadic: Bool = false) { + self.type = type + self.isVariadic = isVariadic + } + + func isGeneric(_ types: [String]) -> Bool { + guard !type.isVoid else { return false } + + return isGeneric(name: unwrapped, generics: types) + } + + private func isGeneric(name: String, generics: [String]) -> Bool { + let name = "(\(name.replacingOccurrences(of: " ", with: "")))" + let modifiers = "[\\?\\!]*" + return generics.contains(where: { generic in + let wrapped = "([\\(]\(generic)\(modifiers)[\\)\\.])" + let constraint = "([<,]\(generic)\(modifiers)[>,\\.])" + let arrays = "([\\[:]\(generic)\(modifiers)[\\],\\.:])" + let tuples = "([\\(,]\(generic)\(modifiers)[,\\.\\)])" + let closures = "((\\-\\>)\(generic)\(modifiers)[,\\.\\)])" + let pattern = "\(wrapped)|\(constraint)|\(arrays)|\(tuples)|\(closures)" + guard let regex = try? NSRegularExpression(pattern: pattern) else { return false } + return regex.firstMatch(in: name, options: [], range: NSRange(location: 0, length: (name as NSString).length)) != nil + }) + } + + func replacingSelf(unwrap: Bool = false) -> String { + guard isSelfTypeRecursive() else { + return unwrap ? self.unwrapped : "\(type)" + } + + if isSelfType { + let optionality: String = { + if type.isImplicitlyUnwrappedOptional { + return "!" + } else if type.isOptional { + return "?" + } else { + return "" + } + }() + return unwrap ? Current.selfType : Current.selfType + optionality + } else if let tuple = type.tuple { + let inner = tuple.elements.map({ TypeWrapper($0.typeName).replacingSelf() }).joined(separator: ",") + let value = "(\(inner))" + return value + } else if let array = type.array { + let value = "[\(TypeWrapper(array.elementTypeName).replacingSelf())]" + return value + } else if let dictionary = type.dictionary { + let value = "[" + + "\(TypeWrapper(dictionary.valueTypeName).replacingSelf())" + + ":" + + "\(TypeWrapper(dictionary.keyTypeName).replacingSelf())" + + "]" + return value + } else if let closure = type.closure { + let returnType = TypeWrapper(closure.actualReturnTypeName).replacingSelf() + let inner = closure.parameters + .map { TypeWrapper($0.typeName).replacingSelf() } + .joined(separator: ",") + let throwing = closure.throws ? "throws " : "" + let value = "(\(inner)) \(throwing)-> \(returnType)" + return value + } else { + return (unwrap ? self.unwrapped : "\(type)") + } + } + + func replacingSelfRespectingVariadic() -> String { + return "\(vPref)\(replacingSelf())\(vSuff)" + } +} +func replacingSelf(_ value: String) -> String { + return value + // TODO: proper regex here + // default < case > + .replacingOccurrences(of: "", with: "<\(Current.selfType)>") + .replacingOccurrences(of: "", with: " \(Current.selfType)>") + .replacingOccurrences(of: ",Self>", with: ",\(Current.selfType)>") + // (Self) -> Case + .replacingOccurrences(of: "(Self)", with: "(\(Current.selfType))") + .replacingOccurrences(of: "(Self ", with: "(\(Current.selfType) ") + .replacingOccurrences(of: "(Self.", with: "(\(Current.selfType).") + .replacingOccurrences(of: "(Self,", with: "(\(Current.selfType),") + .replacingOccurrences(of: "(Self?", with: "(\(Current.selfType)?") + .replacingOccurrences(of: " Self)", with: " \(Current.selfType))") + .replacingOccurrences(of: ",Self)", with: ",\(Current.selfType))") + // literals + .replacingOccurrences(of: "[Self]", with: "[\(Current.selfType)]") + // right + .replacingOccurrences(of: "[Self ", with: "[\(Current.selfType) ") + .replacingOccurrences(of: "[Self.", with: "[\(Current.selfType).") + .replacingOccurrences(of: "[Self,", with: "[\(Current.selfType),") + .replacingOccurrences(of: "[Self:", with: "[\(Current.selfType):") + .replacingOccurrences(of: "[Self?", with: "[\(Current.selfType)?") + // left + .replacingOccurrences(of: " Self]", with: " \(Current.selfType)]") + .replacingOccurrences(of: ",Self]", with: ",\(Current.selfType)]") + .replacingOccurrences(of: ":Self]", with: ":\(Current.selfType)]") + // unknown + .replacingOccurrences(of: " Self ", with: " \(Current.selfType) ") + .replacingOccurrences(of: " Self.", with: " \(Current.selfType).") + .replacingOccurrences(of: " Self,", with: " \(Current.selfType),") + .replacingOccurrences(of: " Self:", with: " \(Current.selfType):") + .replacingOccurrences(of: " Self?", with: " \(Current.selfType)?") + .replacingOccurrences(of: ",Self ", with: ",\(Current.selfType) ") + .replacingOccurrences(of: ",Self,", with: ",\(Current.selfType),") + .replacingOccurrences(of: ",Self?", with: ",\(Current.selfType)?") +} + +class MethodWrapper { + private var noStubDefinedMessage: String { + let methodName = method.name.condenseWhitespace() + .replacingOccurrences(of: "( ", with: "(") + .replacingOccurrences(of: " )", with: ")") + return "Stub return value not specified for \(methodName). Use given" + } + private static var registered: [String: Int] = [:] + private static var suffixes: [String: Int] = [:] + private static var suffixesWithoutReturnType: [String: Int] = [:] + + let method: SourceryRuntime.Method + var accessModifier: String { + guard !method.isStatic else { return "public static" } + guard !returnsGenericConstrainedToSelf else { return "public" } + guard !parametersContainsSelf else { return "public" } + return Current.accessModifier + } + var hasAvailability: Bool { method.attributes["available"]?.isEmpty == false } + var isAsync: Bool { + self.method.annotations["async"] != nil + } + + private var registrationName: String { + var rawName = (method.isStatic ? "sm*\(method.selectorName)" : "m*\(method.selectorName)") + .replacingOccurrences(of: "_", with: "") + .replacingOccurrences(of: "(", with: "__") + .replacingOccurrences(of: ")", with: "") + + var parametersNames = method.parameters.map { "\($0.name)" } + + while let range = rawName.range(of: ":"), let name = parametersNames.first { + parametersNames.removeFirst() + rawName.replaceSubrange(range, with: "_\(name)") + } + + let trimSet = CharacterSet(charactersIn: "_") + + return rawName + .replacingOccurrences(of: ":", with: "") + .replacingOccurrences(of: "m*", with: "m_") + .replacingOccurrences(of: "___", with: "__").trimmingCharacters(in: trimSet) + } + private var uniqueName: String { + var rawName = (method.isStatic ? "sm_\(method.selectorName)" : "m_\(method.selectorName)") + var parametersNames = method.parameters.map { "\($0.name)_of_\($0.typeName.name)" } + + while let range = rawName.range(of: ":"), let name = parametersNames.first { + parametersNames.removeFirst() + rawName.replaceSubrange(range, with: "_\(name)") + } + + return rawName.trimmingCharacters(in: CharacterSet(charactersIn: "_")) + } + private var uniqueNameWithReturnType: String { + let returnTypeRaw = "\(method.returnTypeName)" + var returnTypeStripped: String = { + guard let range = returnTypeRaw.range(of: "where") else { return returnTypeRaw } + var stripped = returnTypeRaw + stripped.removeSubrange((range.lowerBound)...) + return stripped + }() + returnTypeStripped = returnTypeStripped.trimmingCharacters(in: CharacterSet(charactersIn: " ")) + return "\(uniqueName)->\(returnTypeStripped)" + } + private var nameSuffix: String { + guard let count = MethodWrapper.registered[registrationName] else { return "" } + guard count > 1 else { return "" } + guard let index = MethodWrapper.suffixes[uniqueNameWithReturnType] else { return "" } + return "_\(index)" + } + private var methodAttributes: String { + return Helpers.extractAttributes(from: self.method.attributes, filterOutStartingWith: ["mutating", "@inlinable"]) + } + private var methodAttributesNonObjc: String { + return Helpers.extractAttributes(from: self.method.attributes, filterOutStartingWith: ["mutating", "@inlinable", "@objc"]) + } + + var prototype: String { + return "\(registrationName)\(nameSuffix)".replacingOccurrences(of: "`", with: "") + } + var parameters: [ParameterWrapper] { + return filteredParameters.map { ParameterWrapper($0, self.getVariadicParametersNames()) } + } + var filteredParameters: [MethodParameter] { + return method.parameters.filter { $0.name != "" } + } + var functionPrototype: String { + let throwing: String = { + if method.throws { + return "throws " + } else if method.rethrows { + return "rethrows " + } else { + return "" + } + }() + + let staticModifier: String = "\(accessModifier) " + let params = replacingSelf(parametersForStubSignature()) + var attributes = self.methodAttributes + attributes = attributes.isEmpty ? "" : "\(attributes)\n\t" + var asyncModifier = self.isAsync ? "async " : "" + + if method.isInitializer { + return "\(attributes)public required \(method.name) \(asyncModifier)\(throwing)" + } else if method.returnTypeName.isVoid { + let wherePartIfNeeded: String = { + if method.returnTypeName.name.hasPrefix("Void") { + let range = method.returnTypeName.name.range(of: "Void")! + return "\(method.returnTypeName.name[range.upperBound...])" + } else { + return !method.returnTypeName.name.isEmpty ? "\(method.returnTypeName.name) " : "" + } + }() + return "\(attributes)\(staticModifier)func \(method.shortName)\(params) \(asyncModifier)\(throwing)\(wherePartIfNeeded)" + } else if returnsGenericConstrainedToSelf { + return "\(attributes)\(staticModifier)func \(method.shortName)\(params) \(asyncModifier)\(throwing)-> \(returnTypeReplacingSelf) " + } else { + return "\(attributes)\(staticModifier)func \(method.shortName)\(params) \(asyncModifier)\(throwing)-> \(method.returnTypeName.name) " + } + } + var invocation: String { + guard !method.isInitializer else { return "" } + if filteredParameters.isEmpty { + return "addInvocation(.\(prototype))" + } else { + return "addInvocation(.\(prototype)(\(parametersForMethodCall())))" + } + } + var givenValue: String { + guard !method.isInitializer else { return "" } + guard method.throws || !method.returnTypeName.isVoid else { return "" } + + let methodType = filteredParameters.isEmpty ? ".\(prototype)" : ".\(prototype)(\(parametersForMethodCall()))" + let returnType: String = returnsSelf ? "__Self__" : "\(TypeWrapper(method.returnTypeName).stripped)" + + if method.returnTypeName.isVoid { + return """ + \n\t\tdo { + \t\t _ = try methodReturnValue(\(methodType)).casted() as Void + \t\t}\(" ") + """ + } else { + let defaultValue = method.returnTypeName.isOptional ? " = nil" : "" + return """ + \n\t\tvar __value: \(returnType)\(defaultValue) + \t\tdo { + \t\t __value = try methodReturnValue(\(methodType)).casted() + \t\t}\(" ") + """ + } + } + var throwValue: String { + guard !method.isInitializer else { return "" } + guard method.throws || !method.returnTypeName.isVoid else { return "" } + let safeFailure = method.isStatic ? "" : "\t\t\tonFatalFailure(\"\(noStubDefinedMessage)\")\n" + // For Void and Returning optionals - we allow not stubbed case to happen, as we are still able to return + let noStubHandling = method.returnTypeName.isVoid || method.returnTypeName.isOptional ? "\t\t\t// do nothing" : "\(safeFailure)\t\t\tFailure(\"\(noStubDefinedMessage)\")" + guard method.throws else { + return """ + catch { + \(noStubHandling) + \t\t} + """ + } + + return """ + catch MockError.notStubed { + \(noStubHandling) + \t\t} catch { + \t\t throw error + \t\t} + """ + } + var returnValue: String { + guard !method.isInitializer else { return "" } + guard !method.returnTypeName.isVoid else { return "" } + + return "\n\t\treturn __value" + } + var equalCase: String { + guard !method.isInitializer else { return "" } + + if filteredParameters.isEmpty { + return "case (.\(prototype), .\(prototype)):" + } else { + let lhsParams = filteredParameters.map { "let lhs\($0.name.capitalized)" }.joined(separator: ", ") + let rhsParams = filteredParameters.map { "let rhs\($0.name.capitalized)" }.joined(separator: ", ") + return "case (.\(prototype)(\(lhsParams)), .\(prototype)(\(rhsParams))):" + } + } + func equalCases() -> String { + var results = self.equalCase + + guard !parameters.isEmpty else { + results += " return .match" + return results + } + + results += "\n\t\t\t\tvar results: [Matcher.ParameterComparisonResult] = []\n" + results += parameters.map { "\t\t\t\t\($0.comparatorResult())" }.joined(separator: "\n") + results += "\n\t\t\t\treturn Matcher.ComparisonResult(results)" + return results + } + var intValueCase: String { + if filteredParameters.isEmpty { + return "case .\(prototype): return 0" + } else { + let params = filteredParameters.enumerated().map { offset, _ in + return "p\(offset)" + } + let definitions = params.joined(separator: ", ") + let paramsSum = params.map({ "\($0).intValue" }).joined(separator: " + ") + return "case let .\(prototype)(\(definitions)): return \(paramsSum)" + } + } + var assertionName: String { + return "case .\(prototype): return \".\(method.selectorName)\(method.parameters.isEmpty ? "()" : "")\"" + } + + var returnsSelf: Bool { + guard !returnsGenericConstrainedToSelf else { return true } + return !method.returnTypeName.isVoid && TypeWrapper(method.returnTypeName).isSelfType + } + var returnsGenericConstrainedToSelf: Bool { + let defaultReturnType = "\(method.returnTypeName.name) " + return defaultReturnType != returnTypeReplacingSelf + } + var returnTypeReplacingSelf: String { + return replacingSelf("\(method.returnTypeName.name) ") + } + var parametersContainsSelf: Bool { + return replacingSelf(parametersForStubSignature()) != parametersForStubSignature() + } + + var replaceSelf: String { + return Current.selfType + } + + init(_ method: SourceryRuntime.Method) { + self.method = method + } + + public static func clear() -> String { + MethodWrapper.registered = [:] + MethodWrapper.suffixes = [:] + MethodWrapper.suffixesWithoutReturnType = [:] + return "" + } + + func register() { + MethodWrapper.register(registrationName,uniqueName,uniqueNameWithReturnType) + } + + static func register(_ name: String, _ uniqueName: String, _ uniqueNameWithReturnType: String) { + if let count = MethodWrapper.registered[name] { + MethodWrapper.registered[name] = count + 1 + MethodWrapper.suffixes[uniqueNameWithReturnType] = count + 1 + } else { + MethodWrapper.registered[name] = 1 + MethodWrapper.suffixes[uniqueNameWithReturnType] = 1 + } + + if let count = MethodWrapper.suffixesWithoutReturnType[uniqueName] { + MethodWrapper.suffixesWithoutReturnType[uniqueName] = count + 1 + } else { + MethodWrapper.suffixesWithoutReturnType[uniqueName] = 1 + } + } + + func returnTypeMatters() -> Bool { + let count = MethodWrapper.suffixesWithoutReturnType[uniqueName] ?? 0 + return count > 1 + } + + func wrappedInMethodType() -> Bool { + return !method.isInitializer + } + + func returningParameter(_ multiple: Bool, _ front: Bool) -> String { + guard returnTypeMatters() else { return "" } + let returning: String = "returning: \(returnTypeStripped(method, type: true))" + guard multiple else { return returning } + + return front ? ", \(returning)" : "\(returning), " + } + + // Stub + func stubBody() -> String { + let body: String = { + if method.isInitializer || !returnsSelf { + return invocation + performCall() + givenValue + throwValue + returnValue + } else { + return wrappedStubPrefix() + + "\t\t" + invocation + + performCall() + + givenValue + + throwValue + + returnValue + + wrappedStubPostfix() + } + }() + return replacingSelf(body) + } + + func wrappedStubPrefix() -> String { + guard !method.isInitializer, returnsSelf else { + return "" + } + + let throwing: String = { + if method.throws { + return "throws " + } else if method.rethrows { + return "rethrows " + } else { + return "" + } + }() + + return "func _wrapped<__Self__>() \(throwing)-> __Self__ {\n" + } + + func wrappedStubPostfix() -> String { + guard !method.isInitializer, returnsSelf else { + return "" + } + + let throwing: String = (method.throws || method.rethrows) ? "try ": "" + + return "\n\t\t}" + + "\n\t\treturn \(throwing)_wrapped()" + } + + // Method Type + func methodTypeDeclarationWithParameters() -> String { + if filteredParameters.isEmpty { + return "case \(prototype)" + } else { + return "case \(prototype)(\(parametersForMethodTypeDeclaration(availability: hasAvailability)))" + } + } + + // Given + func containsEmptyArgumentLabels() -> Bool { + return parameters.contains(where: { $0.parameter.argumentLabel == nil }) + } + + func givenReturnTypeString() -> String { + let returnTypeString: String = { + guard !returnsGenericConstrainedToSelf else { return returnTypeReplacingSelf } + guard !returnsSelf else { return replaceSelf } + return TypeWrapper(method.returnTypeName).stripped + }() + return returnTypeString + } + + func givenConstructorName(prefix: String = "") -> String { + let returnTypeString = givenReturnTypeString() + let (annotation, _, _) = methodInfo() + let clauseConstraints = whereClauseExpression() + + if filteredParameters.isEmpty { + return "\(annotation)public static func \(method.shortName)(willReturn: \(returnTypeString)...) -> \(prefix)MethodStub" + clauseConstraints + } else { + return "\(annotation)public static func \(method.shortName)(\(parametersForProxySignature()), willReturn: \(returnTypeString)...) -> \(prefix)MethodStub" + clauseConstraints + } + } + + func givenConstructorNameThrows(prefix: String = "") -> String { + let (annotation, _, _) = methodInfo() + let clauseConstraints = whereClauseExpression() + + let genericsArray = getGenericsConstraints(getGenericsAmongParameters(), filterSingle: false) + let generics = genericsArray.isEmpty ? "" : "<\(genericsArray.joined(separator: ", "))>" + + if filteredParameters.isEmpty { + return "\(annotation)public static func \(method.callName)\(generics)(willThrow: Error...) -> \(prefix)MethodStub" + clauseConstraints + } else { + return "\(annotation)public static func \(method.callName)\(generics)(\(parametersForProxySignature()), willThrow: Error...) -> \(prefix)MethodStub" + clauseConstraints + } + } + + func givenConstructor(prefix: String = "") -> String { + if filteredParameters.isEmpty { + return "return \(prefix)Given(method: .\(prototype), products: willReturn.map({ StubProduct.return($0 as Any) }))" + } else { + return "return \(prefix)Given(method: .\(prototype)(\(parametersForProxyInit())), products: willReturn.map({ StubProduct.return($0 as Any) }))" + } + } + + func givenConstructorThrows(prefix: String = "") -> String { + if filteredParameters.isEmpty { + return "return \(prefix)Given(method: .\(prototype), products: willThrow.map({ StubProduct.throw($0) }))" + } else { + return "return \(prefix)Given(method: .\(prototype)(\(parametersForProxyInit())), products: willThrow.map({ StubProduct.throw($0) }))" + } + } + + // Given willProduce + func givenProduceConstructorName(prefix: String = "") -> String { + let returnTypeString = givenReturnTypeString() + let (annotation, _, _) = methodInfo() + let produceClosure = "(Stubber<\(returnTypeString)>) -> Void" + let clauseConstraints = whereClauseExpression() + + if filteredParameters.isEmpty { + return "\(annotation)public static func \(method.shortName)(willProduce: \(produceClosure)) -> \(prefix)MethodStub" + clauseConstraints + } else { + return "\(annotation)public static func \(method.shortName)(\(parametersForProxySignature()), willProduce: \(produceClosure)) -> \(prefix)MethodStub" + clauseConstraints + } + } + + func givenProduceConstructorNameThrows(prefix: String = "") -> String { + let returnTypeString = givenReturnTypeString() + let (annotation, _, _) = methodInfo() + let produceClosure = "(StubberThrows<\(returnTypeString)>) -> Void" + let clauseConstraints = whereClauseExpression() + + if filteredParameters.isEmpty { + return "\(annotation)public static func \(method.shortName)(willProduce: \(produceClosure)) -> \(prefix)MethodStub" + clauseConstraints + } else { + return "\(annotation)public static func \(method.shortName)(\(parametersForProxySignature()), willProduce: \(produceClosure)) -> \(prefix)MethodStub" + clauseConstraints + } + } + + func givenProduceConstructor(prefix: String = "") -> String { + let returnTypeString = givenReturnTypeString() + return """ + let willReturn: [\(returnTypeString)] = [] + \t\t\tlet given: \(prefix)Given = { \(givenConstructor(prefix: prefix)) }() + \t\t\tlet stubber = given.stub(for: (\(returnTypeString)).self) + \t\t\twillProduce(stubber) + \t\t\treturn given + """ + } + + func givenProduceConstructorThrows(prefix: String = "") -> String { + let returnTypeString = givenReturnTypeString() + return """ + let willThrow: [Error] = [] + \t\t\tlet given: \(prefix)Given = { \(givenConstructorThrows(prefix: prefix)) }() + \t\t\tlet stubber = given.stubThrows(for: (\(returnTypeString)).self) + \t\t\twillProduce(stubber) + \t\t\treturn given + """ + } + + // Verify + func verificationProxyConstructorName(prefix: String = "") -> String { + let (annotation, methodName, genericConstrains) = methodInfo() + + if filteredParameters.isEmpty { + return "\(annotation)public static func \(methodName)(\(returningParameter(false,true))) -> \(prefix)Verify\(genericConstrains)" + } else { + return "\(annotation)public static func \(methodName)(\(parametersForProxySignature())\(returningParameter(true,true))) -> \(prefix)Verify\(genericConstrains)" + } + } + + func verificationProxyConstructor(prefix: String = "") -> String { + if filteredParameters.isEmpty { + return "return \(prefix)Verify(method: .\(prototype))" + } else { + return "return \(prefix)Verify(method: .\(prototype)(\(parametersForProxyInit())))" + } + } + + // Perform + func performProxyConstructorName(prefix: String = "") -> String { + let body: String = { + let (annotation, methodName, genericConstrains) = methodInfo() + + if filteredParameters.isEmpty { + return "\(annotation)public static func \(methodName)(\(returningParameter(true,false))perform: @escaping \(performProxyClosureType())) -> \(prefix)Perform\(genericConstrains)" + } else { + return "\(annotation)public static func \(methodName)(\(parametersForProxySignature()), \(returningParameter(true,false))perform: @escaping \(performProxyClosureType())) -> \(prefix)Perform\(genericConstrains)" + } + }() + return replacingSelf(body) + } + + func performProxyConstructor(prefix: String = "") -> String { + if filteredParameters.isEmpty { + return "return \(prefix)Perform(method: .\(prototype), performs: perform)" + } else { + return "return \(prefix)Perform(method: .\(prototype)(\(parametersForProxyInit())), performs: perform)" + } + } + + func performProxyClosureType() -> String { + if filteredParameters.isEmpty { + return "() -> Void" + } else { + let parameters = self.parameters + .map { "\($0.justPerformType)" } + .joined(separator: ", ") + return "(\(parameters)) -> Void" + } + } + + func performProxyClosureCall() -> String { + if filteredParameters.isEmpty { + return "perform?()" + } else { + let parameters = filteredParameters + .map { p in + let wrapped = ParameterWrapper(p, self.getVariadicParametersNames()) + let isAutolosure = wrapped.justType.hasPrefix("@autoclosure") + return "\(p.inout ? "&" : "")`\(p.name)`\(isAutolosure ? "()" : "")" + } + .joined(separator: ", ") + return "perform?(\(parameters))" + } + } + + func performCall() -> String { + guard !method.isInitializer else { return "" } + let type = performProxyClosureType() + var proxy = filteredParameters.isEmpty ? "\(prototype)" : "\(prototype)(\(parametersForMethodCall()))" + + let cast = "let perform = methodPerformValue(.\(proxy)) as? \(type)" + let call = performProxyClosureCall() + + return "\n\t\t\(cast)\n\t\t\(call)" + } + + // Helpers + private func parametersForMethodCall() -> String { + let generics = getGenericsWithoutConstraints() + return parameters.map { $0.wrappedForCalls(generics, hasAvailability) }.joined(separator: ", ") + } + + private func parametersForMethodTypeDeclaration(availability: Bool) -> String { + let generics = getGenericsWithoutConstraints() + return parameters.map { param in + if param.isGeneric(generics) { return param.genericType } + if availability { return param.typeErasedType } + return replacingSelf(param.nestedType) + }.joined(separator: ", ") + } + + private func parametersForProxySignature() -> String { + return parameters.map { p in + return "\(p.labelAndName()): \(replacingSelf(p.nestedType))" + }.joined(separator: ", ") + } + + private func parametersForStubSignature() -> String { + func replacing(first: String, in full: String, with other: String) -> String { + guard let range = full.range(of: first) else { return full } + return full.replacingCharacters(in: range, with: other) + } + let prefix = method.shortName + let full = method.name + let range = full.range(of: prefix)! + var unrefined = "\(full[range.upperBound...])" + parameters.map { p -> (String,String) in + return ("\(p.type)","\(p.justType)") + }.forEach { + unrefined = replacing(first: $0, in: unrefined, with: $1) + } + return unrefined + } + + private func parametersForProxyInit() -> String { + let generics = getGenericsWithoutConstraints() + return parameters.map { "\($0.wrappedForProxy(generics, hasAvailability))" }.joined(separator: ", ") + } + + private func isGeneric() -> Bool { + return method.shortName.contains("<") && method.shortName.contains(">") + } + + private func getVariadicParametersNames() -> [String] { + let pattern = "[\\(|,]( *[_|\\w]* )? *(\\w+) *\\: *(.+?\\.\\.\\.)" + let str = method.name + let range = NSRange(location: 0, length: (str as NSString).length) + + guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] } + + var result: [String] = regex + .matches(in: str, options: [], range: range) + .compactMap { match -> String? in + guard let nameRange = Range(match.range(at: 2), in: str) else { return nil } + return String(str[nameRange]) + } + return result + } + + /// Returns list of generics used in method signature, without their constraints (like [T,U,V]) + /// + /// - Returns: Array of strings, where each strings represent generic name + private func getGenericsWithoutConstraints() -> [String] { + let name = method.shortName + guard let start = name.index(of: "<"), let end = name.index(of: ">") else { return [] } + + var genPart = name[start...end] + genPart.removeFirst() + genPart.removeLast() + + let parts = genPart.replacingOccurrences(of: " ", with: "").split(separator: ",").map(String.init) + return parts.map { stripGenPart(part: $0) } + } + + /// Returns list of generic constraintes from method signature. Does only contain stuff between '<' and '>' + /// + /// - Returns: Array of strings, like ["T: Codable", "U: Whatever"] + private func getGenericsConstraints(_ generics: [String], filterSingle: Bool = true) -> [String] { + let name = method.shortName + guard let start = name.index(of: "<"), let end = name.index(of: ">") else { return [] } + + var genPart = name[start...end] + genPart.removeFirst() + genPart.removeLast() + + let parts = genPart.replacingOccurrences(of: " ", with: "").split(separator: ",").map(String.init) + return parts.filter { + let components = $0.components(separatedBy: ":") + return (components.count == 2 || !filterSingle) && generics.contains(components[0]) + } + } + + private func getGenericsAmongParameters() -> [String] { + return getGenericsWithoutConstraints().filter { + for param in self.parameters { + if param.isGeneric([$0]) { return true } + } + return false + } + } + + private func wrapGenerics(_ generics: [String]) -> String { + guard !generics.isEmpty else { return "" } + return "<\(generics.joined(separator:","))>" + } + + private func stripGenPart(part: String) -> String { + return part.split(separator: ":").map(String.init).first! + } + + private func returnTypeStripped(_ method: SourceryRuntime.Method, type: Bool = false) -> String { + let returnTypeRaw = "\(method.returnTypeName)" + var stripped: String = { + guard let range = returnTypeRaw.range(of: "where") else { return returnTypeRaw } + var stripped = returnTypeRaw + stripped.removeSubrange((range.lowerBound)...) + return stripped + }() + stripped = stripped.trimmingCharacters(in: CharacterSet(charactersIn: " ")) + guard type else { return stripped } + return "(\(stripped)).Type" + } + + private func whereClauseConstraints() -> [String] { + let returnTypeRaw = method.returnTypeName.name + guard let range = returnTypeRaw.range(of: "where") else { return [] } + var whereClause = returnTypeRaw + whereClause.removeSubrange(...(range.upperBound)) + return whereClause + .trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + .components(separatedBy: ",") + } + + private func whereClauseExpression() -> String { + let constraints = whereClauseConstraints() + if constraints.isEmpty { + return "" + } + return " where " + constraints.joined(separator: ", ") + } + + private func methodInfo() -> (annotation: String, methodName: String, genericConstrains: String) { + let generics = getGenericsAmongParameters() + let methodName = returnTypeMatters() ? method.shortName : "\(method.callName)\(wrapGenerics(generics))" + let constraints: String = { + let constraints: [String] + if returnTypeMatters() { + constraints = whereClauseConstraints() + } else { + constraints = getGenericsConstraints(generics) + } + guard !constraints.isEmpty else { return "" } + + return " where \(constraints.joined(separator: ", "))" + }() + var attributes = self.methodAttributesNonObjc + attributes = attributes.condenseWhitespace() + attributes = attributes.isEmpty ? "" : "\(attributes)\n\t\t" + return (attributes, methodName, constraints) + } +} + +extension String { + func condenseWhitespace() -> String { + let components = self.components(separatedBy: .whitespacesAndNewlines) + return components.filter { !$0.isEmpty }.joined(separator: " ") + } +} +class SubscriptWrapper { + let wrapped: SourceryRuntime.Subscript + var readonly: Bool { return !wrapped.isMutable } + var wrappedParameters: [ParameterWrapper] { return wrapped.parameters.map { ParameterWrapper($0) } } + var casesCount: Int { return readonly ? 1 : 2 } + var nestedType: String { return "\(TypeWrapper(wrapped.returnTypeName).nestedParameter)" } + let associatedTypes: [String]? + let genericTypesList: [String] + let genericTypesModifier: String? + let whereClause: String + var hasAvailability: Bool { wrapped.attributes["available"]?.isEmpty == false } + + private var methodAttributes: String { + return Helpers.extractAttributes(from: self.wrapped.attributes, filterOutStartingWith: ["mutating", "@inlinable"]) + } + private var methodAttributesNonObjc: String { + return Helpers.extractAttributes(from: self.wrapped.attributes, filterOutStartingWith: ["mutating", "@inlinable", "@objc"]) + } + + private let noStubDefinedMessage = "Stub return value not specified for subscript. Use given first." + + private static var registered: [String: Int] = [:] + private static var namesWithoutReturnType: [String: Int] = [:] + private static var suffixes: [String: Int] = [:] + public static func clear() -> String { + SubscriptWrapper.registered = [:] + SubscriptWrapper.suffixes = [:] + namesWithoutReturnType = [:] + return "" + } + static func register(_ name: String, _ uniqueName: String) { + let count = SubscriptWrapper.registered[name] ?? 0 + SubscriptWrapper.registered[name] = count + 1 + SubscriptWrapper.suffixes[uniqueName] = count + 1 + } + static func register(short name: String) { + let count = SubscriptWrapper.namesWithoutReturnType[name] ?? 0 + SubscriptWrapper.namesWithoutReturnType[name] = count + 1 + } + + func register() { + SubscriptWrapper.register(registrationName("get"),uniqueName) + SubscriptWrapper.register(short: shortName) + guard !readonly else { return } + SubscriptWrapper.register(registrationName("set"),uniqueName) + } + + init(_ wrapped: SourceryRuntime.Subscript) { + self.wrapped = wrapped + associatedTypes = Helpers.extractAssociatedTypes(from: wrapped) + genericTypesList = Helpers.extractGenericsList(associatedTypes) + whereClause = Helpers.extractWhereClause(from: wrapped) ?? "" + if let types = associatedTypes { + genericTypesModifier = "<\(types.joined(separator: ","))>" + } else { + genericTypesModifier = nil + } + } + + func registrationName(_ accessor: String) -> String { + return "subscript_\(accessor)_\(wrappedParameters.map({ $0.sanitizedForEnumCaseName() }).joined(separator: "_"))" + } + var shortName: String { return "public subscript\(genericTypesModifier ?? " ")(\(wrappedParameters.map({ $0.asMethodArgument() }).joined(separator: ", ")))" } + var uniqueName: String { return "\(shortName) -> \(wrapped.returnTypeName)\(self.whereClause)" } + + private func nameSuffix(_ accessor: String) -> String { + guard let count = SubscriptWrapper.registered[registrationName(accessor)] else { return "" } + guard count > 1 else { return "" } + guard let index = SubscriptWrapper.suffixes[uniqueName] else { return "" } + return "_\(index)" + } + + // call + func subscriptCall() -> String { + let get = "\n\t\tget {\(getter())\n\t\t}" + let set = readonly ? "" : "\n\t\tset {\(setter())\n\t\t}" + var attributes = self.methodAttributesNonObjc + attributes = attributes.isEmpty ? "" : "\(attributes)\n\t" + return "\(attributes)\(uniqueName) {\(get)\(set)\n\t}" + } + private func getter() -> String { + let method = ".\(subscriptCasePrefix("get"))(\(parametersForMethodCall()))" + let optionalReturnWorkaround = "\(wrapped.returnTypeName)".hasSuffix("?") + let noStubDefined = (optionalReturnWorkaround || wrapped.returnTypeName.isOptional) ? "return nil" : "onFatalFailure(\"\(noStubDefinedMessage)\"); Failure(\"noStubDefinedMessage\")" + return + "\n\t\t\taddInvocation(\(method))" + + "\n\t\t\tdo {" + + "\n\t\t\t\treturn try methodReturnValue(\(method)).casted()" + + "\n\t\t\t} catch {" + + "\n\t\t\t\t\(noStubDefined)" + + "\n\t\t\t}" + } + private func setter() -> String { + let method = ".\(subscriptCasePrefix("set"))(\(parametersForMethodCall(set: true)))" + return "\n\t\t\taddInvocation(\(method))" + } + + var assertionName: String { + return readonly ? assertionName("get") : "\(assertionName("get"))\n\t\t\t\(assertionName("set"))" + } + private func assertionName(_ accessor: String) -> String { + return "case .\(subscriptCasePrefix(accessor)): return " + + "\"[\(accessor)] `subscript`\(genericTypesModifier ?? "")[\(parametersForAssertionName())]\"" + } + + // method type + func subscriptCasePrefix(_ accessor: String) -> String { + return "\(registrationName(accessor))\(nameSuffix(accessor))" + } + func subscriptCaseName(_ accessor: String, availability: Bool = false) -> String { + return "\(subscriptCasePrefix(accessor))(\(parametersForMethodTypeDeclaration(availability: availability, set: accessor == "set")))" + } + func subscriptCases() -> String { + if readonly { + return "case \(subscriptCaseName("get", availability: hasAvailability))" + } else { + return "case \(subscriptCaseName("get", availability: hasAvailability))\n\t\tcase \(subscriptCaseName("set", availability: hasAvailability))" + } + } + func equalCase(_ accessor: String) -> String { + var lhsParams = wrapped.parameters.map { "lhs\($0.name.capitalized)" }.joined(separator: ", ") + var rhsParams = wrapped.parameters.map { "rhs\($0.name.capitalized)" }.joined(separator: ", ") + var comparators = "\t\t\t\tvar results: [Matcher.ParameterComparisonResult] = []\n" + comparators += wrappedParameters.map { "\t\t\t\t\($0.comparatorResult())" }.joined(separator: "\n") + + if accessor == "set" { + lhsParams += ", lhsDidSet" + rhsParams += ", rhsDidSet" + comparators += "\n\t\t\t\tresults.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsDidSet, rhs: rhsDidSet, with: matcher), lhsDidSet, rhsDidSet, \"newValue\"))" + } + + comparators += "\n\t\t\t\treturn Matcher.ComparisonResult(results)" + + // comparatorResult() + return "case (let .\(subscriptCasePrefix(accessor))(\(lhsParams)), let .\(subscriptCasePrefix(accessor))(\(rhsParams))):\n" + comparators + } + func equalCases() -> String { + return readonly ? equalCase("get") : "\(equalCase("get"))\n\t\t\t\(equalCase("set"))" + } + func intValueCase() -> String { + return readonly ? intValueCase("get") : "\(intValueCase("get"))\n\t\t\t\(intValueCase("set"))" + } + func intValueCase(_ accessor: String) -> String { + let params = wrappedParameters.enumerated().map { offset, _ in + return "p\(offset)" + } + let definitions = params.joined(separator: ", ") + (accessor == "set" ? ", _" : "") + let paramsSum = params.map({ "\($0).intValue" }).joined(separator: " + ") + return "case let .\(subscriptCasePrefix(accessor))(\(definitions)): return \(paramsSum)" + } + + // Given + func givenConstructorName() -> String { + let returnTypeString = returnsSelf ? replaceSelf : TypeWrapper(wrapped.returnTypeName).stripped + var attributes = self.methodAttributesNonObjc + attributes = attributes.isEmpty ? "" : "\(attributes)\n\t\t" + return "\(attributes)public static func `subscript`\(genericTypesModifier ?? "")(\(parametersForProxySignature()), willReturn: \(returnTypeString)...) -> SubscriptStub" + } + func givenConstructor() -> String { + return "return Given(method: .\(subscriptCasePrefix("get"))(\(parametersForProxyInit())), products: willReturn.map({ StubProduct.return($0 as Any) }))" + } + + // Verify + func verifyConstructorName(set: Bool = false) -> String { + let returnTypeString = returnsSelf ? replaceSelf : nestedType + let returning = set ? "" : returningParameter(true, true) + var attributes = self.methodAttributesNonObjc + attributes = attributes.isEmpty ? "" : "\(attributes)\n\t\t" + return "\(attributes)public static func `subscript`\(genericTypesModifier ?? "")(\(parametersForProxySignature())\(returning)\(set ? ", set newValue: \(returnTypeString)" : "")) -> Verify" + } + func verifyConstructor(set: Bool = false) -> String { + return "return Verify(method: .\(subscriptCasePrefix(set ? "set" : "get"))(\(parametersForProxyInit(set: set))))" + } + + // Generics + private func getGenerics() -> [String] { + return genericTypesList + } + + // Helpers + private var returnsSelf: Bool { return TypeWrapper(wrapped.returnTypeName).isSelfType } + private var replaceSelf: String { return Current.selfType } + private func returnTypeStripped(type: Bool = false) -> String { + let returnTypeRaw = "\(wrapped.returnTypeName)" + var stripped: String = { + guard let range = returnTypeRaw.range(of: "where") else { return returnTypeRaw } + var stripped = returnTypeRaw + stripped.removeSubrange((range.lowerBound)...) + return stripped + }() + stripped = stripped.trimmingCharacters(in: CharacterSet(charactersIn: " ")) + guard type else { return stripped } + return "(\(stripped)).Type" + } + private func returnTypeMatters() -> Bool { + let count = SubscriptWrapper.namesWithoutReturnType[shortName] ?? 0 + return count > 1 + } + + // params + private func returningParameter(_ multiple: Bool, _ front: Bool) -> String { + guard returnTypeMatters() else { return "" } + let returning: String = "returning: \(returnTypeStripped(type: true))" + guard multiple else { return returning } + return front ? ", \(returning)" : "\(returning), " + } + private func parametersForMethodTypeDeclaration(availability: Bool = false, set: Bool = false) -> String { + let generics: [String] = getGenerics() + let params = wrappedParameters.map { param in + if param.isGeneric(generics) { return param.genericType } + if availability { return param.typeErasedType } + return param.nestedType + }.joined(separator: ", ") + guard set else { return params } + let newValue = TypeWrapper(wrapped.returnTypeName).isGeneric(generics) ? "Parameter" : nestedType + return "\(params), \(newValue)" + } + private func parametersForProxyInit(set: Bool = false) -> String { + let generics = getGenerics() + let newValue = TypeWrapper(wrapped.returnTypeName).isGeneric(generics) ? "newValue.wrapAsGeneric()" : "newValue" + return wrappedParameters.map { "\($0.wrappedForProxy(generics, hasAvailability))" }.joined(separator: ", ") + (set ? ", \(newValue)" : "") + } + private func parametersForProxySignature(set: Bool = false) -> String { + return wrappedParameters.map { "\($0.labelAndName()): \($0.nestedType)" }.joined(separator: ", ") + (set ? ", set newValue: \(nestedType)" : "") + } + private func parametersForAssertionName() -> String { + return wrappedParameters.map { "\($0.labelAndName())" }.joined(separator: ", ") + } + private func parametersForMethodCall(set: Bool = false) -> String { + let generics = getGenerics() + let params = wrappedParameters.map { $0.wrappedForCalls(generics, hasAvailability) }.joined(separator: ", ") + let postfix = TypeWrapper(wrapped.returnTypeName).isGeneric(generics) ? ".wrapAsGeneric()" : "" + return !set ? params : "\(params), \(nestedType).value(newValue)\(postfix)" + } +} +class VariableWrapper { + let variable: SourceryRuntime.Variable + let scope: String + var readonly: Bool { return variable.writeAccess.isEmpty } + var privatePrototypeName: String { return "__p_\(variable.name)".replacingOccurrences(of: "`", with: "") } + var casesCount: Int { return readonly ? 1 : 2 } + + var accessModifier: String { + // TODO: Fix access levels for SwiftyPrototype + // guard variable.type?.accessLevel != "internal" else { return "" } + return "public " + } + var attributes: String { + let value = Helpers.extractAttributes(from: self.variable.attributes) + return value.isEmpty ? "\(accessModifier)" : "\(value)\n\t\t\(accessModifier)" + } + var noStubDefinedMessage: String { return "\(scope) - stub value for \(variable.name) was not defined" } + + var getter: String { + let staticModifier = variable.isStatic ? "\(scope)." : "" + let returnValue = variable.isOptional ? "optionalGivenGetterValue(.\(propertyCaseGetName), \"\(noStubDefinedMessage)\")" : "givenGetterValue(.\(propertyCaseGetName), \"\(noStubDefinedMessage)\")" + return "\n\t\tget {\t\(staticModifier)invocations.append(.\(propertyCaseGetName)); return \(staticModifier)\(privatePrototypeName) ?? \(returnValue) }" + } + var setter: String { + let staticModifier = variable.isStatic ? "\(scope)." : "" + if readonly { + return "" + } else { + return "\n\t\tset {\t\(staticModifier)invocations.append(.\(propertyCaseSetName)(.value(newValue))); \(variable.isStatic ? "\(scope)." : "")\(privatePrototypeName) = newValue }" + } + } + var prototype: String { + let staticModifier = variable.isStatic ? "static " : "" + + return "\(attributes)\(staticModifier)var \(variable.name): \(variable.typeName.name) {" + + "\(getter)" + + "\(setter)" + + "\n\t}" + } + var assertionName: String { + var result = "case .\(propertyCaseGetName): return \"[get] .\(variable.name)\"" + if !readonly { + result += "\n\t\t\tcase .\(propertyCaseSetName): return \"[set] .\(variable.name)\"" + } + return result + } + + var privatePrototype: String { + let staticModifier = variable.isStatic ? "static " : "" + var typeName = "\(variable.typeName.unwrappedTypeName)" + let isWrappedInBrackets = typeName.hasPrefix("(") && typeName.hasSuffix(")") + if !isWrappedInBrackets { + typeName = "(\(typeName))" + } + return "private \(staticModifier)var \(privatePrototypeName): \(typeName)?" + } + var nestedType: String { return "\(TypeWrapper(variable.typeName).nestedParameter)" } + + init(_ variable: SourceryRuntime.Variable, scope: String) { + self.variable = variable + self.scope = scope + } + + func compareCases() -> String { + var result = propertyCaseGetCompare() + if !readonly { + result += "\n\t\t\t\(propertyCaseSetCompare())" + } + return result + } + + func propertyGet() -> String { + let staticModifier = variable.isStatic ? "Static" : "" + return "public static var \(variable.name): \(staticModifier)Verify { return \(staticModifier)Verify(method: .\(propertyCaseGetName)) }" + } + + func propertySet() -> String { + let staticModifier = variable.isStatic ? "Static" : "" + return "public static func \(variable.name)(set newValue: \(nestedType)) -> \(staticModifier)Verify { return \(staticModifier)Verify(method: .\(propertyCaseSetName)(newValue)) }" + } + + var propertyCaseGetName: String { return "p_\(variable.name)_get".replacingOccurrences(of: "`", with: "") } + func propertyCaseGet() -> String { + return "case \(propertyCaseGetName)" + } + func propertyCaseGetCompare() -> String { + return "case (.\(propertyCaseGetName),.\(propertyCaseGetName)): return Matcher.ComparisonResult.match" + } + func propertyCaseGetIntValue() -> String { + return "case .\(propertyCaseGetName): return 0" + } + + var propertyCaseSetName: String { return "p_\(variable.name)_set".replacingOccurrences(of: "`", with: "") } + func propertyCaseSet() -> String { + return "case \(propertyCaseSetName)(\(nestedType))" + } + func propertyCaseSetCompare() -> String { + let lhsName = "left" + let rhsName = "right" + let comaprison = "Matcher.ParameterComparisonResult(\(nestedType).compare(lhs: \(lhsName), rhs: \(rhsName), with: matcher), \(lhsName), \(rhsName), \"newValue\")" + let result = "Matcher.ComparisonResult([\(comaprison)])" + return "case (.\(propertyCaseSetName)(let left),.\(propertyCaseSetName)(let right)): return \(result)" + } + func propertyCaseSetIntValue() -> String { + return "case .\(propertyCaseSetName)(let newValue): return newValue.intValue" + } + + // Given + func givenConstructorName(prefix: String = "") -> String { + return "\(attributes)static func \(variable.name)(getter defaultValue: \(TypeWrapper(variable.typeName).stripped)...) -> \(prefix)PropertyStub" + } + + func givenConstructor(prefix: String = "") -> String { + return "return \(prefix)Given(method: .\(propertyCaseGetName), products: defaultValue.map({ StubProduct.return($0 as Any) }))" + } +} +_%> +<%# ================================================== SETUP -%><%_ -%> +<%_ var all = types.all + all += types.protocols.map { $0 } + all += types.protocolCompositions.map { $0 } + var mockedCount = 0 +-%> + +<%_ for type in all { -%><%_ -%> +<%_ let autoMockable: Bool = type.inheritedTypes.contains("AutoMockable") || type.annotations["AutoMockable"] != nil + let protocolToDecorate = types.protocols.first(where: { $0.name == (type.annotations["mock"] as? String) }) + let inlineMockable = protocolToDecorate != nil + guard let aProtocol = autoMockable ? type : protocolToDecorate else { continue } + mockedCount += 1 + + let associatedTypes: [String]? = Helpers.extractAssociatedTypes(from: aProtocol) + let attributes: String = Helpers.extractAttributes(from: type.attributes) + let typeAliases: [String] = Helpers.extractTypealiases(from: aProtocol) + let genericTypesModifier: String = Helpers.extractGenericTypesModifier(associatedTypes) + let genericTypesConstraints: String = Helpers.extractGenericTypesConstraints(associatedTypes) + let allSubscripts = aProtocol.allSubscripts + let allVariables = uniques(variables: aProtocol.allVariables.filter({ !$0.isStatic })) + let containsVariables = !allVariables.isEmpty + let allStaticVariables = uniques(variables: aProtocol.allVariables.filter({ $0.isStatic })) + let containsStaticVariables = !allStaticVariables.isEmpty + let allMethods = uniques(methods: aProtocol.allMethods.filter({ !$0.isStatic || $0.isInitializer })) + let selfConstrained = allMethods.map(wrapMethod).contains(where: { $0.returnsGenericConstrainedToSelf || $0.parametersContainsSelf }) + let accessModifier: String = selfConstrained ? "public final" : "open" + Current.accessModifier = accessModifier // TODO: Temporary workaround for access modifiers + let inheritFromNSObject = type.annotations["ObjcProtocol"] != nil || attributes.contains("@objc") + let allMethodsForMethodType = uniquesWithoutGenericConstraints(methods: aProtocol.allMethods.filter({ !$0.isStatic })) + let allStaticMethods = uniques(methods: aProtocol.allMethods.filter({ $0.isStatic && !$0.isInitializer })) + let allStaticMethodsForMethodType = uniquesWithoutGenericConstraints(methods: aProtocol.allMethods.filter({ $0.isStatic })) + let conformsToStaticMock = !allStaticMethods.isEmpty || !allStaticVariables.isEmpty + let conformsToMock = !allMethods.isEmpty || !allVariables.isEmpty -%><%_ -%><%_ -%> +<%_ if autoMockable { -%> +// MARK: - <%= type.name %> +<%= attributes %> +<%= accessModifier %> class <%= type.name %><%= mockTypeName %><%= genericTypesModifier %>:<%= inheritFromNSObject ? " NSObject," : "" %> <%= type.name %>, Mock<%= conformsToStaticMock ? ", StaticMock" : "" %><%= genericTypesConstraints %> { + public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { + SwiftyMockyTestObserver.setup() + self.sequencingPolicy = sequencingPolicy + self.stubbingPolicy = stubbingPolicy + self.file = file + self.line = line + } + +<%_ } else { -%> +// sourcery:inline:auto:<%= type.name %>.autoMocked +<%_ } -%> +<%# ================================================== MAIN CLASS -%><%_ -%> + <%# ================================================== MOCK INTERNALS -%><%_ -%> + var matcher: Matcher = Matcher.default + var stubbingPolicy: StubbingPolicy = .wrap + var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst + + private var queue = DispatchQueue(label: "com.swiftymocky.invocations", qos: .userInteractive) + private var invocations: [MethodType] = [] + private var methodReturnValues: [Given] = [] + private var methodPerformValues: [Perform] = [] + private var file: StaticString? + private var line: UInt? + + public typealias PropertyStub = Given + public typealias MethodStub = Given + public typealias SubscriptStub = Given + <%_ for typeAlias in typeAliases { -%> + public typealias <%= typeAlias %> + <%_ } %> <%_ -%> + + /// Convenience method - call setupMock() to extend debug information when failure occurs + public func setupMock(file: StaticString = #file, line: UInt = #line) { + self.file = file + self.line = line + } + + /// Clear mock internals. You can specify what to reset (invocations aka verify, givens or performs) or leave it empty to clear all mock internals + public func resetMock(_ scopes: MockScope...) { + let scopes: [MockScope] = scopes.isEmpty ? [.invocation, .given, .perform] : scopes + if scopes.contains(.invocation) { invocations = [] } + if scopes.contains(.given) { methodReturnValues = [] } + if scopes.contains(.perform) { methodPerformValues = [] } + } + <%_ -%> + <%# ================================================== STATIC MOCK INTERNALS -%><%_ -%> + <%_ if conformsToStaticMock { -%> + static var matcher: Matcher = Matcher.default + static var stubbingPolicy: StubbingPolicy = .wrap + static var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst + static private var queue = DispatchQueue(label: "com.swiftymocky.invocations.static", qos: .userInteractive) + static private var invocations: [StaticMethodType] = [] + static private var methodReturnValues: [StaticGiven] = [] + static private var methodPerformValues: [StaticPerform] = [] + public typealias StaticPropertyStub = StaticGiven + public typealias StaticMethodStub = StaticGiven + + /// Clear mock internals. You can specify what to reset (invocations aka verify, givens or performs) or leave it empty to clear all mock internals + public static func resetMock(_ scopes: MockScope...) { + let scopes: [MockScope] = scopes.isEmpty ? [.invocation, .given, .perform] : scopes + if scopes.contains(.invocation) { invocations = [] } + if scopes.contains(.given) { methodReturnValues = [] } + if scopes.contains(.perform) { methodPerformValues = [] } + } + <%_ } -%> + + <%# ================================================== VARIABLES -%><%_ -%> + <%_ for variable in allVariables { -%> + <%_ if autoMockable { -%> + <%= stubProperty(variable,"\(type.name)\(mockTypeName)") %> + <%_ } else { %> + <%= stubProperty(variable,"\(type.name)") %> + <%_ } %> + <%_ } %> <%_ -%> + + <%# ================================================== STATIC VARIABLES -%><%_ -%> + <%_ for variable in allStaticVariables { -%> + <%_ if autoMockable { -%> + <%= stubProperty(variable,"\(type.name)\(mockTypeName)") %> + <%_ } else { %> + <%= stubProperty(variable,"\(type.name)") %> + <%_ } %> + <%_ } %> <%_ -%> + + <%# ================================================== METHOD REGISTRATIONS -%><%_ -%> + <%_ MethodWrapper.clear() -%> + <%_ SubscriptWrapper.clear() -%> + <%_ if autoMockable { -%> + <%_ Current.selfType = "\(type.name)\(mockTypeName)\(genericTypesModifier)" -%> + <%_ } else { %> + <%_ Current.selfType = "\(type.name)\(mockTypeName)\(genericTypesModifier)" -%> + <%_ } %> + <%_ let wrappedSubscripts = allSubscripts.map(wrapSubscript) -%> + <%_ let wrappedMethods = allMethods.map(wrapMethod).filter({ $0.wrappedInMethodType() }) -%> + <%_ let wrappedVariables = allVariables.map(justWrap) -%> + <%_ let wrappedMethodsForMethodType = allMethodsForMethodType.map(wrapMethod).filter({ $0.wrappedInMethodType() }) -%> + <%_ let wrappedInitializers = allMethods.map(wrapMethod).filter({ $0.method.isInitializer }) -%> + <%_ let wrappedStaticMethods = allStaticMethods.map(wrapMethod).filter({ $0.wrappedInMethodType() }) -%> + <%_ let wrappedStaticVariables = allStaticVariables.map(justWrap) -%> + <%_ let wrappedStaticMethodsForMethodType = allStaticMethodsForMethodType.map(wrapMethod).filter({ $0.wrappedInMethodType() }) -%> + <%_ for variable in allVariables { propertyRegister(variable) } -%> + <%_ for variable in allStaticVariables { propertyRegister(variable) } -%> + <%_ for method in wrappedMethods { method.register() } -%> + <%_ for wrapped in wrappedSubscripts { wrapped.register() } -%> + <%_ for method in wrappedStaticMethods { method.register() } -%><%_ -%> + <%_ let variableCasesCount: Int = wrappedVariables.reduce(0) { return $0 + $1.casesCount } -%><%_ -%> + <%_ let subscriptsCasesCount: Int = wrappedSubscripts.reduce(0) { return $0 + $1.casesCount } -%><%_ -%> + <%_ let staticVariableCasesCount: Int = wrappedStaticVariables.reduce(0) { return $0 + $1.casesCount } -%><%_ -%> + + <%# ================================================== STATIC STUBS -%><%_ -%> + <%_ for method in wrappedStaticMethods { -%> + <%= method.functionPrototype _%> { + <%= method.stubBody() _%> + } + + <%_ } %><%_ -%> + <%_ -%> + <%# ================================================== INITIALIZERS -%><%_ -%> + <%_ for method in wrappedInitializers { -%> + <%= method.functionPrototype _%> { } + + <%_ } -%><%_ -%> + <%_ -%><%_ -%> + <%# ================================================== STUBS -%><%_ -%> + <%_ for method in wrappedMethods { -%> + <%= method.functionPrototype _%> { + <%= method.stubBody() _%> + } + + <%_ } -%> + <%_ for wrapped in wrappedSubscripts { -%> + <%= wrapped.subscriptCall() _%> + + <%_ } -%> + <%# ================================================== STATIC METHOD TYPE -%><%_ -%> + <%_ if conformsToStaticMock { -%> + fileprivate enum StaticMethodType { + <%_ for method in wrappedStaticMethodsForMethodType { -%> + <%= method.methodTypeDeclarationWithParameters() _%> + <%_ } %> <%_ for variable in allStaticVariables { -%> + <%= propertyMethodTypes(variable) %> + <%_ } %> <%_ %> + <%_ -%> + static func compareParameters(lhs: StaticMethodType, rhs: StaticMethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { <%_ for method in wrappedStaticMethodsForMethodType { %> + <%= method.equalCases() %> + <%_ } %> <%_ for variable in wrappedStaticVariables { -%> + <%= variable.compareCases() %> + <%_ } %> <%_ -%> <%_ if wrappedStaticMethods.count + staticVariableCasesCount > 1 { -%> + default: return .none + <%_ } -%> + } + } + <%_ %> + func intValue() -> Int { + switch self { <%_ for method in wrappedStaticMethodsForMethodType { %> + <%= method.intValueCase -%><% } %> + <%_ for variable in allStaticVariables { -%> + <%= propertyMethodTypesIntValue(variable) %> + <%_ } %> <%_ -%> + } + } + func assertionName() -> String { + switch self { <%_ for method in wrappedStaticMethodsForMethodType { %> + <%= method.assertionName -%><% } %> + <%_ for variable in wrappedStaticVariables { -%> + <%= variable.assertionName %> + <%_ } %> + } + } + } + + open class StaticGiven: StubbedMethod { + fileprivate var method: StaticMethodType + + private init(method: StaticMethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + <%_ for variable in allStaticVariables { -%> + <%= wrapProperty(variable).givenConstructorName(prefix: "Static") -%> { + <%= wrapProperty(variable).givenConstructor(prefix: "Static") _%> + } + <%_ } %> <%_ %> + <%_ for method in wrappedStaticMethodsForMethodType.filter({ !$0.method.returnTypeName.isVoid && !$0.method.isInitializer }) { -%> + <%= method.givenConstructorName(prefix: "Static") -%> { + <%= method.givenConstructor(prefix: "Static") _%> + } + <%_ } -%> + <%_ for method in wrappedStaticMethodsForMethodType.filter({ !$0.method.throws && !$0.method.rethrows && !$0.method.returnTypeName.isVoid && !$0.method.isInitializer }) { -%> + <%= method.givenProduceConstructorName(prefix: "Static") -%> { + <%= method.givenProduceConstructor(prefix: "Static") _%> + } + <%_ } -%> + <%_ for method in wrappedStaticMethodsForMethodType.filter({ ($0.method.throws || $0.method.rethrows) && !$0.method.isInitializer }) { -%> + <%= method.givenConstructorNameThrows(prefix: "Static") -%> { + <%= method.givenConstructorThrows(prefix: "Static") _%> + } + <%= method.givenProduceConstructorNameThrows(prefix: "Static") -%> { + <%= method.givenProduceConstructorThrows(prefix: "Static") _%> + } + <%_ } %> <%_ -%> + } + + public struct StaticVerify { + fileprivate var method: StaticMethodType + + <%_ for method in wrappedStaticMethodsForMethodType { -%> + <%= method.verificationProxyConstructorName(prefix: "Static") -%> { <%= method.verificationProxyConstructor(prefix: "Static") _%> } + <%_ } %> <%_ -%> + <%_ for variable in allStaticVariables { -%> + <%= propertyTypes(variable) %> + <%_ } %> <%_ -%> + } + + public struct StaticPerform { + fileprivate var method: StaticMethodType + var performs: Any + + <%_ for method in wrappedStaticMethodsForMethodType { -%> + <%= method.performProxyConstructorName(prefix: "Static") -%> { + <%= method.performProxyConstructor(prefix: "Static") _%> + } + <%_ } %> <%_ -%> + } + + <% } -%> + <%# ================================================== METHOD TYPE -%><%_ -%> + <%_ if !wrappedMethods.isEmpty || !allVariables.isEmpty || !allSubscripts.isEmpty { -%> + + fileprivate enum MethodType { + <%_ for method in wrappedMethodsForMethodType { -%> + <%= method.methodTypeDeclarationWithParameters() _%> + <%_ } -%> <%_ for variable in allVariables { -%> + <%= propertyMethodTypes(variable) %> + <%_ } %> <%_ %> <%_ for wrapped in wrappedSubscripts { -%> + <%= wrapped.subscriptCases() _%> + <%_ } %> <%_ %> + <%_ -%> + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { <%_ for method in wrappedMethodsForMethodType { %> + <%= method.equalCases() %> + <%_ } %> <%_ for variable in wrappedVariables { -%> + <%= variable.compareCases() %> + <%_ } %> <%_ -%> <%_ for wrapped in wrappedSubscripts { -%> + <%= wrapped.equalCases() %> + <%_ } %> <%_ if wrappedMethods.count + variableCasesCount + subscriptsCasesCount > 1 { -%> + default: return .none + <%_ } -%> + } + } + <%_ %> + func intValue() -> Int { + switch self { <%_ for method in wrappedMethodsForMethodType { %> + <%= method.intValueCase -%><% } %> + <%_ for variable in allVariables { -%> + <%= propertyMethodTypesIntValue(variable) %> + <%_ } %> <%_ for wrapped in wrappedSubscripts { -%> + <%= wrapped.intValueCase() %> + <%_ } -%> + } + } + func assertionName() -> String { + switch self { <%_ for method in wrappedMethodsForMethodType { %> + <%= method.assertionName -%><% } %> + <%_ for variable in wrappedVariables { -%> + <%= variable.assertionName %> + <%_ } %> <%_ for wrapped in wrappedSubscripts { -%> + <%= wrapped.assertionName %> + <%_ } -%> + } + } + } + <%_ } else { %> + fileprivate struct MethodType { + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { return .match } + func intValue() -> Int { return 0 } + func assertionName() -> String { return "" } + } + <%_ } -%><%_ -%> + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + <%_ for variable in allVariables { -%> + <%= wrapProperty(variable).givenConstructorName() -%> { + <%= wrapProperty(variable).givenConstructor() _%> + } + <%_ } %> <%_ %> + <%_ for method in wrappedMethodsForMethodType.filter({ !$0.method.returnTypeName.isVoid && !$0.method.isInitializer }) { -%> + <%= method.givenConstructorName() -%> { + <%= method.givenConstructor() _%> + } + <%_ } -%> + <%_ for method in wrappedMethodsForMethodType.filter({ !$0.method.throws && !$0.method.rethrows && !$0.method.returnTypeName.isVoid && !$0.method.isInitializer }) { -%> + <%= method.givenProduceConstructorName() -%> { + <%= method.givenProduceConstructor() _%> + } + <%_ } -%> + <%_ for wrapped in wrappedSubscripts { -%> + <%= wrapped.givenConstructorName() -%> { + <%= wrapped.givenConstructor() _%> + } + <%_ } -%> + <%_ for method in wrappedMethodsForMethodType.filter({ ($0.method.throws || $0.method.rethrows) && !$0.method.isInitializer }) { -%> + <%= method.givenConstructorNameThrows() -%> { + <%= method.givenConstructorThrows() _%> + } + <%= method.givenProduceConstructorNameThrows() -%> { + <%= method.givenProduceConstructorThrows() _%> + } + <%_ } %> <%_ -%> + } + + public struct Verify { + fileprivate var method: MethodType + + <%_ for method in wrappedMethodsForMethodType { -%> + <%= method.verificationProxyConstructorName() -%> { <%= method.verificationProxyConstructor() _%> } + <%_ } %> <%_ -%> + <%_ for variable in allVariables { -%> + <%= propertyTypes(variable) %> + <%_ } %> <%_ -%> + <%_ for wrapped in wrappedSubscripts { -%> + <%= wrapped.verifyConstructorName() -%> { <%= wrapped.verifyConstructor() _%> } + <%_ if !wrapped.readonly { -%> + <%= wrapped.verifyConstructorName(set: true) -%> { <%= wrapped.verifyConstructor(set: true) _%> } + <%_ } -%> + <%_ } %> <%_ -%> + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + <%_ for method in wrappedMethodsForMethodType { -%> + <%= method.performProxyConstructorName() -%> { + <%= method.performProxyConstructor() _%> + } + <%_ } %> <%_ -%> + } + + <%# ================================================== MOCK METHODS -%><%_ -%> + public func given(_ method: Given) { + methodReturnValues.append(method) + } + + public func perform(_ method: Perform) { + methodPerformValues.append(method) + methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } + } + + public func verify(_ method: Verify, count: Count = Count.moreOrEqual(to: 1), file: StaticString = #file, line: UInt = #line) { + let fullMatches = matchingCalls(method, file: file, line: line) + let success = count.matches(fullMatches) + let assertionName = method.method.assertionName() + let feedback: String = { + guard !success else { return "" } + return Utils.closestCallsMessage( + for: self.invocations.map { invocation in + matcher.set(file: file, line: line) + defer { matcher.clearFileAndLine() } + return MethodType.compareParameters(lhs: invocation, rhs: method.method, matcher: matcher) + }, + name: assertionName + ) + }() + MockyAssert(success, "Expected: \(count) invocations of `\(assertionName)`, but was: \(fullMatches).\(feedback)", file: file, line: line) + } + + private func addInvocation(_ call: MethodType) { + self.queue.sync { invocations.append(call) } + } + private func methodReturnValue(_ method: MethodType) throws -> StubProduct { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let candidates = sequencingPolicy.sorted(methodReturnValues, by: { $0.method.intValue() > $1.method.intValue() }) + let matched = candidates.first(where: { $0.isValid && MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch }) + guard let product = matched?.getProduct(policy: self.stubbingPolicy) else { throw MockError.notStubed } + return product + } + private func methodPerformValue(_ method: MethodType) -> Any? { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let matched = methodPerformValues.reversed().first { MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch } + return matched?.performs + } + private func matchingCalls(_ method: MethodType, file: StaticString?, line: UInt?) -> [MethodType] { + matcher.set(file: file ?? self.file, line: line ?? self.line) + defer { matcher.clearFileAndLine() } + return invocations.filter { MethodType.compareParameters(lhs: $0, rhs: method, matcher: matcher).isFullMatch } + } + private func matchingCalls(_ method: Verify, file: StaticString?, line: UInt?) -> Int { + return matchingCalls(method.method, file: file, line: line).count + } + private func givenGetterValue(_ method: MethodType, _ message: String) -> T { + do { + return try methodReturnValue(method).casted() + } catch { + onFatalFailure(message) + Failure(message) + } + } + private func optionalGivenGetterValue(_ method: MethodType, _ message: String) -> T? { + do { + return try methodReturnValue(method).casted() + } catch { + return nil + } + } + private func onFatalFailure(_ message: String) { + guard let file = self.file, let line = self.line else { return } // Let if fail if cannot handle gratefully + SwiftyMockyTestObserver.handleFatalError(message: message, file: file, line: line) + } + <%# ================================================== STATIC MOCK METHODS -%><%_ -%> + <%_ if conformsToStaticMock { -%> + + static public func given(_ method: StaticGiven) { + methodReturnValues.append(method) + } + + static public func perform(_ method: StaticPerform) { + methodPerformValues.append(method) + methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } + } + + static public func verify(_ method: StaticVerify, count: Count = Count.moreOrEqual(to: 1), file: StaticString = #file, line: UInt = #line) { + let fullMatches = matchingCalls(method, file: file, line: line) + let success = count.matches(fullMatches) + let assertionName = method.method.assertionName() + let feedback: String = { + guard !success else { return "" } + return Utils.closestCallsMessage( + for: self.invocations.map { invocation in + matcher.set(file: file, line: line) + defer { matcher.clearFileAndLine() } + return StaticMethodType.compareParameters(lhs: invocation, rhs: method.method, matcher: matcher) + }, + name: assertionName + ) + }() + MockyAssert(success, "Expected: \(count) invocations of `\(assertionName)`, but was: \(fullMatches).\(feedback)", file: file, line: line) + } + + static private func addInvocation(_ call: StaticMethodType) { + self.queue.sync { invocations.append(call) } + } + static private func methodReturnValue(_ method: StaticMethodType) throws -> StubProduct { + let candidates = sequencingPolicy.sorted(methodReturnValues, by: { $0.method.intValue() > $1.method.intValue() }) + let matched = candidates.first(where: { $0.isValid && StaticMethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch }) + guard let product = matched?.getProduct(policy: self.stubbingPolicy) else { throw MockError.notStubed } + return product + } + static private func methodPerformValue(_ method: StaticMethodType) -> Any? { + let matched = methodPerformValues.reversed().first { StaticMethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch } + return matched?.performs + } + static private func matchingCalls(_ method: StaticMethodType, file: StaticString?, line: UInt?) -> [StaticMethodType] { + matcher.set(file: file, line: line) + defer { matcher.clearFileAndLine() } + return invocations.filter { StaticMethodType.compareParameters(lhs: $0, rhs: method, matcher: matcher).isFullMatch } + } + static private func matchingCalls(_ method: StaticVerify, file: StaticString?, line: UInt?) -> Int { + return matchingCalls(method.method, file: file, line: line).count + } + static private func givenGetterValue(_ method: StaticMethodType, _ message: String) -> T { + do { + return try methodReturnValue(method).casted() + } catch { + Failure(message) + } + } + static private func optionalGivenGetterValue(_ method: StaticMethodType, _ message: String) -> T? { + do { + return try methodReturnValue(method).casted() + } catch { + return nil + } + } + <%_ } -%> +<%_ if autoMockable { -%> +} + +<%_ } else { -%> +// sourcery:end +<%_ } -%> +<% } -%> +<%_ if mockedCount == 0 { -%> +// SwiftyMocky: no AutoMockable found. +// Please define and inherit from AutoMockable, or annotate protocols to be mocked +<%_ } -%> diff --git a/Profile/Mockfile b/Profile/Mockfile index dd72a756d..408c90399 100644 --- a/Profile/Mockfile +++ b/Profile/Mockfile @@ -1,5 +1,5 @@ -sourceryCommand: null -sourceryTemplate: null +sourceryCommand: mint run krzysztofzablocki/Sourcery@2.1.2 sourcery +sourceryTemplate: ../MockTemplate.swifttemplate unit.tests.mock: sources: include: diff --git a/Profile/ProfileTests/ProfileMock.generated.swift b/Profile/ProfileTests/ProfileMock.generated.swift index 58466c37c..9c82a4dd6 100644 --- a/Profile/ProfileTests/ProfileMock.generated.swift +++ b/Profile/ProfileTests/ProfileMock.generated.swift @@ -1,4 +1,4 @@ -// Generated using Sourcery 1.8.0 — https://github.com/krzysztofzablocki/Sourcery +// Generated using Sourcery 2.1.2 — https://github.com/krzysztofzablocki/Sourcery // DO NOT EDIT diff --git a/WhatsNew/Mockfile b/WhatsNew/Mockfile index 0b15b3b93..3fee3de2b 100644 --- a/WhatsNew/Mockfile +++ b/WhatsNew/Mockfile @@ -1,5 +1,5 @@ -sourceryCommand: null -sourceryTemplate: null +sourceryCommand: mint run krzysztofzablocki/Sourcery@2.1.2 sourcery +sourceryTemplate: ../MockTemplate.swifttemplate unit.tests.mock: sources: include: diff --git a/WhatsNew/WhatsNewTests/WhatsNewMock.generated.swift b/WhatsNew/WhatsNewTests/WhatsNewMock.generated.swift index f61f1556d..999f7cc25 100644 --- a/WhatsNew/WhatsNewTests/WhatsNewMock.generated.swift +++ b/WhatsNew/WhatsNewTests/WhatsNewMock.generated.swift @@ -1,4 +1,4 @@ -// Generated using Sourcery 1.8.0 — https://github.com/krzysztofzablocki/Sourcery +// Generated using Sourcery 2.1.2 — https://github.com/krzysztofzablocki/Sourcery // DO NOT EDIT From 5bd9203f0cf928a5635910e8d68eda5c1605f9ce Mon Sep 17 00:00:00 2001 From: Muhammad Umer Date: Thu, 2 Nov 2023 12:54:47 +0500 Subject: [PATCH 008/158] feat: course dates (#137) * feat: course dates * add tests to course dates * cleanup * add analytics and translation * regenerate CourseMock for analytics * chore: add translations * chore: update .gitignore and remove files * fix: remove gitignored file * fix: update .gitignore * fix: another try on removing files --- .gitignore | 14 +- .../xcshareddata/IDEWorkspaceChecks.plist | 5 +- .../xcshareddata/swiftpm/Package.resolved | 14 + .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/swiftpm/Package.resolved | 14 + Core/Core/Extensions/DateExtension.swift | 5 + Core/Core/SwiftGen/Strings.swift | 14 + Core/Core/en.lproj/Localizable.strings | 7 + Core/Core/uk.lproj/Localizable.strings | 7 + Course/Course.xcodeproj/project.pbxproj | 28 ++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + Course/Course/Data/CourseRepository.swift | 393 +++++++++++++++ .../Course/Data/Model/Data_CourseDates.swift | 90 ++++ .../Model/Data_CourseDetailsResponse.swift | 2 +- .../Course/Data/Network/CourseEndpoint.swift | 9 +- .../CourseCoreModel.xcdatamodel/contents | 27 +- .../CoursePersistenceProtocol.swift | 4 +- Course/Course/Domain/CourseInteractor.swift | 5 + Course/Course/Domain/Model/CourseDates.swift | 211 ++++++++ .../Course/Domain/Model/CourseDetails.swift | 4 +- .../Container/CourseContainerView.swift | 12 + .../Container/CourseContainerViewModel.swift | 2 + .../Course/Presentation/CourseAnalytics.swift | 2 + .../Presentation/Dates/CourseDatesView.swift | 279 +++++++++++ .../Dates/CourseDatesViewModel.swift | 72 +++ .../Details/CourseDetailsView.swift | 2 +- Course/Course/SwiftGen/Strings.swift | 2 + Course/Course/en.lproj/Localizable.strings | 1 + Course/Course/uk.lproj/Localizable.strings | 1 + Course/CourseTests/CourseMock.generated.swift | 60 +++ .../Unit/CourseDateViewModelTests.swift | 469 ++++++++++++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../UserInterfaceState.xcuserstate | Bin 10773 -> 0 bytes OpenEdX/AnalyticsManager.swift | 9 + OpenEdX/DI/ScreenAssembly.swift | 9 + OpenEdX/Data/CoursePersistence.swift | 8 + 39 files changed, 1816 insertions(+), 10 deletions(-) rename OpenEdX.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings => Core/Core.xcodeproj.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist (72%) create mode 100644 Core/Core.xcodeproj.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 Core/Core.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 Core/Core.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 Core/Core.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 Course/Course.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 Course/Course.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 Course/Course/Data/Model/Data_CourseDates.swift create mode 100644 Course/Course/Domain/Model/CourseDates.swift create mode 100644 Course/Course/Presentation/Dates/CourseDatesView.swift create mode 100644 Course/Course/Presentation/Dates/CourseDatesViewModel.swift create mode 100644 Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift create mode 100644 Discovery/Discovery.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 Discovery/Discovery.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist delete mode 100644 OpenEdX.xcodeproj/project.xcworkspace/xcuserdata/vchekyrta.xcuserdatad/UserInterfaceState.xcuserstate diff --git a/.gitignore b/.gitignore index 36e2b2501..582c86cb2 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,8 @@ # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore ## User settings -xcuserdata/* +xcuserdata/ +*.xcuserdata/* /OpenEdX.xcodeproj/xcuserdata/ /OpenEdX.xcworkspace/xcuserdata/ /OpenEdX.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -25,6 +26,15 @@ DerivedData/ *.perspectivev3 !default.perspectivev3 +*.xcodeproj/* +**/xcuserdata/ +**/*.xcuserdata/* +!*.xcodeproj/project.pbxproj +!*.xcodeproj/xcshareddata/ +!*.xcodeproj/project.xcworkspace/ +!*.xcworkspace/contents.xcworkspacedata +**/xcshareddata/WorkspaceSettings.xcsettings + ## Obj-C/Swift specific *.hmap @@ -100,4 +110,4 @@ iOSInjectionProject/ xcode-frameworks vendor/ -.bundle/ \ No newline at end of file +.bundle/ diff --git a/OpenEdX.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/Core/Core.xcodeproj.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist similarity index 72% rename from OpenEdX.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings rename to Core/Core.xcodeproj.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist index 0c67376eb..18d981003 100644 --- a/OpenEdX.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +++ b/Core/Core.xcodeproj.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -1,5 +1,8 @@ - + + IDEDidComputeMac32BitWarning + + diff --git a/Core/Core.xcodeproj.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Core/Core.xcodeproj.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 000000000..7dde19a07 --- /dev/null +++ b/Core/Core.xcodeproj.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "youtubeplayerkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SvenTiigi/YouTubePlayerKit", + "state" : { + "revision" : "1fe4c8b07a61d50c2fd276e1d9c8087583c7638a", + "version" : "1.5.3" + } + } + ], + "version" : 2 +} diff --git a/Core/Core.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Core/Core.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..919434a62 --- /dev/null +++ b/Core/Core.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Core/Core.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Core/Core.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000..18d981003 --- /dev/null +++ b/Core/Core.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Core/Core.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Core/Core.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 000000000..7dde19a07 --- /dev/null +++ b/Core/Core.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "youtubeplayerkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SvenTiigi/YouTubePlayerKit", + "state" : { + "revision" : "1fe4c8b07a61d50c2fd276e1d9c8087583c7638a", + "version" : "1.5.3" + } + } + ], + "version" : 2 +} diff --git a/Core/Core/Extensions/DateExtension.swift b/Core/Core/Extensions/DateExtension.swift index 059b54cc2..362a3dc33 100644 --- a/Core/Core/Extensions/DateExtension.swift +++ b/Core/Core/Extensions/DateExtension.swift @@ -69,6 +69,7 @@ public enum DateStringStyle { case monthYear case lastPost case iso8601 + case shortWeekdayMonthDayYear } public extension Date { @@ -102,6 +103,8 @@ public extension Date { dateFormatter.dateFormat = CoreLocalization.DateFormat.mmmDdYyyy case .iso8601: dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ" + case .shortWeekdayMonthDayYear: + dateFormatter.dateFormat = "EEE, MMM d, yyyy" } let date = dateFormatter.string(from: self) @@ -130,6 +133,8 @@ public extension Date { } case .iso8601: return date + case .shortWeekdayMonthDayYear: + return date } } } diff --git a/Core/Core/SwiftGen/Strings.swift b/Core/Core/SwiftGen/Strings.swift index 0197b0494..41a0fcb7d 100644 --- a/Core/Core/SwiftGen/Strings.swift +++ b/Core/Core/SwiftGen/Strings.swift @@ -54,6 +54,20 @@ public enum CoreLocalization { /// Section “ public static let section = CoreLocalization.tr("Localizable", "COURSEWARE.SECTION", fallback: "Section “") } + public enum CourseDates { + /// Completed + public static let completed = CoreLocalization.tr("Localizable", "COURSE_DATES.COMPLETED", fallback: "Completed") + /// Due next + public static let dueNext = CoreLocalization.tr("Localizable", "COURSE_DATES.DUE_NEXT", fallback: "Due next") + /// Past due + public static let pastDue = CoreLocalization.tr("Localizable", "COURSE_DATES.PAST_DUE", fallback: "Past due") + /// Today + public static let today = CoreLocalization.tr("Localizable", "COURSE_DATES.TODAY", fallback: "Today") + /// Unreleased + public static let unreleased = CoreLocalization.tr("Localizable", "COURSE_DATES.UNRELEASED", fallback: "Unreleased") + /// Verified Only + public static let verifiedOnly = CoreLocalization.tr("Localizable", "COURSE_DATES.VERIFIED_ONLY", fallback: "Verified Only") + } public enum Date { /// Ended public static let ended = CoreLocalization.tr("Localizable", "DATE.ENDED", fallback: "Ended") diff --git a/Core/Core/en.lproj/Localizable.strings b/Core/Core/en.lproj/Localizable.strings index f16f781dc..6c055ab25 100644 --- a/Core/Core/en.lproj/Localizable.strings +++ b/Core/Core/en.lproj/Localizable.strings @@ -68,3 +68,10 @@ "WEBVIEW.ALERT.OK" = "Ok"; "WEBVIEW.ALERT.CANCEL" = "Cancel"; + +"COURSE_DATES.TODAY" = "Today"; +"COURSE_DATES.COMPLETED" = "Completed"; +"COURSE_DATES.PAST_DUE" = "Past due"; +"COURSE_DATES.DUE_NEXT" = "Due next"; +"COURSE_DATES.UNRELEASED" = "Unreleased"; +"COURSE_DATES.VERIFIED_ONLY" = "Verified Only"; diff --git a/Core/Core/uk.lproj/Localizable.strings b/Core/Core/uk.lproj/Localizable.strings index e06937311..40ff490a3 100644 --- a/Core/Core/uk.lproj/Localizable.strings +++ b/Core/Core/uk.lproj/Localizable.strings @@ -68,3 +68,10 @@ "WEBVIEW.ALERT.OK" = "Так"; "WEBVIEW.ALERT.CANCEL" = "Скасувати"; + +"COURSE_DATES.TODAY" = "Today"; +"COURSE_DATES.COMPLETED" = "Completed"; +"COURSE_DATES.PAST_DUE" = "Past due"; +"COURSE_DATES.DUE_NEXT" = "Due next"; +"COURSE_DATES.UNRELEASED" = "Unreleased"; +"COURSE_DATES.VERIFIED_ONLY" = "Verified Only"; diff --git a/Course/Course.xcodeproj/project.pbxproj b/Course/Course.xcodeproj/project.pbxproj index c80d63032..16f57b0fb 100644 --- a/Course/Course.xcodeproj/project.pbxproj +++ b/Course/Course.xcodeproj/project.pbxproj @@ -64,6 +64,11 @@ 0766DFD0299AB29000EBEF6A /* PlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0766DFCF299AB29000EBEF6A /* PlayerViewController.swift */; }; 197FB8EA8F92F00A8F383D82 /* Pods_App_Course.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E5E795BD160CDA7D9C120DE6 /* Pods_App_Course.framework */; }; B8F50317B6B830A0E520C954 /* Pods_App_Course_CourseTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 50E59D2B81E12610964282C5 /* Pods_App_Course_CourseTests.framework */; }; + 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 */ @@ -150,6 +155,11 @@ 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 = ""; }; ADC2A1B8183A674705F5F7E2 /* Pods-App-Course.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Course.debug.xcconfig"; path = "Target Support Files/Pods-App-Course/Pods-App-Course.debug.xcconfig"; sourceTree = ""; }; B196A14555D0E006995A5683 /* Pods-App-CourseDetails.releaseprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-CourseDetails.releaseprod.xcconfig"; path = "Target Support Files/Pods-App-CourseDetails/Pods-App-CourseDetails.releaseprod.xcconfig"; sourceTree = ""; }; + 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 = ""; }; @@ -291,6 +301,7 @@ 027020FB28E7362100F54332 /* Data_CourseOutlineResponse.swift */, 022C64DB29ACFDEE000F532B /* Data_HandoutsResponse.swift */, 022C64DF29ADEA9B000F532B /* Data_UpdatesResponse.swift */, + DB7D6EB12ADFE9510036BB13 /* Data_CourseDates.swift */, ); path = Model; sourceTree = ""; @@ -319,6 +330,7 @@ 02EAE2CA28E1F0A700529644 /* Presentation */ = { isa = PBXGroup; children = ( + DB7D6EAA2ADFCAA00036BB13 /* Dates */, 070019A828F6F33600D5FC78 /* Container */, 070019A628F6F2CB00D5FC78 /* Details */, 070019A728F6F2D600D5FC78 /* Outline */, @@ -337,6 +349,7 @@ 02B6B3C228E1DCD100232911 /* CourseDetails.swift */, 0276D75C29DDA3F80004CDF8 /* ResumeBlock.swift */, 022C64E129ADEB83000F532B /* CourseUpdate.swift */, + DB7D6EAF2ADFDA0E0036BB13 /* CourseDates.swift */, ); path = Model; sourceTree = ""; @@ -428,6 +441,7 @@ children = ( 0262148E29AE17C4008BD75A /* HandoutsViewModelTests.swift */, 0295B1D8297E6DF8003B0C65 /* CourseUnitViewModelTests.swift */, + DB205BFA2AE81B1200136EC2 /* CourseDateViewModelTests.swift */, ); path = Unit; sourceTree = ""; @@ -462,6 +476,15 @@ path = ../Pods; sourceTree = ""; }; + DB7D6EAA2ADFCAA00036BB13 /* Dates */ = { + isa = PBXGroup; + children = ( + DB7D6EAB2ADFCAC40036BB13 /* CourseDatesView.swift */, + DB7D6EAD2ADFCB4A0036BB13 /* CourseDatesViewModel.swift */, + ); + path = Dates; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -664,6 +687,7 @@ 02F78AEB29E6BCA20038DE30 /* VideoPlayerViewModelTests.swift in Sources */, 023812E7297AC8EB0087098F /* CourseDetailsViewModelTests.swift in Sources */, 023812F3297AC9ED0087098F /* CourseMock.generated.swift in Sources */, + DB205BFB2AE81B1200136EC2 /* CourseDateViewModelTests.swift in Sources */, 022EA8CB297AD63B0014A8F7 /* CourseContainerViewModelTests.swift in Sources */, 0262148F29AE17C4008BD75A /* HandoutsViewModelTests.swift in Sources */, ); @@ -685,6 +709,7 @@ 02B6B3BC28E1D14F00232911 /* CourseRepository.swift in Sources */, 02280F60294B50030032823A /* CoursePersistenceProtocol.swift in Sources */, 02454CAA2A2619B40043052A /* LessonProgressView.swift in Sources */, + DB7D6EB22ADFE9510036BB13 /* Data_CourseDates.swift in Sources */, 02280F5E294B4FDA0032823A /* CourseCoreModel.xcdatamodeld in Sources */, 0766DFCE299AB26D00EBEF6A /* EncodedVideoPlayer.swift in Sources */, 0276D75B29DDA3890004CDF8 /* Data_ResumeBlock.swift in Sources */, @@ -700,8 +725,10 @@ 02A8076829474831007F53AB /* CourseVerticalView.swift in Sources */, 0231124D28EDA804002588FB /* CourseUnitView.swift in Sources */, 027020FC28E7362100F54332 /* Data_CourseOutlineResponse.swift in Sources */, + DB7D6EB02ADFDA0E0036BB13 /* CourseDates.swift in Sources */, 02E685C028E4B629000AE015 /* CourseDetailsViewModel.swift in Sources */, 0295C889299BBE8200ABE571 /* CourseNavigationView.swift in Sources */, + DB7D6EAE2ADFCB4A0036BB13 /* CourseDatesViewModel.swift in Sources */, 02F066E829DC71750073E13B /* SubtittlesView.swift in Sources */, 022C64E229ADEB83000F532B /* CourseUpdate.swift in Sources */, 02454CA62A26196C0043052A /* UnknownView.swift in Sources */, @@ -711,6 +738,7 @@ 02454CA82A2619890043052A /* DiscussionView.swift in Sources */, 0265B4B728E2141D00E6EAFD /* Strings.swift in Sources */, 02B6B3C128E1DBA100232911 /* Data_CourseDetailsResponse.swift in Sources */, + DB7D6EAC2ADFCAC50036BB13 /* CourseDatesView.swift in Sources */, 0766DFCC299AA7A600EBEF6A /* YouTubeVideoPlayer.swift in Sources */, 022F8E162A1DFBC6008EFAB9 /* YouTubeVideoPlayerViewModel.swift in Sources */, 02E685BE28E4B60A000AE015 /* CourseDetailsView.swift in Sources */, diff --git a/Course/Course.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Course/Course.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..919434a62 --- /dev/null +++ b/Course/Course.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Course/Course.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Course/Course.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000..18d981003 --- /dev/null +++ b/Course/Course.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Course/Course/Data/CourseRepository.swift b/Course/Course/Data/CourseRepository.swift index 68e833255..80ec3c3ef 100644 --- a/Course/Course/Data/CourseRepository.swift +++ b/Course/Course/Data/CourseRepository.swift @@ -19,6 +19,8 @@ public protocol CourseRepositoryProtocol { func getUpdates(courseID: String) async throws -> [CourseUpdate] func resumeBlock(courseID: String) async throws -> ResumeBlock func getSubtitles(url: String, selectedLanguage: String) async throws -> String + func getCourseDates(courseID: String) async throws -> CourseDates + func getCourseDatesOffline(courseID: String) async throws -> CourseDates } public class CourseRepository: CourseRepositoryProtocol { @@ -112,6 +114,18 @@ public class CourseRepository: CourseRepositoryProtocol { } } + public func getCourseDates(courseID: String) async throws -> CourseDates { + let courseDates = try await api.requestData( + CourseEndpoint.getCourseDates(courseID: courseID) + ).mapResponse(DataLayer.CourseDates.self).domain + persistence.saveCourseDates(courseID: courseID, courseDates: courseDates) + return courseDates + } + + public func getCourseDatesOffline(courseID: String) async throws -> CourseDates { + return try persistence.loadCourseDates(courseID: courseID) + } + private func parseCourseStructure(course: DataLayer.CourseStructure) -> CourseStructure { let blocks = Array(course.dict.values) let courseBlock = blocks.first(where: {$0.type == BlockType.course.rawValue })! @@ -220,6 +234,10 @@ public class CourseRepository: CourseRepositoryProtocol { // swiftlint:disable all #if DEBUG class CourseRepositoryMock: CourseRepositoryProtocol { + func getCourseDatesOffline(courseID: String) async throws -> CourseDates { + throw NoCachedDataError() + } + func resumeBlock(courseID: String) async throws -> ResumeBlock { ResumeBlock(blockID: "123") } @@ -232,6 +250,14 @@ class CourseRepositoryMock: CourseRepositoryProtocol { return [CourseUpdate(id: 1, date: "Date", content: "content", status: "status")] } + func getCourseDates(courseID: String) async throws -> CourseDates { + do { + let courseDates = try courseDatesJSON.data(using: .utf8)!.mapResponse(DataLayer.CourseDates.self) + return courseDates.domain + } catch { + throw error + } + } func getCourseDetailsOffline(courseID: String) async throws -> CourseDetails { return CourseDetails( @@ -1034,6 +1060,373 @@ And there are various ways of describing it-- call it oral poetry or "is_self_paced": false } """ + + private let courseDatesJSON: String = """ + { + "dates_banner_info": { + "missed_deadlines": false, + "content_type_gating_enabled": true, + "missed_gated_content": false, + "verified_upgrade_link": null + }, + "course_date_blocks": [ + { + "assignment_type": null, + "complete": null, + "date": "2023-08-30T15:00:00Z", + "date_type": "course-start-date", + "description": "", + "learner_has_access": true, + "link": "", + "link_text": "", + "title": "Course starts", + "extra_info": null, + "first_component_block_id": "" + }, + { + "assignment_type": "Problem Set", + "complete": false, + "date": "2023-09-14T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@ca19e125470846f2a36ad1225410e39a", + "link_text": "", + "title": "Problem Set 1", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@problem+block@bd89c1dd129240f99bb8c5cbe3f56530" + }, + { + "assignment_type": "Problem Set", + "complete": false, + "date": "2023-09-14T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@ca19e125470846f2a36ad1225410e39aa", + "link_text": "", + "title": "Problem Set 1.1", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@problem+block@bd89c1dd129240f99bb8c5cbe3f56530a" + }, + { + "assignment_type": "Problem Set", + "complete": false, + "date": "2023-09-21T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@e137765987514da7851a59dedeb5ecec", + "link_text": "", + "title": "Problem Set 2", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@html+block@c99e81ffff4546e28fecab0a0c381abd" + }, + { + "assignment_type": "Problem Set", + "complete": true, + "date": "2023-09-21T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@e137765987514da7851a59dedeb5ececc", + "link_text": "", + "title": "Problem Set 2.1", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@html+block@c99e81ffff4546e28fecab0a0c381abdc" + }, + { + "assignment_type": "Problem Set", + "complete": false, + "date": "2023-09-21T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": false, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@e137765987514da7851a59dedeb5ececcc", + "link_text": "", + "title": "Problem Set 2.2", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@html+block@c99e81ffff4546e28fecab0a0c381abdcc" + }, + { + "assignment_type": "Problem Set", + "complete": false, + "date": "2023-09-28T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@bfe9eb02884a4812883ff9e543887968", + "link_text": "", + "title": "Problem Set 3", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@html+block@5e117d71433647eaa6de63434641c011" + }, + { + "assignment_type": "Midterm", + "complete": false, + "date": "2023-10-04T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": false, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@bb284b9c4ff04091951f77b50e3b72f4", + "link_text": "", + "title": "Midterm Exam (time limit removed due to grader issues)", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@vertical+block@ec1c5d83de6244d38b1f3ff4d32b6e17" + }, + { + "assignment_type": "Problem Set", + "complete": false, + "date": "2023-10-12T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@64f4d344ecdc48d2bef514882e6236ab", + "link_text": "", + "title": "Problem Set 4", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@html+block@eeb64a67e52e4f3e80656b9233204f25" + }, + { + "assignment_type": "Problem Set", + "complete": false, + "date": "2023-10-19T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@79d22d4ab4f740158930fca4e80d67db", + "link_text": "", + "title": "Problem Set 5", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@html+block@3dde572871fc4b6ebdb47722a184a514" + }, + { + "assignment_type": "Problem Set", + "complete": false, + "date": "2023-10-26T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@3d419098708e4bcd9209ffa31a4cb3dc", + "link_text": "", + "title": "Problem Set 6", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@problem+block@9b2a0176bf6a4c21ad4a63c2fce2d0cb" + }, + { + "assignment_type": "Final Exam", + "complete": false, + "date": "2023-10-31T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": false, + "link": "", + "link_text": "", + "title": "Final Exam (time limit removed due to grader issues)", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@vertical+block@e7b4f091d7ad457097d0bbda9d9af267" + }, + { + "assignment_type": "Finger Exercises", + "complete": false, + "date": "2023-11-01T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@221a4c17dba341d6a970a0d80343253c", + "link_text": "", + "title": "1. Introduction to Python (TIME: 1:03:12)", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@ad9387910b7e47069c452efebd7b36dd" + }, + { + "assignment_type": "Finger Exercises", + "complete": false, + "date": "2023-11-01T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@35f82f6c3ecb4e9e913dc279a9b73a9f", + "link_text": "", + "title": "2. Core Elements of Programs (TIME: 54:14)", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@8fb4fa767a204d41a6366c2bc53bea22" + }, + { + "assignment_type": "Finger Exercises", + "complete": false, + "date": "2023-11-01T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@62f08cc899344863a1ab678aee506dec", + "link_text": "", + "title": "3. Simple Algorithms (TIME: 41:06)", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@1f2b055948c9467492649b59e24e8fdc" + }, + { + "assignment_type": "Finger Exercises", + "complete": false, + "date": "2023-11-01T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@38007cdb67c44b46b124cdbce33510b5", + "link_text": "", + "title": "4. Functions (TIME: 1:08:06)", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@9dc4c11c46274b87964c7534b449d50a" + }, + { + "assignment_type": "Finger Exercises", + "complete": false, + "date": "2023-11-01T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@01df98c1e74a459b8fb20d2d785622cd", + "link_text": "", + "title": "5. Tuples and Lists", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@3464df78190b43948ba0507ef4287290" + }, + { + "assignment_type": "Finger Exercises", + "complete": false, + "date": "2023-11-01T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@8a590293a22e46dd9760ec917d122ec1", + "link_text": "", + "title": "6. Dictionaries", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@d2abc5b3db0d43ba90c5d3a25e95e2d5" + }, + { + "assignment_type": "Finger Exercises", + "complete": false, + "date": "2023-11-01T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@78648402e8bf4738ade97101cc1ba263", + "link_text": "", + "title": "7. Testing and Debugging", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@dd0621fbfe594e789b187a1e4f8406eb" + }, + { + "assignment_type": "Finger Exercises", + "complete": false, + "date": "2023-11-01T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@c81c3de20ec54c37a04a8b3d1806e82c", + "link_text": "", + "title": "8. Exceptions and Assertions", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@6038a1b2f8a340eb8cdb41c021d62234" + }, + { + "assignment_type": "Finger Exercises", + "complete": false, + "date": "2023-11-01T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@37cb9a5012e443bbaa776a80afd9c87a", + "link_text": "", + "title": "9. Classes and Inheritance", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@b87e596b827142f09e9664fac3ab0be0" + }, + { + "assignment_type": "Finger Exercises", + "complete": false, + "date": "2023-11-01T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@54cd6b1bbbbe40f294ac0b5664c03f1e", + "link_text": "", + "title": "10. An Extended Example", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@6bc79b1a29ac46a7857caa53a8e203d0" + }, + { + "assignment_type": "Finger Exercises", + "complete": false, + "date": "2023-11-01T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@1334ab336b1b4458b5c2972c50e903b2", + "link_text": "", + "title": "11. Computational Complexity", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@be73e5a3ee7847d98805a257189b9fad" + }, + { + "assignment_type": "Finger Exercises", + "complete": false, + "date": "2023-11-01T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@a7387dbd3728491c8f834e29a73e0cf4", + "link_text": "", + "title": "12. Searching and Sorting Algorithms", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@fa7e29b3b95b4a3b963d1c5dfdd4e8f8" + }, + { + "assignment_type": null, + "complete": null, + "date": "2023-11-01T23:30:00Z", + "date_type": "course-end-date", + "description": "After the course ends, the course content will be archived and no longer active.", + "learner_has_access": true, + "link": "", + "link_text": "", + "title": "Course ends", + "extra_info": null, + "first_component_block_id": "" + }, + { + "assignment_type": null, + "complete": null, + "date": "2023-11-03T00:00:00Z", + "date_type": "certificate-available-date", + "description": "Day certificates will become available for passing verified learners.", + "learner_has_access": true, + "link": "", + "link_text": "", + "title": "Certificate Available", + "extra_info": null, + "first_component_block_id": "" + }, + { + "assignment_type": null, + "complete": null, + "date": "2023-11-23T12:34:28Z", + "date_type": "course-expired-date", + "description": "You lose all access to this course, including your progress.", + "learner_has_access": true, + "link": "", + "link_text": "", + "title": "Audit Access Expires", + "extra_info": null, + "first_component_block_id": "" + } + ], + "has_ended": false, + "learner_is_full_access": false, + "user_timezone": null + } + """ } #endif // swiftlint:enable all diff --git a/Course/Course/Data/Model/Data_CourseDates.swift b/Course/Course/Data/Model/Data_CourseDates.swift new file mode 100644 index 000000000..b78691020 --- /dev/null +++ b/Course/Course/Data/Model/Data_CourseDates.swift @@ -0,0 +1,90 @@ +// +// Data_CourseDates.swift +// Course +// +// Created by Muhammad Umer on 10/18/23. +// + +import Foundation +import Core + +public extension DataLayer { + struct CourseDates: Codable { + let datesBannerInfo: DatesBannerInfo? + let courseDateBlocks: [CourseDateBlock] + let hasEnded, learnerIsFullAccess: Bool + let userTimezone: String? + + enum CodingKeys: String, CodingKey { + case datesBannerInfo = "dates_banner_info" + case courseDateBlocks = "course_date_blocks" + case hasEnded = "has_ended" + case learnerIsFullAccess = "learner_is_full_access" + case userTimezone = "user_timezone" + } + } + + struct CourseDateBlock: Codable { + let assignmentType: String? + let complete: Bool? + let date, dateType, description: String + let learnerHasAccess: Bool + let link, title: String + let linkText: String? + let extraInfo: String? + let firstComponentBlockID: String + + enum CodingKeys: String, CodingKey { + case assignmentType = "assignment_type" + case complete, date + case dateType = "date_type" + case description + case learnerHasAccess = "learner_has_access" + case link + case linkText = "link_text" + case title + case extraInfo = "extra_info" + case firstComponentBlockID = "first_component_block_id" + } + } + + struct DatesBannerInfo: Codable { + let missedDeadlines, contentTypeGatingEnabled, missedGatedContent: Bool + let verifiedUpgradeLink: String? + + enum CodingKeys: String, CodingKey { + case missedDeadlines = "missed_deadlines" + case contentTypeGatingEnabled = "content_type_gating_enabled" + case missedGatedContent = "missed_gated_content" + case verifiedUpgradeLink = "verified_upgrade_link" + } + } +} + +public extension DataLayer.CourseDates { + var domain: CourseDates { + return CourseDates( + datesBannerInfo: DatesBannerInfo( + missedDeadlines: datesBannerInfo?.missedDeadlines ?? false, + contentTypeGatingEnabled: datesBannerInfo?.contentTypeGatingEnabled ?? false, + missedGatedContent: datesBannerInfo?.missedGatedContent ?? false, + verifiedUpgradeLink: datesBannerInfo?.verifiedUpgradeLink), + courseDateBlocks: courseDateBlocks.map { block in + CourseDateBlock( + assignmentType: block.assignmentType, + complete: block.complete, + date: Date(iso8601: block.date), + dateType: block.dateType, + description: block.description, + learnerHasAccess: block.learnerHasAccess, + link: block.link, + linkText: block.linkText ?? nil, + title: block.title, + extraInfo: block.extraInfo, + firstComponentBlockID: block.firstComponentBlockID) + }, + hasEnded: hasEnded, + learnerIsFullAccess: learnerIsFullAccess, + userTimezone: userTimezone) + } +} diff --git a/Course/Course/Data/Model/Data_CourseDetailsResponse.swift b/Course/Course/Data/Model/Data_CourseDetailsResponse.swift index 306ca6f96..1047727e8 100644 --- a/Course/Course/Data/Model/Data_CourseDetailsResponse.swift +++ b/Course/Course/Data/Model/Data_CourseDetailsResponse.swift @@ -22,7 +22,7 @@ public extension DataLayer { public let name: String public let number: String public let org: String - public let shortDescription: String + public let shortDescription: String? public let start: String? public let startDisplay: String? public let startType: String? diff --git a/Course/Course/Data/Network/CourseEndpoint.swift b/Course/Course/Data/Network/CourseEndpoint.swift index 7b3109a9c..63ef3b4e1 100644 --- a/Course/Course/Data/Network/CourseEndpoint.swift +++ b/Course/Course/Data/Network/CourseEndpoint.swift @@ -19,6 +19,7 @@ enum CourseEndpoint: EndPointType { case getUpdates(courseID: String) case resumeBlock(userName: String, courseID: String) case getSubtitles(url: String, selectedLanguage: String) + case getCourseDates(courseID: String) var path: String { switch self { @@ -40,6 +41,8 @@ enum CourseEndpoint: EndPointType { return "/api/mobile/v1/users/\(userName)/course_status_info/\(courseID)" case let .getSubtitles(url, _): return url + case .getCourseDates(courseID: let courseID): + return "/api/course_home/v1/dates/\(courseID)" } } @@ -63,6 +66,8 @@ enum CourseEndpoint: EndPointType { return .get case .getSubtitles: return .get + case .getCourseDates: + return .get } } @@ -112,11 +117,13 @@ enum CourseEndpoint: EndPointType { case .resumeBlock: return .requestParameters(encoding: JSONEncoding.default) case let .getSubtitles(_, subtitleLanguage): -// let languageCode = Locale.current.languageCode ?? "en" + // let languageCode = Locale.current.languageCode ?? "en" let params: [String: Any] = [ "lang": subtitleLanguage ] return .requestParameters(parameters: params, encoding: URLEncoding.queryString) + case .getCourseDates: + return .requestParameters(encoding: JSONEncoding.default) } } } diff --git a/Course/Course/Data/Persistence/CourseCoreModel.xcdatamodeld/CourseCoreModel.xcdatamodel/contents b/Course/Course/Data/Persistence/CourseCoreModel.xcdatamodeld/CourseCoreModel.xcdatamodel/contents index f83d58906..33202dee9 100644 --- a/Course/Course/Data/Persistence/CourseCoreModel.xcdatamodeld/CourseCoreModel.xcdatamodel/contents +++ b/Course/Course/Data/Persistence/CourseCoreModel.xcdatamodeld/CourseCoreModel.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -19,6 +19,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Course/Course/Data/Persistence/CoursePersistenceProtocol.swift b/Course/Course/Data/Persistence/CoursePersistenceProtocol.swift index b17874645..9efb9d435 100644 --- a/Course/Course/Data/Persistence/CoursePersistenceProtocol.swift +++ b/Course/Course/Data/Persistence/CoursePersistenceProtocol.swift @@ -17,9 +17,11 @@ public protocol CoursePersistenceProtocol { func saveCourseStructure(structure: DataLayer.CourseStructure) func saveSubtitles(url: String, subtitlesString: String) func loadSubtitles(url: String) -> String? + func saveCourseDates(courseID: String, courseDates: CourseDates) + func loadCourseDates(courseID: String) throws -> CourseDates } public final class CourseBundle { private init() {} } - \ No newline at end of file + diff --git a/Course/Course/Domain/CourseInteractor.swift b/Course/Course/Domain/CourseInteractor.swift index df58f05a9..872b22bde 100644 --- a/Course/Course/Domain/CourseInteractor.swift +++ b/Course/Course/Domain/CourseInteractor.swift @@ -21,6 +21,7 @@ public protocol CourseInteractorProtocol { func getUpdates(courseID: String) async throws -> [CourseUpdate] func resumeBlock(courseID: String) async throws -> ResumeBlock func getSubtitles(url: String, selectedLanguage: String) async throws -> [Subtitle] + func getCourseDates(courseID: String) async throws -> CourseDates } public class CourseInteractor: CourseInteractorProtocol { @@ -94,6 +95,10 @@ public class CourseInteractor: CourseInteractorProtocol { return parseSubtitles(from: result) } + public func getCourseDates(courseID: String) async throws -> CourseDates { + return try await repository.getCourseDates(courseID: courseID) + } + private func filterChapter(chapter: CourseChapter) -> CourseChapter { var newChilds = [CourseSequential]() for sequential in chapter.childs { diff --git a/Course/Course/Domain/Model/CourseDates.swift b/Course/Course/Domain/Model/CourseDates.swift new file mode 100644 index 000000000..e60265941 --- /dev/null +++ b/Course/Course/Domain/Model/CourseDates.swift @@ -0,0 +1,211 @@ +// +// CourseDates.swift +// Course +// +// Created by Muhammad Umer on 10/18/23. +// + +import Foundation +import Core + +public struct CourseDates { + let datesBannerInfo: DatesBannerInfo + let courseDateBlocks: [CourseDateBlock] + let hasEnded, learnerIsFullAccess: Bool + let userTimezone: String? + + var sortedDateToCourseDateBlockDict: [Date: [CourseDateBlock]] { + var dateToCourseDateBlockDict: [Date: [CourseDateBlock]] = [:] + var hasToday = false + let today = Date.today + + for block in courseDateBlocks { + let date = block.date + if date == today { + hasToday = true + } + + dateToCourseDateBlockDict[date, default: []].append(block) + } + + if !hasToday { + let todayBlock = CourseDateBlock( + assignmentType: nil, + complete: nil, + date: today, + dateType: "", + description: "", + learnerHasAccess: true, + link: "", linkText: nil, + title: CoreLocalization.CourseDates.today, + extraInfo: nil, + firstComponentBlockID: "uniqueIDForToday") + dateToCourseDateBlockDict[today] = [todayBlock] + } + + return dateToCourseDateBlockDict + } +} + +extension Date { + static var today: Date { + return Calendar.current.startOfDay(for: Date()) + } + + static func compare(_ fromDate: Date, to toDate: Date) -> ComparisonResult { + if fromDate > toDate { + return .orderedDescending + } else if fromDate < toDate { + return .orderedAscending + } + return .orderedSame + } + + var isInPast: Bool { + return Date.compare(self, to: .today) == .orderedAscending + } + + var isToday: Bool { + return Date.compare(self, to: .today) == .orderedSame + } + + var isInFuture: Bool { + return Date.compare(self, to: .today) == .orderedDescending + } +} + +public struct CourseDateBlock { + 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 blockTitle: String { + if isToday { + return CoreLocalization.CourseDates.today + } else { + return blockStatus.title + } + } + + var isInPast: Bool { + return date.isInPast + } + + var isToday: Bool { + if dateType.isEmpty { + return true + } else { + return date.isToday + } + } + + var isInFuture: Bool { + return date.isInFuture + } + + var isAssignment: Bool { + return BlockStatus.status(of: dateType) == .assignment + } + + var isVerifiedOnly: Bool { + return !learnerHasAccess + } + + var isComplete: Bool { + return complete ?? false + } + + var isLearnerAssignment: Bool { + return learnerHasAccess && isAssignment + } + + var isPastDue: Bool { + return !isComplete && (date < .today) + } + + var isUnreleased: Bool { + return link.isEmpty + } + + var canShowLink: Bool { + return !isUnreleased && isLearnerAssignment + } + + var blockStatus: BlockStatus { + if isComplete { + return .completed + } + + if !learnerHasAccess { + return .verifiedOnly + } + + if isAssignment { + if isInPast { + return isUnreleased ? .unreleased : .pastDue + } else if isToday || isInFuture { + return isUnreleased ? .unreleased : .dueNext + } + } + + return BlockStatus.status(of: dateType) + } +} + +public struct DatesBannerInfo { + let missedDeadlines, contentTypeGatingEnabled, missedGatedContent: Bool + let verifiedUpgradeLink: String? +} + +public enum BlockStatus { + case completed + case pastDue + case dueNext + case unreleased + case verifiedOnly + case assignment + case verifiedUpgradeDeadline + case courseExpiredDate + case verificationDeadlineDate + case certificateAvailbleDate + case courseStartDate + case courseEndDate + case event + + static func status(of type: String) -> BlockStatus { + switch type { + case "assignment-due-date": return .assignment + case "verified-upgrade-deadline": return .verifiedUpgradeDeadline + case "course-expired-date": return .courseExpiredDate + case "verification-deadline-date": return .verificationDeadlineDate + case "certificate-available-date": return .certificateAvailbleDate + case "course-start-date": return .courseStartDate + case "course-end-date": return .courseEndDate + default: return .event + } + } + + var title: String { + switch self { + case .completed: + return CoreLocalization.CourseDates.completed + case .pastDue: + return CoreLocalization.CourseDates.pastDue + case .dueNext: + return CoreLocalization.CourseDates.dueNext + case .unreleased: + return CoreLocalization.CourseDates.unreleased + case .verifiedOnly: + return CoreLocalization.CourseDates.verifiedOnly + default: + return "" + } + } +} diff --git a/Course/Course/Domain/Model/CourseDetails.swift b/Course/Course/Domain/Model/CourseDetails.swift index 0edb58854..6769aff53 100644 --- a/Course/Course/Domain/Model/CourseDetails.swift +++ b/Course/Course/Domain/Model/CourseDetails.swift @@ -11,7 +11,7 @@ public struct CourseDetails { public let courseID: String public let org: String public let courseTitle: String - public let courseDescription: String + public let courseDescription: String? public let courseStart: Date? public let courseEnd: Date? public let enrollmentStart: Date? @@ -24,7 +24,7 @@ public struct CourseDetails { public init(courseID: String, org: String, courseTitle: String, - courseDescription: String, + courseDescription: String?, courseStart: Date?, courseEnd: Date?, enrollmentStart: Date?, diff --git a/Course/Course/Presentation/Container/CourseContainerView.swift b/Course/Course/Presentation/Container/CourseContainerView.swift index 574f71b81..726cdaeb3 100644 --- a/Course/Course/Presentation/Container/CourseContainerView.swift +++ b/Course/Course/Presentation/Container/CourseContainerView.swift @@ -15,6 +15,7 @@ public struct CourseContainerView: View { enum CourseTab { case course case videos + case dates case discussion case handounds } @@ -74,6 +75,15 @@ public struct CourseContainerView: View { } .tag(CourseTab.videos) + CourseDatesView(courseID: courseID, + viewModel: Container.shared.resolve(CourseDatesViewModel.self, + argument: courseID)!) + .tabItem { + Image(systemName: "calendar").renderingMode(.template) + Text(CourseLocalization.CourseContainer.dates) + } + .tag(CourseTab.dates) + DiscussionTopicsView(courseID: courseID, viewModel: Container.shared.resolve(DiscussionTopicsViewModel.self, argument: title)!, @@ -122,6 +132,8 @@ public struct CourseContainerView: View { return DiscussionLocalization.title case .handounds: return CourseLocalization.CourseContainer.handouts + case .dates: + return CourseLocalization.CourseContainer.dates } } } diff --git a/Course/Course/Presentation/Container/CourseContainerViewModel.swift b/Course/Course/Presentation/Container/CourseContainerViewModel.swift index 938c187a0..a3f90b8e9 100644 --- a/Course/Course/Presentation/Container/CourseContainerViewModel.swift +++ b/Course/Course/Presentation/Container/CourseContainerViewModel.swift @@ -165,6 +165,8 @@ public class CourseContainerViewModel: BaseCourseViewModel { analytics.courseOutlineCourseTabClicked(courseId: courseId, courseName: courseName) case .videos: analytics.courseOutlineVideosTabClicked(courseId: courseId, courseName: courseName) + case .dates: + analytics.courseOutlineDatesTabClicked(courseId: courseId, courseName: courseName) case .discussion: analytics.courseOutlineDiscussionTabClicked(courseId: courseId, courseName: courseName) case .handounds: diff --git a/Course/Course/Presentation/CourseAnalytics.swift b/Course/Course/Presentation/CourseAnalytics.swift index 6ad6e0389..7396438b4 100644 --- a/Course/Course/Presentation/CourseAnalytics.swift +++ b/Course/Course/Presentation/CourseAnalytics.swift @@ -22,6 +22,7 @@ public protocol CourseAnalytics { func finishVerticalBackToOutlineClicked(courseId: String, courseName: String) func courseOutlineCourseTabClicked(courseId: String, courseName: String) func courseOutlineVideosTabClicked(courseId: String, courseName: String) + func courseOutlineDatesTabClicked(courseId: String, courseName: String) func courseOutlineDiscussionTabClicked(courseId: String, courseName: String) func courseOutlineHandoutsTabClicked(courseId: String, courseName: String) } @@ -46,6 +47,7 @@ class CourseAnalyticsMock: CourseAnalytics { public func finishVerticalBackToOutlineClicked(courseId: String, courseName: String) {} public func courseOutlineCourseTabClicked(courseId: String, courseName: String) {} public func courseOutlineVideosTabClicked(courseId: String, courseName: String) {} + public func courseOutlineDatesTabClicked(courseId: String, courseName: String) {} public func courseOutlineDiscussionTabClicked(courseId: String, courseName: String) {} public func courseOutlineHandoutsTabClicked(courseId: String, courseName: String) {} } diff --git a/Course/Course/Presentation/Dates/CourseDatesView.swift b/Course/Course/Presentation/Dates/CourseDatesView.swift new file mode 100644 index 000000000..34774e1f0 --- /dev/null +++ b/Course/Course/Presentation/Dates/CourseDatesView.swift @@ -0,0 +1,279 @@ +// +// CourseDatesView.swift +// Discussion +// +// Created by Muhammad Umer on 10/17/23. +// + +import Foundation +import SwiftUI +import Core + +public struct CourseDatesView: View { + + private let courseID: String + + @StateObject + private var viewModel: CourseDatesViewModel + + public init( + courseID: String, + viewModel: CourseDatesViewModel + ) { + self.courseID = courseID + self._viewModel = StateObject(wrappedValue: { viewModel }()) + } + + public var body: some View { + ZStack { + VStack(alignment: .center) { + if viewModel.isShowProgress { + HStack(alignment: .center) { + ProgressBar(size: 40, lineWidth: 8) + .padding(.top, 200) + .padding(.horizontal) + } + } else if let courseDates = viewModel.courseDates, !courseDates.courseDateBlocks.isEmpty { + CourseDateListView(viewModel: viewModel, courseDates: courseDates) + } + } + if viewModel.showError { + VStack { + Spacer() + SnackBarView(message: viewModel.errorMessage) + } + .transition(.move(edge: .bottom)) + .onAppear { + doAfter(Theme.Timeout.snackbarMessageLongTimeout) { + viewModel.errorMessage = nil + } + } + } + } + .onFirstAppear { + Task { + await viewModel.getCourseDates(courseID: courseID) + } + } + .background( + Theme.Colors.background + .ignoresSafeArea() + ) + + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +struct Line: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + path.move(to: CGPoint(x: rect.midX, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.midX, y: rect.maxY)) + return path + } +} + +struct TimeLineView: View { + let date: Date + let firstDate: Date? + let lastDate: Date? + + var body: some View { + ZStack(alignment: .top) { + if lastDate == date { + VStack { + Line() + .stroke(style: StrokeStyle(lineWidth: 1)) + .frame(maxHeight: 10.0, alignment: .top) + Spacer() + } + } else if firstDate == date { + Line() + .stroke(style: StrokeStyle(lineWidth: 1)) + .frame(maxHeight: .infinity, alignment: .top) + .padding(.top, 10) + } else { + Line() + .stroke(style: StrokeStyle(lineWidth: 1)) + .frame(maxHeight: .infinity, alignment: .top) + } + + Circle() + .frame(width: date.isToday ? 12 : 8, height: date.isToday ? 12 : 8) + .foregroundColor({ + if date.isToday { + return Theme.Colors.warning + } else if date.isInPast { + return Color.gray + } else { + return Color.black + } + }()) + .overlay(Circle().stroke(Color.black, lineWidth: 1)) + .padding(.top, 5) + } + .frame(width: 16) + } +} + +struct CourseDateListView: View { + @ObservedObject var viewModel: CourseDatesViewModel + var courseDates: CourseDates + + var body: some View { + VStack { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + ForEach(viewModel.sortedDates, id: \.self) { date in + let blocks = courseDates.sortedDateToCourseDateBlockDict[date]! + + HStack(alignment: .center) { + TimeLineView(date: date, + firstDate: viewModel.sortedDates.first, + lastDate: viewModel.sortedDates.last) + + let ignoredStatuses: [BlockStatus] = [.courseStartDate, .courseEndDate] + let block = blocks[0] + let allHaveSameStatus = blocks + .filter { !ignoredStatuses.contains($0.blockStatus) } + .allSatisfy { $0.blockStatus == block.blockStatus } + + BlockStatusView(block: block, + allHaveSameStatus: allHaveSameStatus, + blocks: blocks) + + Spacer() + } + } + } + .padding(.horizontal, 16) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } +} + +struct BlockStatusView: View { + let block: CourseDateBlock + let allHaveSameStatus: Bool + let blocks: [CourseDateBlock] + + var body: some View { + VStack(alignment: .leading) { + HStack { + Text(block.date.dateToString(style: .shortWeekdayMonthDayYear)) + .font(.subheadline) + .bold() + + if block.isToday { + Text(block.blockTitle) + .font(.footnote) + .foregroundColor(Color.black) + .padding(EdgeInsets(top: 2, leading: 6, bottom: 2, trailing: 8)) + .background(Theme.Colors.warning) + .cornerRadius(5) + } + + if allHaveSameStatus { + let lockImageText = block.isVerifiedOnly ? Text(Image(systemName: "lock.fill")) : Text("") + Text("\(lockImageText) \(block.blockTitle)") + .font(.footnote) + .foregroundColor(determineForegroundColor(for: block.blockStatus)) + .padding(EdgeInsets(top: 2, leading: 6, bottom: 2, trailing: 8)) + .background(determineBackgroundColor(for: block.blockStatus)) + .cornerRadius(5) + } + } + + ForEach(blocks, id: \.firstComponentBlockID) { block in + styleBlock(block: block, allHaveSameStatus: allHaveSameStatus) + } + } + .padding(.vertical, 0) + .padding(.leading, 5) + .padding(.bottom, 10) + } + + private func determineForegroundColor(for status: BlockStatus) -> Color { + switch status { + case .verifiedOnly: return Color.white + case .pastDue: return Color.black + case .dueNext: return Color.white + default: return Color.white.opacity(0) + } + } + + private func determineBackgroundColor(for status: BlockStatus) -> Color { + switch status { + case .verifiedOnly: return Color.black.opacity(0.5) + case .pastDue: return Color.gray.opacity(0.4) + case .dueNext: return Color.black.opacity(0.5) + default: return Color.white.opacity(0) + } + } + + func styleBlock(block: CourseDateBlock, allHaveSameStatus: Bool) -> some View { + var attrString = AttributedString("") + + if let prefix = block.assignmentType, !prefix.isEmpty { + attrString += AttributedString("\(prefix): ") + } + + attrString += block.canShowLink ? getAttributedUnderlineString(string: block.title) : AttributedString(block.title) + + if !allHaveSameStatus { + attrString += " " + let (status, foregroundColor, backgroundColor) = getStatusDetails(for: block.blockStatus) + attrString += getAttributedString(string: status, forgroundColor: foregroundColor, backgroundColor: backgroundColor) + } + + return Text(attrString).padding(.bottom, 2).font(.footnote) + } + + func getStatusDetails(for blockStatus: BlockStatus) -> (String, Color, Color) { + switch blockStatus { + case .verifiedOnly: + return (CoreLocalization.CourseDates.verifiedOnly, Color.white, Color.black.opacity(0.5)) + case .pastDue: + return (CoreLocalization.CourseDates.pastDue, Color.black, Color.gray.opacity(0.4)) + case .dueNext: + return (CoreLocalization.CourseDates.dueNext, Color.white, Color.black.opacity(0.5)) + case .unreleased: + return (CoreLocalization.CourseDates.unreleased, Color.white.opacity(0), Color.white.opacity(0)) + default: + return ("", Color.white.opacity(0), Color.white.opacity(0)) + } + } + + func getAttributedUnderlineString(string: String) -> AttributedString { + var attributedString = AttributedString(string) + attributedString.font = .footnote + attributedString.underlineStyle = .single + return attributedString + } + + func getAttributedString(string: String, forgroundColor: Color, backgroundColor: Color) -> AttributedString { + var attributedString = AttributedString(string) + attributedString.font = .footnote + attributedString.foregroundColor = forgroundColor + attributedString.backgroundColor = backgroundColor + return attributedString + } +} + +#if DEBUG +struct CourseDatesView_Previews: PreviewProvider { + static var previews: some View { + let viewModel = CourseDatesViewModel( + interactor: CourseInteractor(repository: CourseRepositoryMock()), + router: CourseRouterMock(), + cssInjector: CSSInjectorMock(), + connectivity: Connectivity(), + courseID: "") + + CourseDatesView( + courseID: "", + viewModel: viewModel) + } +} +#endif diff --git a/Course/Course/Presentation/Dates/CourseDatesViewModel.swift b/Course/Course/Presentation/Dates/CourseDatesViewModel.swift new file mode 100644 index 000000000..e60d413d9 --- /dev/null +++ b/Course/Course/Presentation/Dates/CourseDatesViewModel.swift @@ -0,0 +1,72 @@ +// +// CourseDatesViewModel.swift +// Course +// +// Created by Muhammad Umer on 10/18/23. +// + +import Foundation +import Core +import SwiftUI + +public class CourseDatesViewModel: ObservableObject { + + @Published private(set) var isShowProgress = false + @Published var showError: Bool = false + @Published var courseDates: CourseDates? + + var errorMessage: String? { + didSet { + withAnimation { + showError = errorMessage != nil + } + } + } + + private let interactor: CourseInteractorProtocol + let cssInjector: CSSInjector + let router: CourseRouter + let connectivity: ConnectivityProtocol + + public init( + interactor: CourseInteractorProtocol, + router: CourseRouter, + cssInjector: CSSInjector, + connectivity: ConnectivityProtocol, + courseID: String + ) { + self.interactor = interactor + self.router = router + self.cssInjector = cssInjector + self.connectivity = connectivity + } + + var sortedDates: [Date] { + courseDates?.sortedDateToCourseDateBlockDict.keys.sorted() ?? [] + } + + func blocks(for date: Date) -> [CourseDateBlock] { + courseDates?.sortedDateToCourseDateBlockDict[date] ?? [] + } + + @MainActor + func getCourseDates(courseID: String) async { + isShowProgress = true + do { + courseDates = try await interactor.getCourseDates(courseID: courseID) + if courseDates?.courseDateBlocks == nil { + isShowProgress = false + errorMessage = CoreLocalization.Error.unknownError + return + } + isShowProgress = false + } catch let error { + isShowProgress = false + if error.isInternetError || error is NoCachedDataError { + errorMessage = CoreLocalization.Error.slowOrNoInternetConnection + } else { + errorMessage = CoreLocalization.Error.unknownError + } + } + } +} diff --git a/Course/Course/Presentation/Details/CourseDetailsView.swift b/Course/Course/Presentation/Details/CourseDetailsView.swift index 2b846d526..5323efc64 100644 --- a/Course/Course/Presentation/Details/CourseDetailsView.swift +++ b/Course/Course/Presentation/Details/CourseDetailsView.swift @@ -246,7 +246,7 @@ private struct CourseTitleView: View { var body: some View { VStack(alignment: .leading, spacing: 10) { - Text(courseDetails.courseDescription) + Text(courseDetails.courseDescription ?? "") .font(Theme.Fonts.labelSmall) .padding(.horizontal, 26) diff --git a/Course/Course/SwiftGen/Strings.swift b/Course/Course/SwiftGen/Strings.swift index f5719bf9d..9ec7b8b36 100644 --- a/Course/Course/SwiftGen/Strings.swift +++ b/Course/Course/SwiftGen/Strings.swift @@ -41,6 +41,8 @@ public enum CourseLocalization { public enum CourseContainer { /// Course public static let course = CourseLocalization.tr("Localizable", "COURSE_CONTAINER.COURSE", fallback: "Course") + /// Dates + public static let dates = CourseLocalization.tr("Localizable", "COURSE_CONTAINER.DATES", fallback: "Dates") /// Discussion public static let discussion = CourseLocalization.tr("Localizable", "COURSE_CONTAINER.DISCUSSION", fallback: "Discussion") /// Handouts diff --git a/Course/Course/en.lproj/Localizable.strings b/Course/Course/en.lproj/Localizable.strings index 0f5edc88f..a37d426c0 100644 --- a/Course/Course/en.lproj/Localizable.strings +++ b/Course/Course/en.lproj/Localizable.strings @@ -37,6 +37,7 @@ "COURSE_CONTAINER.COURSE" = "Course"; "COURSE_CONTAINER.VIDEOS" = "Videos"; +"COURSE_CONTAINER.DATES" = "Dates"; "COURSE_CONTAINER.DISCUSSION" = "Discussion"; "COURSE_CONTAINER.HANDOUTS" = "Handouts"; "COURSE_CONTAINER.HANDOUTS_IN_DEVELOPING" = "Handouts In developing"; diff --git a/Course/Course/uk.lproj/Localizable.strings b/Course/Course/uk.lproj/Localizable.strings index cedc987f1..4f7ff5f87 100644 --- a/Course/Course/uk.lproj/Localizable.strings +++ b/Course/Course/uk.lproj/Localizable.strings @@ -36,6 +36,7 @@ "COURSE_CONTAINER.COURSE" = "Курс"; "COURSE_CONTAINER.VIDEOS" = "Всі відео"; +//"COURSE_CONTAINER.DATES" = "Dates"; "COURSE_CONTAINER.DISCUSSION" = "Дискусії"; "COURSE_CONTAINER.HANDOUTS" = "Матеріали"; "COURSE_CONTAINER.HANDOUTS_IN_DEVELOPING" = "Матеріали в процесі розробки"; diff --git a/Course/CourseTests/CourseMock.generated.swift b/Course/CourseTests/CourseMock.generated.swift index 122385f57..8c909304b 100644 --- a/Course/CourseTests/CourseMock.generated.swift +++ b/Course/CourseTests/CourseMock.generated.swift @@ -1124,6 +1124,12 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { perform?(`courseId`, `courseName`) } + open func courseOutlineDatesTabClicked(courseId: String, courseName: String) { + addInvocation(.m_courseOutlineDatesTabClicked__courseId_courseIdcourseName_courseName(Parameter.value(`courseId`), Parameter.value(`courseName`))) + let perform = methodPerformValue(.m_courseOutlineDatesTabClicked__courseId_courseIdcourseName_courseName(Parameter.value(`courseId`), Parameter.value(`courseName`))) as? (String, String) -> Void + perform?(`courseId`, `courseName`) + } + open func courseOutlineDiscussionTabClicked(courseId: String, courseName: String) { addInvocation(.m_courseOutlineDiscussionTabClicked__courseId_courseIdcourseName_courseName(Parameter.value(`courseId`), Parameter.value(`courseName`))) let perform = methodPerformValue(.m_courseOutlineDiscussionTabClicked__courseId_courseIdcourseName_courseName(Parameter.value(`courseId`), Parameter.value(`courseName`))) as? (String, String) -> Void @@ -1151,6 +1157,7 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { case m_finishVerticalBackToOutlineClicked__courseId_courseIdcourseName_courseName(Parameter, Parameter) case m_courseOutlineCourseTabClicked__courseId_courseIdcourseName_courseName(Parameter, Parameter) case m_courseOutlineVideosTabClicked__courseId_courseIdcourseName_courseName(Parameter, Parameter) + case m_courseOutlineDatesTabClicked__courseId_courseIdcourseName_courseName(Parameter, Parameter) case m_courseOutlineDiscussionTabClicked__courseId_courseIdcourseName_courseName(Parameter, Parameter) case m_courseOutlineHandoutsTabClicked__courseId_courseIdcourseName_courseName(Parameter, Parameter) @@ -1247,6 +1254,12 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCoursename, rhs: rhsCoursename, with: matcher), lhsCoursename, rhsCoursename, "courseName")) return Matcher.ComparisonResult(results) + case (.m_courseOutlineDatesTabClicked__courseId_courseIdcourseName_courseName(let lhsCourseid, let lhsCoursename), .m_courseOutlineDatesTabClicked__courseId_courseIdcourseName_courseName(let rhsCourseid, let rhsCoursename)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCoursename, rhs: rhsCoursename, with: matcher), lhsCoursename, rhsCoursename, "courseName")) + return Matcher.ComparisonResult(results) + case (.m_courseOutlineDiscussionTabClicked__courseId_courseIdcourseName_courseName(let lhsCourseid, let lhsCoursename), .m_courseOutlineDiscussionTabClicked__courseId_courseIdcourseName_courseName(let rhsCourseid, let rhsCoursename)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) @@ -1277,6 +1290,7 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { case let .m_finishVerticalBackToOutlineClicked__courseId_courseIdcourseName_courseName(p0, p1): return p0.intValue + p1.intValue case let .m_courseOutlineCourseTabClicked__courseId_courseIdcourseName_courseName(p0, p1): return p0.intValue + p1.intValue case let .m_courseOutlineVideosTabClicked__courseId_courseIdcourseName_courseName(p0, p1): return p0.intValue + p1.intValue + case let .m_courseOutlineDatesTabClicked__courseId_courseIdcourseName_courseName(p0, p1): return p0.intValue + p1.intValue case let .m_courseOutlineDiscussionTabClicked__courseId_courseIdcourseName_courseName(p0, p1): return p0.intValue + p1.intValue case let .m_courseOutlineHandoutsTabClicked__courseId_courseIdcourseName_courseName(p0, p1): return p0.intValue + p1.intValue } @@ -1296,6 +1310,7 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { case .m_finishVerticalBackToOutlineClicked__courseId_courseIdcourseName_courseName: return ".finishVerticalBackToOutlineClicked(courseId:courseName:)" case .m_courseOutlineCourseTabClicked__courseId_courseIdcourseName_courseName: return ".courseOutlineCourseTabClicked(courseId:courseName:)" case .m_courseOutlineVideosTabClicked__courseId_courseIdcourseName_courseName: return ".courseOutlineVideosTabClicked(courseId:courseName:)" + case .m_courseOutlineDatesTabClicked__courseId_courseIdcourseName_courseName: return ".courseOutlineDatesTabClicked(courseId:courseName:)" case .m_courseOutlineDiscussionTabClicked__courseId_courseIdcourseName_courseName: return ".courseOutlineDiscussionTabClicked(courseId:courseName:)" case .m_courseOutlineHandoutsTabClicked__courseId_courseIdcourseName_courseName: return ".courseOutlineHandoutsTabClicked(courseId:courseName:)" } @@ -1329,6 +1344,7 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { public static func finishVerticalBackToOutlineClicked(courseId: Parameter, courseName: Parameter) -> Verify { return Verify(method: .m_finishVerticalBackToOutlineClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`))} public static func courseOutlineCourseTabClicked(courseId: Parameter, courseName: Parameter) -> Verify { return Verify(method: .m_courseOutlineCourseTabClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`))} public static func courseOutlineVideosTabClicked(courseId: Parameter, courseName: Parameter) -> Verify { return Verify(method: .m_courseOutlineVideosTabClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`))} + public static func courseOutlineDatesTabClicked(courseId: Parameter, courseName: Parameter) -> Verify { return Verify(method: .m_courseOutlineDatesTabClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`))} public static func courseOutlineDiscussionTabClicked(courseId: Parameter, courseName: Parameter) -> Verify { return Verify(method: .m_courseOutlineDiscussionTabClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`))} public static func courseOutlineHandoutsTabClicked(courseId: Parameter, courseName: Parameter) -> Verify { return Verify(method: .m_courseOutlineHandoutsTabClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`))} } @@ -1376,6 +1392,9 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { public static func courseOutlineVideosTabClicked(courseId: Parameter, courseName: Parameter, perform: @escaping (String, String) -> Void) -> Perform { return Perform(method: .m_courseOutlineVideosTabClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`), performs: perform) } + public static func courseOutlineDatesTabClicked(courseId: Parameter, courseName: Parameter, perform: @escaping (String, String) -> Void) -> Perform { + return Perform(method: .m_courseOutlineDatesTabClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`), performs: perform) + } public static func courseOutlineDiscussionTabClicked(courseId: Parameter, courseName: Parameter, perform: @escaping (String, String) -> Void) -> Perform { return Perform(method: .m_courseOutlineDiscussionTabClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`), performs: perform) } @@ -1671,6 +1690,22 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { 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_getCourseDetails__courseID_courseID(Parameter) @@ -1684,6 +1719,7 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { case m_getUpdates__courseID_courseID(Parameter) case m_resumeBlock__courseID_courseID(Parameter) case m_getSubtitles__url_urlselectedLanguage_selectedLanguage(Parameter, Parameter) + case m_getCourseDates__courseID_courseID(Parameter) static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { switch (lhs, rhs) { @@ -1743,6 +1779,11 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsUrl, rhs: rhsUrl, with: matcher), lhsUrl, rhsUrl, "url")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSelectedlanguage, rhs: rhsSelectedlanguage, with: matcher), lhsSelectedlanguage, rhsSelectedlanguage, "selectedLanguage")) return Matcher.ComparisonResult(results) + + 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 } } @@ -1760,6 +1801,7 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { case let .m_getUpdates__courseID_courseID(p0): return p0.intValue case let .m_resumeBlock__courseID_courseID(p0): return p0.intValue case let .m_getSubtitles__url_urlselectedLanguage_selectedLanguage(p0, p1): return p0.intValue + p1.intValue + case let .m_getCourseDates__courseID_courseID(p0): return p0.intValue } } func assertionName() -> String { @@ -1775,6 +1817,7 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { case .m_getUpdates__courseID_courseID: return ".getUpdates(courseID:)" case .m_resumeBlock__courseID_courseID: return ".resumeBlock(courseID:)" case .m_getSubtitles__url_urlselectedLanguage_selectedLanguage: return ".getSubtitles(url:selectedLanguage:)" + case .m_getCourseDates__courseID_courseID: return ".getCourseDates(courseID:)" } } } @@ -1818,6 +1861,9 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { public static func getSubtitles(url: Parameter, selectedLanguage: Parameter, willReturn: [Subtitle]...) -> MethodStub { return Given(method: .m_getSubtitles__url_urlselectedLanguage_selectedLanguage(`url`, `selectedLanguage`), 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 getCourseVideoBlocks(fullStructure: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { let willReturn: [CourseStructure] = [] let given: Given = { return Given(method: .m_getCourseVideoBlocks__fullStructure_fullStructure(`fullStructure`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() @@ -1925,6 +1971,16 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { 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 { @@ -1941,6 +1997,7 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { public static func getUpdates(courseID: Parameter) -> Verify { return Verify(method: .m_getUpdates__courseID_courseID(`courseID`))} public static func resumeBlock(courseID: Parameter) -> Verify { return Verify(method: .m_resumeBlock__courseID_courseID(`courseID`))} public static func getSubtitles(url: Parameter, selectedLanguage: Parameter) -> Verify { return Verify(method: .m_getSubtitles__url_urlselectedLanguage_selectedLanguage(`url`, `selectedLanguage`))} + public static func getCourseDates(courseID: Parameter) -> Verify { return Verify(method: .m_getCourseDates__courseID_courseID(`courseID`))} } public struct Perform { @@ -1980,6 +2037,9 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { public static func getSubtitles(url: Parameter, selectedLanguage: Parameter, perform: @escaping (String, String) -> Void) -> Perform { return Perform(method: .m_getSubtitles__url_urlselectedLanguage_selectedLanguage(`url`, `selectedLanguage`), 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) { diff --git a/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift b/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift new file mode 100644 index 000000000..0b6f6f9bf --- /dev/null +++ b/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift @@ -0,0 +1,469 @@ +// +// CourseDateViewModelTests.swift +// CourseTests +// +// Created by Muhammad Umer on 10/24/23. +// + +import SwiftyMocky +import XCTest +@testable import Core +@testable import Course +import Alamofire +import SwiftUI + +final class CourseDateViewModelTests: XCTestCase { + func testGetCourseDatesSuccess() async throws { + let interactor = CourseInteractorProtocolMock() + let router = CourseRouterMock() + let cssInjector = CSSInjectorMock() + let connectivity = ConnectivityProtocolMock() + + let courseDates = CourseDates( + datesBannerInfo: + DatesBannerInfo( + missedDeadlines: false, + contentTypeGatingEnabled: false, + missedGatedContent: false, + verifiedUpgradeLink: ""), + courseDateBlocks: [], + hasEnded: false, + learnerIsFullAccess: false, + userTimezone: nil) + + Given(interactor, .getCourseDates(courseID: .any, willReturn: courseDates)) + + let viewModel = CourseDatesViewModel( + interactor: interactor, + router: router, + cssInjector: cssInjector, + connectivity: connectivity, + courseID: "1") + + await viewModel.getCourseDates(courseID: "1") + + Verify(interactor, .getCourseDates(courseID: .any)) + + XCTAssert((viewModel.courseDates != nil)) + XCTAssertFalse(viewModel.isShowProgress) + XCTAssertNil(viewModel.errorMessage) + XCTAssertFalse(viewModel.showError) + } + + func testGetCourseDatesUnknownError() async throws { + let interactor = CourseInteractorProtocolMock() + let router = CourseRouterMock() + let cssInjector = CSSInjectorMock() + let connectivity = ConnectivityProtocolMock() + + Given(interactor, .getCourseDates(courseID: .any, willThrow: NSError())) + + let viewModel = CourseDatesViewModel( + interactor: interactor, + router: router, + cssInjector: cssInjector, + connectivity: connectivity, + courseID: "1") + + await viewModel.getCourseDates(courseID: "1") + + Verify(interactor, .getCourseDates(courseID: .any)) + + XCTAssertTrue(viewModel.showError) + XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.unknownError) + } + + func testNoInternetConnectionError() async throws { + let interactor = CourseInteractorProtocolMock() + let router = CourseRouterMock() + let cssInjector = CSSInjectorMock() + let connectivity = ConnectivityProtocolMock() + + let noInternetError = AFError.sessionInvalidated(error: URLError(.notConnectedToInternet)) + + Given(interactor, .getCourseDates(courseID: .any, willThrow: noInternetError)) + + let viewModel = CourseDatesViewModel( + interactor: interactor, + router: router, + cssInjector: cssInjector, + connectivity: connectivity, + courseID: "1") + + await viewModel.getCourseDates(courseID: "1") + + Verify(interactor, .getCourseDates(courseID: .any)) + + XCTAssertTrue(viewModel.showError) + XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.slowOrNoInternetConnection) + } + + func testSortedDateTodayToCourseDateBlockDict() { + let block1 = CourseDateBlock( + assignmentType: nil, + complete: nil, + date: Date.today.addingTimeInterval(86400), + dateType: "event", + description: "", + learnerHasAccess: true, + link: "www.example.com", + linkText: nil, + title: "TestBlock", + extraInfo: nil, + firstComponentBlockID: "blockID1" + ) + + let block2 = CourseDateBlock( + assignmentType: nil, + complete: nil, + date: Date.today, + dateType: "event", + description: "", + learnerHasAccess: true, + link: "www.example.com", + linkText: nil, + title: "TestBlock", + extraInfo: nil, + firstComponentBlockID: "blockID1" + ) + + let courseDates = CourseDates( + datesBannerInfo: DatesBannerInfo( + missedDeadlines: false, + contentTypeGatingEnabled: false, + missedGatedContent: false, + verifiedUpgradeLink: nil + ), + courseDateBlocks: [block1, block2], + hasEnded: false, + learnerIsFullAccess: true, + userTimezone: nil + ) + + let sortedDict = courseDates.sortedDateToCourseDateBlockDict + + XCTAssertEqual(sortedDict.keys.sorted().first, Date.today) + } + + func testMultipleBlocksForSameDate() { + let block1 = CourseDateBlock( + assignmentType: nil, + complete: nil, + date: Date.today, + dateType: "event", + description: "", + learnerHasAccess: true, + link: "www.example.com", + linkText: nil, + title: "TestBlock", + extraInfo: nil, + firstComponentBlockID: "blockID1" + ) + + let block2 = CourseDateBlock( + assignmentType: nil, + complete: nil, + date: Date.today, + dateType: "event", + description: "", + learnerHasAccess: true, + link: "www.example.com", + linkText: nil, + title: "TestBlock", + extraInfo: nil, + firstComponentBlockID: "blockID1" + ) + + let courseDates = CourseDates( + datesBannerInfo: DatesBannerInfo( + missedDeadlines: false, + contentTypeGatingEnabled: false, + missedGatedContent: false, + verifiedUpgradeLink: nil + ), + courseDateBlocks: [block1, block2], + hasEnded: false, + learnerIsFullAccess: true, + userTimezone: nil + ) + + let sortedDict = courseDates.sortedDateToCourseDateBlockDict + XCTAssertEqual(sortedDict[block1.date]?.count, 2, "There should be two blocks for the given date.") + } + + func testBlockStatusForAssignmentType() { + let block = CourseDateBlock( + assignmentType: nil, + complete: nil, + date: Date(), + dateType: "assignment-due-date", + description: "", + learnerHasAccess: true, + link: "www.example.com", + linkText: nil, + title: "TestAssignment", + extraInfo: nil, + firstComponentBlockID: "blockID3" + ) + + XCTAssertEqual(block.blockStatus, .dueNext) + } + + func testBadgeLogicForToday() { + let block = CourseDateBlock( + assignmentType: nil, + complete: false, + date: Date.today, + dateType: "", + description: "", + learnerHasAccess: false, + link: "www.example.com", + linkText: nil, + title: "TestBlock", + extraInfo: nil, + firstComponentBlockID: "blockIDTest" + ) + + XCTAssertEqual(block.blockTitle, "Today", "Block title for 'today' should be 'Today'") + } + + func testBadgeLogicForCompleted() { + let block = CourseDateBlock( + assignmentType: nil, + complete: true, + date: Date.today, + dateType: "assignment-due-date", + description: "", + learnerHasAccess: true, + link: "www.example.com", + linkText: nil, + title: "TestBlock", + extraInfo: nil, + firstComponentBlockID: "blockIDTest" + ) + XCTAssertEqual(block.blockStatus, .completed, "Block status for a completed assignment should be 'completed'") + } + + func testBadgeLogicForVerifiedOnly() { + let block = CourseDateBlock( + assignmentType: nil, + complete: false, + date: Date.today, + dateType: "assignment-due-date", + description: "", + learnerHasAccess: false, + link: "www.example.com", + linkText: nil, + title: "TestBlock", + extraInfo: nil, + firstComponentBlockID: "blockIDTest" + ) + + XCTAssertEqual(block.blockStatus, .verifiedOnly, "Block status for a block without learner access should be 'verifiedOnly'") + } + + func testBadgeLogicForPastDue() { + let block = CourseDateBlock( + assignmentType: nil, + complete: false, + date: Date.today.addingTimeInterval(-86400), + dateType: "assignment-due-date", + description: "", + learnerHasAccess: true, + link: "www.example.com", + linkText: nil, + title: "TestBlock", + extraInfo: nil, + firstComponentBlockID: "blockIDTest" + ) + + XCTAssertEqual(block.blockStatus, .pastDue, "Block status for a past due assignment should be 'pastDue'") + } + + func testLinkForAvailableAssignment() { + let availableAssignment = CourseDateBlock( + assignmentType: nil, + complete: true, + date: Date.today, + dateType: "assignment-due-date", + description: "", + learnerHasAccess: true, + link: "www.example.com", + linkText: nil, + title: "TestBlock", + extraInfo: nil, + firstComponentBlockID: "blockIDTest" + ) + XCTAssertTrue(availableAssignment.canShowLink, "Available assignments should be hyperlinked.") + } + + func testIsAssignment() { + let block = CourseDateBlock( + assignmentType: nil, + complete: false, + date: Date.today.addingTimeInterval(86400), + dateType: "assignment-due-date", + description: "", + learnerHasAccess: false, + link: "www.example.com", + linkText: nil, + title: "TestBlock", + extraInfo: nil, + firstComponentBlockID: "blockIDTest" + ) + + XCTAssertTrue(block.isAssignment) + } + + func testIsCourseStartDate() { + let block = CourseDateBlock( + assignmentType: nil, + complete: nil, + date: Date.today.addingTimeInterval(-86400), + dateType: "course-start-date", + description: "", + learnerHasAccess: true, + link: "www.example.com", + linkText: nil, + title: "TestBlock", + extraInfo: nil, + firstComponentBlockID: "blockIDTest" + ) + + XCTAssertEqual(block.blockStatus, BlockStatus.courseStartDate) + } + + func testIsCourseEndDate() { + let block = CourseDateBlock( + assignmentType: nil, + complete: nil, + date: Date.today.addingTimeInterval(86400), + dateType: "course-end-date", + description: "", + learnerHasAccess: true, + link: "www.example.com", + linkText: nil, + title: "TestBlock", + extraInfo: nil, + firstComponentBlockID: "blockIDTest" + ) + + XCTAssertEqual(block.blockStatus, BlockStatus.courseEndDate) + } + + func testVerifiedOnly() { + let block = CourseDateBlock( + assignmentType: nil, + complete: false, + date: Date.today.addingTimeInterval(86400), + dateType: "assignment-due-date", + description: "", + learnerHasAccess: false, + link: "www.example.com", + linkText: nil, + title: "TestBlock", + extraInfo: nil, + firstComponentBlockID: "blockIDTest" + ) + + XCTAssertTrue(block.isVerifiedOnly) + } + + func testIsCompleted() { + let block = CourseDateBlock( + assignmentType: nil, + complete: true, + date: Date.today, + dateType: "assignment-due-date", + description: "", + learnerHasAccess: false, + link: "www.example.com", + linkText: nil, + title: "TestBlock", + extraInfo: nil, + firstComponentBlockID: "blockIDTest" + ) + + XCTAssertTrue(block.isComplete) + } + + func testBadgeLogicForUnreleasedAssignment() { + let block = CourseDateBlock( + assignmentType: nil, + complete: false, + date: Date.today.addingTimeInterval(86400), + dateType: "assignment-due-date", + description: "", + learnerHasAccess: true, + link: "", + linkText: nil, + title: "TestBlock", + extraInfo: nil, + firstComponentBlockID: "blockIDTest" + ) + + XCTAssertEqual(block.blockStatus, .unreleased) + } + + func testNoLinkForUnavailableAssignment() { + let block = CourseDateBlock( + assignmentType: nil, + complete: false, + date: Date.today.addingTimeInterval(86400), + dateType: "assignment-due-date", + description: "", + learnerHasAccess: false, + link: "www.example.com", + linkText: nil, + title: "TestBlock", + extraInfo: nil, + firstComponentBlockID: "blockIDTest" + ) + + XCTAssertFalse(block.canShowLink) + } + + func testNoLinkAvailableForUnreleasedAssignment() { + let block = CourseDateBlock( + assignmentType: nil, + complete: false, + date: Date.today, + dateType: "assignment-due-date", + description: "", + learnerHasAccess: false, + link: "", + linkText: nil, + title: "TestBlock", + extraInfo: nil, + firstComponentBlockID: "blockIDTest" + ) + + XCTAssertFalse(block.canShowLink) + } + + func testTodayProperty() { + let today = Date.today + let currentDay = Calendar.current.startOfDay(for: Date()) + XCTAssertEqual(today, currentDay, "The today property should equal the start of the current day.") + } + + func testDateIsInPastProperty() { + let pastDate = Date().addingTimeInterval(-100000) + XCTAssertTrue(pastDate.isInPast) + } + + func testDateIsInFutureProperty() { + let futureDate = Date().addingTimeInterval(100000) + XCTAssertTrue(futureDate.isInFuture) + } + + func testBlockStatusMapping() { + XCTAssertEqual(BlockStatus.status(of: "course-start-date"), .courseStartDate, "Incorrect mapping for 'course-start-date'") + XCTAssertEqual(BlockStatus.status(of: "course-end-date"), .courseEndDate, "Incorrect mapping for 'course-end-date'") + XCTAssertEqual(BlockStatus.status(of: "certificate-available-date"), .certificateAvailbleDate, "Incorrect mapping for 'certificate-available-date'") + XCTAssertEqual(BlockStatus.status(of: "verification-deadline-date"), .verificationDeadlineDate, "Incorrect mapping for 'verification-deadline-date'") + XCTAssertEqual(BlockStatus.status(of: "verified-upgrade-deadline"), .verifiedUpgradeDeadline, "Incorrect mapping for 'verified-upgrade-deadline'") + XCTAssertEqual(BlockStatus.status(of: "assignment-due-date"), .assignment, "Incorrect mapping for 'assignment-due-date'") + XCTAssertEqual(BlockStatus.status(of: ""), .event, "Incorrect mapping for ''") + } +} diff --git a/Discovery/Discovery.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Discovery/Discovery.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..919434a62 --- /dev/null +++ b/Discovery/Discovery.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Discovery/Discovery.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Discovery/Discovery.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000..18d981003 --- /dev/null +++ b/Discovery/Discovery.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/OpenEdX.xcodeproj/project.xcworkspace/xcuserdata/vchekyrta.xcuserdatad/UserInterfaceState.xcuserstate b/OpenEdX.xcodeproj/project.xcworkspace/xcuserdata/vchekyrta.xcuserdatad/UserInterfaceState.xcuserstate deleted file mode 100644 index b59556e654f35a351b926f65bd11ffd1fa0a1c57..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10773 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 ab8F`FnQvz9i@F#p{z|kl=OX@QzW1L+M1ZCM diff --git a/OpenEdX/AnalyticsManager.swift b/OpenEdX/AnalyticsManager.swift index 04a11b640..598aac053 100644 --- a/OpenEdX/AnalyticsManager.swift +++ b/OpenEdX/AnalyticsManager.swift @@ -257,6 +257,14 @@ class AnalyticsManager: AuthorizationAnalytics, logEvent(.courseOutlineVideosTabClicked, parameters: parameters) } + public func courseOutlineDatesTabClicked(courseId: String, courseName: String) { + let parameters = [ + Key.courseID: courseId, + Key.courseName: courseName + ] + logEvent(.courseOutlineDatesTabClicked, parameters: parameters) + } + public func courseOutlineDiscussionTabClicked(courseId: String, courseName: String) { let parameters = [ Key.courseID: courseId, @@ -360,6 +368,7 @@ enum Event: String { case finishVerticalBackToOutlineClicked = "Finish_Vertical_Back_to_outline_Clicked" case courseOutlineCourseTabClicked = "Course_Outline_Course_tab_Clicked" case courseOutlineVideosTabClicked = "Course_Outline_Videos_tab_Clicked" + case courseOutlineDatesTabClicked = "Course_Outline_Dates_tab_Clicked" case courseOutlineDiscussionTabClicked = "Course_Outline_Discussion_tab_Clicked" case courseOutlineHandoutsTabClicked = "Course_Outline_Handouts_tab_Clicked" diff --git a/OpenEdX/DI/ScreenAssembly.swift b/OpenEdX/DI/ScreenAssembly.swift index c5c52df4c..eb4e04396 100644 --- a/OpenEdX/DI/ScreenAssembly.swift +++ b/OpenEdX/DI/ScreenAssembly.swift @@ -298,6 +298,15 @@ class ScreenAssembly: Assembly { ) } + container.register(CourseDatesViewModel.self) { r, courseID in + CourseDatesViewModel( + interactor: r.resolve(CourseInteractorProtocol.self)!, + router: r.resolve(CourseRouter.self)!, + cssInjector: r.resolve(CSSInjector.self)!, + connectivity: r.resolve(ConnectivityProtocol.self)!, + courseID: courseID) + } + // MARK: Discussion container.register(DiscussionRepositoryProtocol.self) { r in DiscussionRepository( diff --git a/OpenEdX/Data/CoursePersistence.swift b/OpenEdX/Data/CoursePersistence.swift index cd61e5e6e..6c7f5d83e 100644 --- a/OpenEdX/Data/CoursePersistence.swift +++ b/OpenEdX/Data/CoursePersistence.swift @@ -215,4 +215,12 @@ public class CoursePersistence: CoursePersistenceProtocol { } return nil } + + public func saveCourseDates(courseID: String, courseDates: CourseDates) { + + } + + public func loadCourseDates(courseID: String) throws -> CourseDates { + throw NoCachedDataError() + } } From 55aa1358af73f97a0ab161ed6a008185de62d069 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Fri, 3 Nov 2023 11:12:45 +0200 Subject: [PATCH 009/158] Feature/App update (#136) * add update version notifications * App update version views * fix logout logic * update tests * Update UpdateRequiredView.swift * remove debug prints * switch the localization to default * minor fixes after review * add executable to RequestInterceptor * code style review * add prefetchDataForOffline to MainScreenViewModel * remove appUpdateFeatureEnabled flag * disable version button when is up to date * fix merge conflicts --------- Co-authored-by: stepanokdev <100592747+Stepanokdev@users.noreply.github.com> --- .../Presentation/AuthorizationRouter.swift | 7 +- .../Presentation/Login/SignInView.swift | 3 +- .../Presentation/Login/SignInViewModel.swift | 10 +- .../Registration/SignUpViewModel.swift | 2 + .../AuthorizationMock.generated.swift | 18 +++ .../Login/SignInViewModelTests.swift | 11 +- .../warning_filled.imageset/Contents.json | 12 ++ .../warning_filled.svg | 5 + Core/Core/Configuration/Config.swift | 4 + Core/Core/Domain/Model/UserProfile.swift | 12 ++ Core/Core/Extensions/Notification.swift | 3 + Core/Core/Network/API.swift | 16 +- Core/Core/Network/Alamofire+Error.swift | 4 + Core/Core/Network/RequestInterceptor.swift | 17 +++ Core/Core/SwiftGen/Assets.swift | 1 + Discovery/Discovery.xcodeproj/project.pbxproj | 20 +++ .../Presentation/DiscoveryRouter.swift | 5 +- .../Presentation/DiscoveryView.swift | 22 +-- .../Presentation/DiscoveryViewModel.swift | 67 ++++++++- .../UpdateViews/UpdateNotificationView.swift | 60 ++++++++ .../UpdateViews/UpdateRecommendedView.swift | 85 +++++++++++ .../UpdateViews/UpdateRequiredView.swift | 76 ++++++++++ Discovery/Discovery/SwiftGen/Strings.swift | 20 +++ .../Discovery/en.lproj/Localizable.strings | 13 ++ .../Discovery/uk.lproj/Localizable.strings | 13 ++ .../DiscoveryViewModelTests.swift | 32 +++- OpenEdX.xcodeproj/project.pbxproj | 16 +- OpenEdX/DI/ScreenAssembly.swift | 12 ++ OpenEdX/MainScreenAnalytics.swift | 9 ++ OpenEdX/RouteController.swift | 3 +- OpenEdX/Router.swift | 18 ++- OpenEdX/View/MainScreenView.swift | 60 +++++--- OpenEdX/View/MainScreenViewModel.swift | 44 ++++++ Profile/Profile/Data/ProfileRepository.swift | 12 +- .../Profile/Domain/ProfileInteractor.swift | 6 +- .../Presentation/Profile/ProfileView.swift | 140 ++++++++++++------ .../Profile/ProfileViewModel.swift | 68 ++++++--- Profile/Profile/SwiftGen/Strings.swift | 8 + Profile/Profile/en.lproj/Localizable.strings | 5 + Profile/Profile/uk.lproj/Localizable.strings | 5 + .../Profile/ProfileViewModelTests.swift | 87 ++--------- .../ProfileTests/ProfileMock.generated.swift | 28 ++-- 42 files changed, 833 insertions(+), 226 deletions(-) create mode 100644 Core/Core/Assets.xcassets/warning_filled.imageset/Contents.json create mode 100644 Core/Core/Assets.xcassets/warning_filled.imageset/warning_filled.svg create mode 100644 Discovery/Discovery/Presentation/UpdateViews/UpdateNotificationView.swift create mode 100644 Discovery/Discovery/Presentation/UpdateViews/UpdateRecommendedView.swift create mode 100644 Discovery/Discovery/Presentation/UpdateViews/UpdateRequiredView.swift create mode 100644 OpenEdX/View/MainScreenViewModel.swift diff --git a/Authorization/Authorization/Presentation/AuthorizationRouter.swift b/Authorization/Authorization/Presentation/AuthorizationRouter.swift index 925e0a358..2d41d7683 100644 --- a/Authorization/Authorization/Presentation/AuthorizationRouter.swift +++ b/Authorization/Authorization/Presentation/AuthorizationRouter.swift @@ -9,13 +9,14 @@ import Foundation import Core //sourcery: AutoMockable -public protocol AuthorizationRouter: BaseRouter {} +public protocol AuthorizationRouter: BaseRouter { + func showUpdateRequiredView(showAccountLink: Bool) +} // Mark - For testing and SwiftUI preview #if DEBUG public class AuthorizationRouterMock: BaseRouterMock, AuthorizationRouter { - public override init() {} - + public func showUpdateRequiredView(showAccountLink: Bool) {} } #endif diff --git a/Authorization/Authorization/Presentation/Login/SignInView.swift b/Authorization/Authorization/Presentation/Login/SignInView.swift index 3bdab24ca..8ad4a9949 100644 --- a/Authorization/Authorization/Presentation/Login/SignInView.swift +++ b/Authorization/Authorization/Presentation/Login/SignInView.swift @@ -165,7 +165,8 @@ struct SignInView_Previews: PreviewProvider { static var previews: some View { let vm = SignInViewModel( interactor: AuthInteractor.mock, - router: AuthorizationRouterMock(), + router: AuthorizationRouterMock(), + config: ConfigMock(), analytics: AuthorizationAnalyticsMock(), validator: Validator() ) diff --git a/Authorization/Authorization/Presentation/Login/SignInViewModel.swift b/Authorization/Authorization/Presentation/Login/SignInViewModel.swift index c97689735..86b3f32ef 100644 --- a/Authorization/Authorization/Presentation/Login/SignInViewModel.swift +++ b/Authorization/Authorization/Presentation/Login/SignInViewModel.swift @@ -31,7 +31,7 @@ public class SignInViewModel: ObservableObject { } let router: AuthorizationRouter - + private let config: Config private let interactor: AuthInteractorProtocol private let analytics: AuthorizationAnalytics private let validator: Validator @@ -39,11 +39,13 @@ public class SignInViewModel: ObservableObject { public init( interactor: AuthInteractorProtocol, router: AuthorizationRouter, + config: Config, analytics: AuthorizationAnalytics, validator: Validator ) { self.interactor = interactor self.router = router + self.config = config self.analytics = analytics self.validator = validator } @@ -67,8 +69,10 @@ public class SignInViewModel: ObservableObject { router.showMainOrWhatsNewScreen() } catch let error { isShowProgress = false - if let validationError = error.validationError, - let value = validationError.data?["error_description"] as? String { + if error.isUpdateRequeiredError { + router.showUpdateRequiredView(showAccountLink: false) + } else if let validationError = error.validationError, + let value = validationError.data?["error_description"] as? String { errorMessage = value } else if case APIError.invalidGrant = error { errorMessage = CoreLocalization.Error.invalidCredentials diff --git a/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift b/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift index e882311dd..3cc1e16fd 100644 --- a/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift +++ b/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift @@ -71,6 +71,8 @@ public class SignUpViewModel: ObservableObject { isShowProgress = false if error.isInternetError { errorMessage = CoreLocalization.Error.slowOrNoInternetConnection + } else if error.isUpdateRequeiredError { + router.showUpdateRequiredView(showAccountLink: false) } else { errorMessage = CoreLocalization.Error.unknownError } diff --git a/Authorization/AuthorizationTests/AuthorizationMock.generated.swift b/Authorization/AuthorizationTests/AuthorizationMock.generated.swift index 7c0c4707d..2afc95ada 100644 --- a/Authorization/AuthorizationTests/AuthorizationMock.generated.swift +++ b/Authorization/AuthorizationTests/AuthorizationMock.generated.swift @@ -731,6 +731,12 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { + open func showUpdateRequiredView(showAccountLink: Bool) { + addInvocation(.m_showUpdateRequiredView__showAccountLink_showAccountLink(Parameter.value(`showAccountLink`))) + let perform = methodPerformValue(.m_showUpdateRequiredView__showAccountLink_showAccountLink(Parameter.value(`showAccountLink`))) as? (Bool) -> Void + perform?(`showAccountLink`) + } + open func backToRoot(animated: Bool) { addInvocation(.m_backToRoot__animated_animated(Parameter.value(`animated`))) let perform = methodPerformValue(.m_backToRoot__animated_animated(Parameter.value(`animated`))) as? (Bool) -> Void @@ -811,6 +817,7 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { fileprivate enum MethodType { + case m_showUpdateRequiredView__showAccountLink_showAccountLink(Parameter) case m_backToRoot__animated_animated(Parameter) case m_back__animated_animated(Parameter) case m_backWithFade @@ -827,6 +834,11 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { switch (lhs, rhs) { + case (.m_showUpdateRequiredView__showAccountLink_showAccountLink(let lhsShowaccountlink), .m_showUpdateRequiredView__showAccountLink_showAccountLink(let rhsShowaccountlink)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsShowaccountlink, rhs: rhsShowaccountlink, with: matcher), lhsShowaccountlink, rhsShowaccountlink, "showAccountLink")) + return Matcher.ComparisonResult(results) + case (.m_backToRoot__animated_animated(let lhsAnimated), .m_backToRoot__animated_animated(let rhsAnimated)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAnimated, rhs: rhsAnimated, with: matcher), lhsAnimated, rhsAnimated, "animated")) @@ -896,6 +908,7 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { func intValue() -> Int { switch self { + case let .m_showUpdateRequiredView__showAccountLink_showAccountLink(p0): return p0.intValue case let .m_backToRoot__animated_animated(p0): return p0.intValue case let .m_back__animated_animated(p0): return p0.intValue case .m_backWithFade: return 0 @@ -913,6 +926,7 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { } func assertionName() -> String { switch self { + case .m_showUpdateRequiredView__showAccountLink_showAccountLink: return ".showUpdateRequiredView(showAccountLink:)" case .m_backToRoot__animated_animated: return ".backToRoot(animated:)" case .m_back__animated_animated: return ".back(animated:)" case .m_backWithFade: return ".backWithFade()" @@ -944,6 +958,7 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { public struct Verify { fileprivate var method: MethodType + public static func showUpdateRequiredView(showAccountLink: Parameter) -> Verify { return Verify(method: .m_showUpdateRequiredView__showAccountLink_showAccountLink(`showAccountLink`))} public static func backToRoot(animated: Parameter) -> Verify { return Verify(method: .m_backToRoot__animated_animated(`animated`))} public static func back(animated: Parameter) -> Verify { return Verify(method: .m_back__animated_animated(`animated`))} public static func backWithFade() -> Verify { return Verify(method: .m_backWithFade)} @@ -963,6 +978,9 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { fileprivate var method: MethodType var performs: Any + public static func showUpdateRequiredView(showAccountLink: Parameter, perform: @escaping (Bool) -> Void) -> Perform { + return Perform(method: .m_showUpdateRequiredView__showAccountLink_showAccountLink(`showAccountLink`), performs: perform) + } public static func backToRoot(animated: Parameter, perform: @escaping (Bool) -> Void) -> Perform { return Perform(method: .m_backToRoot__animated_animated(`animated`), performs: perform) } diff --git a/Authorization/AuthorizationTests/Presentation/Login/SignInViewModelTests.swift b/Authorization/AuthorizationTests/Presentation/Login/SignInViewModelTests.swift index aec540570..036da9d10 100644 --- a/Authorization/AuthorizationTests/Presentation/Login/SignInViewModelTests.swift +++ b/Authorization/AuthorizationTests/Presentation/Login/SignInViewModelTests.swift @@ -29,7 +29,8 @@ final class SignInViewModelTests: XCTestCase { let analytics = AuthorizationAnalyticsMock() let viewModel = SignInViewModel( interactor: interactor, - router: router, + router: router, + config: ConfigMock(), analytics: analytics, validator: validator ) @@ -51,6 +52,7 @@ final class SignInViewModelTests: XCTestCase { let viewModel = SignInViewModel( interactor: interactor, router: router, + config: ConfigMock(), analytics: analytics, validator: validator ) @@ -71,6 +73,7 @@ final class SignInViewModelTests: XCTestCase { let viewModel = SignInViewModel( interactor: interactor, router: router, + config: ConfigMock(), analytics: analytics, validator: validator ) @@ -96,6 +99,7 @@ final class SignInViewModelTests: XCTestCase { let viewModel = SignInViewModel( interactor: interactor, router: router, + config: ConfigMock(), analytics: analytics, validator: validator ) @@ -123,6 +127,7 @@ final class SignInViewModelTests: XCTestCase { let viewModel = SignInViewModel( interactor: interactor, router: router, + config: ConfigMock(), analytics: analytics, validator: validator ) @@ -146,6 +151,7 @@ final class SignInViewModelTests: XCTestCase { let viewModel = SignInViewModel( interactor: interactor, router: router, + config: ConfigMock(), analytics: analytics, validator: validator ) @@ -169,6 +175,7 @@ final class SignInViewModelTests: XCTestCase { let viewModel = SignInViewModel( interactor: interactor, router: router, + config: ConfigMock(), analytics: analytics, validator: validator ) @@ -194,6 +201,7 @@ final class SignInViewModelTests: XCTestCase { let viewModel = SignInViewModel( interactor: interactor, router: router, + config: ConfigMock(), analytics: analytics, validator: validator ) @@ -211,6 +219,7 @@ final class SignInViewModelTests: XCTestCase { let viewModel = SignInViewModel( interactor: interactor, router: router, + config: ConfigMock(), analytics: analytics, validator: validator ) diff --git a/Core/Core/Assets.xcassets/warning_filled.imageset/Contents.json b/Core/Core/Assets.xcassets/warning_filled.imageset/Contents.json new file mode 100644 index 000000000..1f83277d1 --- /dev/null +++ b/Core/Core/Assets.xcassets/warning_filled.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "warning_filled.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Core/Core/Assets.xcassets/warning_filled.imageset/warning_filled.svg b/Core/Core/Assets.xcassets/warning_filled.imageset/warning_filled.svg new file mode 100644 index 000000000..3ff3fdfec --- /dev/null +++ b/Core/Core/Assets.xcassets/warning_filled.imageset/warning_filled.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Core/Core/Configuration/Config.swift b/Core/Core/Configuration/Config.swift index 4cb9f3afc..8263f5c16 100644 --- a/Core/Core/Configuration/Config.swift +++ b/Core/Core/Configuration/Config.swift @@ -23,6 +23,10 @@ public class Config { public let feedbackEmail = "support@example.com" + private let appStoreId = "0000000000" + public var appStoreLink: String { + "itms-apps://itunes.apple.com/app/id\(appStoreId)?mt=8" + } public let whatsNewEnabled: Bool = false public init(baseURL: String, oAuthClientId: String) { diff --git a/Core/Core/Domain/Model/UserProfile.swift b/Core/Core/Domain/Model/UserProfile.swift index 0d8ca7e2c..03b19990a 100644 --- a/Core/Core/Domain/Model/UserProfile.swift +++ b/Core/Core/Domain/Model/UserProfile.swift @@ -39,4 +39,16 @@ public struct UserProfile: Hashable { self.shortBiography = shortBiography self.isFullProfile = isFullProfile } + + public init() { + self.avatarUrl = "" + self.name = "" + self.username = "" + self.dateJoined = Date() + self.yearOfBirth = 0 + self.country = "" + self.spokenLanguage = "" + self.shortBiography = "" + self.isFullProfile = true + } } diff --git a/Core/Core/Extensions/Notification.swift b/Core/Core/Extensions/Notification.swift index fd12e5aab..1a745398a 100644 --- a/Core/Core/Extensions/Notification.swift +++ b/Core/Core/Extensions/Notification.swift @@ -10,4 +10,7 @@ import Foundation public extension Notification.Name { static let onCourseEnrolled = Notification.Name("onCourseEnrolled") static let onTokenRefreshFailed = Notification.Name("onTokenRefreshFailed") + static let onActualVersionReceived = Notification.Name("onActualVersionReceived") + static let onAppUpgradeAccountSettingsTapped = Notification.Name("onAppUpgradeAccountSettingsTapped") + static let onNewVersionAvaliable = Notification.Name("onNewVersionAvaliable") } diff --git a/Core/Core/Network/API.swift b/Core/Core/Network/API.swift index d891c7af5..1142d85ce 100644 --- a/Core/Core/Network/API.swift +++ b/Core/Core/Network/API.swift @@ -65,13 +65,25 @@ public final class API { if !route.path.isEmpty { url = url.appendingPathComponent(route.path) } - return try await session.request( + + let result = session.request( url, method: route.httpMethod, parameters: parameters, encoding: encoding, headers: route.headers - ).validateResponse().serializingData().value + ).validateResponse().serializingData() + + let latestVersion = await result.response.response?.headers["EDX-APP-LATEST-VERSION"] + + if await result.response.response?.statusCode != 426 { + if let latestVersion = latestVersion { + NotificationCenter.default.post(name: .onActualVersionReceived, object: latestVersion) + } + } + + return try await result.value + } private func callCookies( diff --git a/Core/Core/Network/Alamofire+Error.swift b/Core/Core/Network/Alamofire+Error.swift index 8984b36df..277a79fed 100644 --- a/Core/Core/Network/Alamofire+Error.swift +++ b/Core/Core/Network/Alamofire+Error.swift @@ -8,6 +8,10 @@ import Alamofire public extension Error { + var isUpdateRequeiredError: Bool { + self.asAFError?.responseCode == 426 + } + var isInternetError: Bool { guard let afError = self.asAFError, let urlError = afError.underlyingError as? URLError else { diff --git a/Core/Core/Network/RequestInterceptor.swift b/Core/Core/Network/RequestInterceptor.swift index 49f13a806..c2b0b00fd 100644 --- a/Core/Core/Network/RequestInterceptor.swift +++ b/Core/Core/Network/RequestInterceptor.swift @@ -38,6 +38,23 @@ final public class RequestInterceptor: Alamofire.RequestInterceptor { urlRequest.setValue("\(config.tokenType.rawValue) \(token)", forHTTPHeaderField: "Authorization") } + let userAgent: String = { + if let info = Bundle.main.infoDictionary { + let executable: AnyObject = info[kCFBundleExecutableKey as String] as AnyObject? ?? "Unknown" as AnyObject + let bundle: AnyObject = info[kCFBundleIdentifierKey as String] as AnyObject? ?? "Unknown" as AnyObject + let version: AnyObject = info["CFBundleShortVersionString"] as AnyObject? ?? "Unknown" as AnyObject + let os: AnyObject = ProcessInfo.processInfo.operatingSystemVersionString as AnyObject + var mutableUserAgent = NSMutableString(string: "\(executable)/\(bundle) (\(version); OS \(os))") as CFMutableString + let transform = NSString(string: "Any-Latin; Latin-ASCII; [:^ASCII:] Remove") as CFString + if CFStringTransform(mutableUserAgent, nil, transform, false) == true { + return mutableUserAgent as String + } + } + return "Alamofire" + }() + + urlRequest.setValue(userAgent, forHTTPHeaderField: "User-Agent") + completion(.success(urlRequest)) } diff --git a/Core/Core/SwiftGen/Assets.swift b/Core/Core/SwiftGen/Assets.swift index ee82da098..61c33b049 100644 --- a/Core/Core/SwiftGen/Assets.swift +++ b/Core/Core/SwiftGen/Assets.swift @@ -108,6 +108,7 @@ public enum CoreAssets { public static let noCourseImage = ImageAsset(name: "noCourseImage") public static let notAvaliable = ImageAsset(name: "notAvaliable") public static let playVideo = ImageAsset(name: "playVideo") + public static let warningFilled = ImageAsset(name: "warning_filled") } // swiftlint:enable identifier_name line_length nesting type_body_length type_name diff --git a/Discovery/Discovery.xcodeproj/project.pbxproj b/Discovery/Discovery.xcodeproj/project.pbxproj index 05974ad4f..88b0be486 100644 --- a/Discovery/Discovery.xcodeproj/project.pbxproj +++ b/Discovery/Discovery.xcodeproj/project.pbxproj @@ -14,6 +14,9 @@ 0283347928D49A8700C828FC /* DiscoveryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0283347828D49A8700C828FC /* DiscoveryViewModel.swift */; }; 0284DBFC28D4856A00830893 /* DiscoveryEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0284DBFB28D4856A00830893 /* DiscoveryEndpoint.swift */; }; 0284DC0328D4922900830893 /* DiscoveryRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0284DC0228D4922900830893 /* DiscoveryRepository.swift */; }; + 029242E72AE6978400A940EC /* UpdateRequiredView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029242E62AE6978400A940EC /* UpdateRequiredView.swift */; }; + 029242E92AE6A3AB00A940EC /* UpdateRecommendedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029242E82AE6A3AB00A940EC /* UpdateRecommendedView.swift */; }; + 029242EB2AE6AB7B00A940EC /* UpdateNotificationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029242EA2AE6AB7B00A940EC /* UpdateNotificationView.swift */; }; 029737402949FB070051696B /* DiscoveryCoreModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 0297373E2949FB070051696B /* DiscoveryCoreModel.xcdatamodeld */; }; 029737422949FB3B0051696B /* DiscoveryPersistenceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029737412949FB3B0051696B /* DiscoveryPersistenceProtocol.swift */; }; 02EF39D128D867690058F6BD /* swiftgen.yml in Resources */ = {isa = PBXBuildFile; fileRef = 02EF39D028D867690058F6BD /* swiftgen.yml */; }; @@ -49,6 +52,9 @@ 0283347828D49A8700C828FC /* DiscoveryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryViewModel.swift; sourceTree = ""; }; 0284DBFB28D4856A00830893 /* DiscoveryEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryEndpoint.swift; sourceTree = ""; }; 0284DC0228D4922900830893 /* DiscoveryRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryRepository.swift; sourceTree = ""; }; + 029242E62AE6978400A940EC /* UpdateRequiredView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateRequiredView.swift; sourceTree = ""; }; + 029242E82AE6A3AB00A940EC /* UpdateRecommendedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateRecommendedView.swift; sourceTree = ""; }; + 029242EA2AE6AB7B00A940EC /* UpdateNotificationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateNotificationView.swift; sourceTree = ""; }; 0297373F2949FB070051696B /* DiscoveryCoreModel.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = DiscoveryCoreModel.xcdatamodel; sourceTree = ""; }; 029737412949FB3B0051696B /* DiscoveryPersistenceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryPersistenceProtocol.swift; sourceTree = ""; }; 02ED50C729A649C9008341CD /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = ""; }; @@ -151,6 +157,16 @@ path = Domain; sourceTree = ""; }; + 029242E52AE6976E00A940EC /* UpdateViews */ = { + isa = PBXGroup; + children = ( + 029242E62AE6978400A940EC /* UpdateRequiredView.swift */, + 029242E82AE6A3AB00A940EC /* UpdateRecommendedView.swift */, + 029242EA2AE6AB7B00A940EC /* UpdateNotificationView.swift */, + ); + path = UpdateViews; + sourceTree = ""; + }; 02EF39CB28D866C50058F6BD /* SwiftGen */ = { isa = PBXGroup; children = ( @@ -162,6 +178,7 @@ 070019A228F6EF2700D5FC78 /* Presentation */ = { isa = PBXGroup; children = ( + 029242E52AE6976E00A940EC /* UpdateViews */, 072787B328D34D91002E9142 /* DiscoveryView.swift */, 0283347828D49A8700C828FC /* DiscoveryViewModel.swift */, CFC849422996A5150055E497 /* SearchView.swift */, @@ -459,15 +476,18 @@ buildActionMask = 2147483647; files = ( CFC849452996A52A0055E497 /* SearchViewModel.swift in Sources */, + 029242E92AE6A3AB00A940EC /* UpdateRecommendedView.swift in Sources */, CFC849432996A5150055E497 /* SearchView.swift in Sources */, 0284DBFC28D4856A00830893 /* DiscoveryEndpoint.swift in Sources */, 029737402949FB070051696B /* DiscoveryCoreModel.xcdatamodeld in Sources */, + 029242E72AE6978400A940EC /* UpdateRequiredView.swift in Sources */, 0283347728D499BC00C828FC /* DiscoveryInteractor.swift in Sources */, 02F3BFDF29252F2F0051930C /* DiscoveryRouter.swift in Sources */, 0283347928D49A8700C828FC /* DiscoveryViewModel.swift in Sources */, 072787B428D34D91002E9142 /* DiscoveryView.swift in Sources */, 029737422949FB3B0051696B /* DiscoveryPersistenceProtocol.swift in Sources */, 0284DC0328D4922900830893 /* DiscoveryRepository.swift in Sources */, + 029242EB2AE6AB7B00A940EC /* UpdateNotificationView.swift in Sources */, 02EF39DC28D86BEF0058F6BD /* Strings.swift in Sources */, 02F1752F2A4DA3B60019CD70 /* DiscoveryAnalytics.swift in Sources */, ); diff --git a/Discovery/Discovery/Presentation/DiscoveryRouter.swift b/Discovery/Discovery/Presentation/DiscoveryRouter.swift index 8a0b68e14..61fc564d5 100644 --- a/Discovery/Discovery/Presentation/DiscoveryRouter.swift +++ b/Discovery/Discovery/Presentation/DiscoveryRouter.swift @@ -12,6 +12,8 @@ public protocol DiscoveryRouter: BaseRouter { func showCourseDetais(courseID: String, title: String) func showDiscoverySearch() + func showUpdateRequiredView(showAccountLink: Bool) + func showUpdateRecomendedView() } // Mark - For testing and SwiftUI preview @@ -22,6 +24,7 @@ public class DiscoveryRouterMock: BaseRouterMock, DiscoveryRouter { public func showCourseDetais(courseID: String, title: String) {} public func showDiscoverySearch() {} - + public func showUpdateRequiredView(showAccountLink: Bool) {} + public func showUpdateRecomendedView() {} } #endif diff --git a/Discovery/Discovery/Presentation/DiscoveryView.swift b/Discovery/Discovery/Presentation/DiscoveryView.swift index 8ca698572..2abf4dd78 100644 --- a/Discovery/Discovery/Presentation/DiscoveryView.swift +++ b/Discovery/Discovery/Presentation/DiscoveryView.swift @@ -12,7 +12,6 @@ public struct DiscoveryView: View { @StateObject private var viewModel: DiscoveryViewModel - private let router: DiscoveryRouter @State private var isRefreshing: Bool = false private let discoveryNew: some View = VStack(alignment: .leading) { @@ -24,9 +23,8 @@ public struct DiscoveryView: View { .foregroundColor(Theme.Colors.textPrimary) }.listRowBackground(Color.clear) - public init(viewModel: DiscoveryViewModel, router: DiscoveryRouter) { + public init(viewModel: DiscoveryViewModel) { self._viewModel = StateObject(wrappedValue: { viewModel }()) - self.router = router } public var body: some View { @@ -45,7 +43,7 @@ public struct DiscoveryView: View { Spacer() } .onTapGesture { - router.showDiscoverySearch() + viewModel.router.showDiscoverySearch() viewModel.discoverySearchBarClicked() } .frame(minHeight: 48) @@ -59,7 +57,7 @@ public struct DiscoveryView: View { .stroke(lineWidth: 1) .fill(Theme.Colors.textInputUnfocusedStroke) ).onTapGesture { - router.showDiscoverySearch() + viewModel.router.showDiscoverySearch() viewModel.discoverySearchBarClicked() } .padding(.horizontal, 24) @@ -72,7 +70,7 @@ public struct DiscoveryView: View { Task { await viewModel.discovery(page: 1, withProgress: false) } - }) { + }) { LazyVStack(spacing: 0) { HStack { discoveryNew @@ -97,7 +95,7 @@ public struct DiscoveryView: View { courseID: course.courseID, courseName: course.name ) - router.showCourseDetais( + viewModel.router.showCourseDetais( courseID: course.courseID, title: course.name ) @@ -145,6 +143,7 @@ public struct DiscoveryView: View { Task { await viewModel.discovery(page: 1) } + viewModel.setupNotifications() } .background(Theme.Colors.background.ignoresSafeArea()) } @@ -153,15 +152,18 @@ public struct DiscoveryView: View { #if DEBUG struct DiscoveryView_Previews: PreviewProvider { static var previews: some View { - let vm = DiscoveryViewModel(interactor: DiscoveryInteractor.mock, connectivity: Connectivity(), + let vm = DiscoveryViewModel(router: DiscoveryRouterMock(), + config: ConfigMock(), + interactor: DiscoveryInteractor.mock, + connectivity: Connectivity(), analytics: DiscoveryAnalyticsMock()) let router = DiscoveryRouterMock() - DiscoveryView(viewModel: vm, router: router) + DiscoveryView(viewModel: vm) .preferredColorScheme(.light) .previewDisplayName("DiscoveryView Light") - DiscoveryView(viewModel: vm, router: router) + DiscoveryView(viewModel: vm) .preferredColorScheme(.dark) .previewDisplayName("DiscoveryView Dark") } diff --git a/Discovery/Discovery/Presentation/DiscoveryViewModel.swift b/Discovery/Discovery/Presentation/DiscoveryViewModel.swift index 37514275b..18391bcc5 100644 --- a/Discovery/Discovery/Presentation/DiscoveryViewModel.swift +++ b/Discovery/Discovery/Presentation/DiscoveryViewModel.swift @@ -5,15 +5,17 @@ // Created by  Stepanok Ivan on 16.09.2022. // -import Foundation +import Combine import Core import SwiftUI public class DiscoveryViewModel: ObservableObject { - public var nextPage = 1 - public var totalPages = 1 - public private(set) var fetchInProgress = false + var nextPage = 1 + var totalPages = 1 + private(set) var fetchInProgress = false + private var cancellables = Set() + private var updateShowedOnce: Bool = false @Published var courses: [CourseItem] = [] @Published var showError: Bool = false @@ -26,15 +28,21 @@ public class DiscoveryViewModel: ObservableObject { } } + let router: DiscoveryRouter + let config: Config let connectivity: ConnectivityProtocol private let interactor: DiscoveryInteractorProtocol private let analytics: DiscoveryAnalytics public init( + router: DiscoveryRouter, + config: Config, interactor: DiscoveryInteractorProtocol, connectivity: ConnectivityProtocol, analytics: DiscoveryAnalytics ) { + self.router = router + self.config = config self.interactor = interactor self.connectivity = connectivity self.analytics = analytics @@ -55,6 +63,29 @@ public class DiscoveryViewModel: ObservableObject { } } + func setupNotifications() { + NotificationCenter.default.publisher(for: .onActualVersionReceived) + .sink { [weak self] notification in + if let latestVersion = notification.object as? String { + if let info = Bundle.main.infoDictionary { + guard let currentVersion = info["CFBundleShortVersionString"] as? String, + let self else { return } + switch self.compareVersions(currentVersion, latestVersion) { + case .orderedAscending: + if self.updateShowedOnce == false { + DispatchQueue.main.async { + self.router.showUpdateRecomendedView() + } + self.updateShowedOnce = true + } + default: + return + } + } + } + }.store(in: &cancellables) + } + @MainActor func discovery(page: Int, withProgress: Bool = true) async { fetchInProgress = withProgress @@ -82,6 +113,8 @@ public class DiscoveryViewModel: ObservableObject { fetchInProgress = false if error.isInternetError || error is NoCachedDataError { errorMessage = CoreLocalization.Error.slowOrNoInternetConnection + } else if error.isUpdateRequeiredError { + self.router.showUpdateRequiredView(showAccountLink: true) } else { errorMessage = CoreLocalization.Error.unknownError } @@ -95,4 +128,30 @@ public class DiscoveryViewModel: ObservableObject { func discoverySearchBarClicked() { analytics.discoverySearchBarClicked() } + + private func compareVersions(_ version1: String, _ version2: String) -> ComparisonResult { + let components1 = version1.components(separatedBy: ".").prefix(2) + let components2 = version2.components(separatedBy: ".").prefix(2) + + guard let major1 = Int(components1.first ?? ""), + let minor1 = Int(components1.last ?? ""), + let major2 = Int(components2.first ?? ""), + let minor2 = Int(components2.last ?? "") else { + return .orderedSame + } + + if major1 < major2 { + return .orderedAscending + } else if major1 > major2 { + return .orderedDescending + } else { + if minor1 < minor2 { + return .orderedAscending + } else if minor1 > minor2 { + return .orderedDescending + } else { + return .orderedSame + } + } + } } diff --git a/Discovery/Discovery/Presentation/UpdateViews/UpdateNotificationView.swift b/Discovery/Discovery/Presentation/UpdateViews/UpdateNotificationView.swift new file mode 100644 index 000000000..83098abf7 --- /dev/null +++ b/Discovery/Discovery/Presentation/UpdateViews/UpdateNotificationView.swift @@ -0,0 +1,60 @@ +// +// UpdateNotificationView.swift +// Discovery +// +// Created by  Stepanok Ivan on 23.10.2023. +// + +import SwiftUI +import Core + +public struct UpdateNotificationView: View { + + private let config: Config + + public init(config: Config) { + self.config = config + } + + public var body: some View { + ZStack { + VStack { + Spacer() + HStack(spacing: 10) { + Image(systemName: "arrow.up.circle") + .resizable() + .frame(width: 36, + height: 36) + .foregroundColor(.white) + VStack(alignment: .leading) { + Text(DiscoveryLocalization.updateNeededTitle) + .font(Theme.Fonts.titleMedium) + Text(DiscoveryLocalization.updateNewAvaliable) + .font(Theme.Fonts.bodySmall) + }.foregroundColor(.white) + Spacer() + } + .padding(16) + .background(Theme.Colors.accentColor) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .shadow(color: Color.black.opacity(0.4), radius: 12, x: 0, y: 0) + .padding(24) + + } + }.onTapGesture { + openAppStore() + } + } + private func openAppStore() { + guard let appStoreURL = URL(string: config.appStoreLink) else { return } + UIApplication.shared.open(appStoreURL) + } +} + +#if DEBUG +struct UpdateNotificationView_Previews: PreviewProvider { + static var previews: some View { + UpdateNotificationView(config: ConfigMock()) + } +} +#endif diff --git a/Discovery/Discovery/Presentation/UpdateViews/UpdateRecommendedView.swift b/Discovery/Discovery/Presentation/UpdateViews/UpdateRecommendedView.swift new file mode 100644 index 000000000..5059707d7 --- /dev/null +++ b/Discovery/Discovery/Presentation/UpdateViews/UpdateRecommendedView.swift @@ -0,0 +1,85 @@ +// +// UpdateRecommendedView.swift +// Discovery +// +// Created by  Stepanok Ivan on 23.10.2023. +// + +import SwiftUI +import Core + +public struct UpdateRecommendedView: View { + + @Environment (\.isHorizontal) private var isHorizontal + private let router: DiscoveryRouter + private let config: Config + + public init(router: DiscoveryRouter, config: Config) { + self.router = router + self.config = config + } + + public var body: some View { + ZStack { + Color.black.opacity(0.5) + .ignoresSafeArea() + .onTapGesture { + router.dismiss(animated: true) + NotificationCenter.default.post(name: .onNewVersionAvaliable, object: nil) + } + VStack(spacing: 10) { + Image(systemName: "arrow.up.circle") + .resizable() + .frame(width: isHorizontal ? 50 : 110, + height: isHorizontal ? 50 : 110) + .foregroundColor(Theme.Colors.accentColor) + .padding(.bottom, isHorizontal ? 0 : 20) + Text(DiscoveryLocalization.updateNeededTitle) + .font(Theme.Fonts.titleMedium) + Text(DiscoveryLocalization.updateNeededDescription) + .font(Theme.Fonts.titleSmall) + .foregroundColor(Theme.Colors.avatarStroke) + .multilineTextAlignment(.center) + + HStack(spacing: 28) { + Button(action: { + router.dismiss(animated: true) + NotificationCenter.default.post(name: .onNewVersionAvaliable, object: nil) + }, label: { + HStack { + Text(DiscoveryLocalization.updateNeededNotNow) + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.accentColor) + }.padding(8) + }) + + StyledButton(DiscoveryLocalization.updateButton, action: { + openAppStore() + }).fixedSize() + }.padding(.top, isHorizontal ? 0 : 44) + + }.padding(isHorizontal ? 40 : 40) + .background(Theme.Colors.background) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .frame(maxWidth: 400, maxHeight: 400) + .padding(24) + .shadow(color: Color.black.opacity(0.4), radius: 12, x: 0, y: 0) + }.navigationTitle(DiscoveryLocalization.updateDeprecatedApp) + } + + private func openAppStore() { + guard let appStoreURL = URL(string: config.appStoreLink) else { return } + UIApplication.shared.open(appStoreURL) + } +} + +#if DEBUG +struct UpdateRecommendedView_Previews: PreviewProvider { + static var previews: some View { + UpdateRecommendedView( + router: DiscoveryRouterMock(), + config: ConfigMock() + ) + } +} +#endif diff --git a/Discovery/Discovery/Presentation/UpdateViews/UpdateRequiredView.swift b/Discovery/Discovery/Presentation/UpdateViews/UpdateRequiredView.swift new file mode 100644 index 000000000..9f121b944 --- /dev/null +++ b/Discovery/Discovery/Presentation/UpdateViews/UpdateRequiredView.swift @@ -0,0 +1,76 @@ +// +// UpdateRequiredView.swift +// Discovery +// +// Created by  Stepanok Ivan on 23.10.2023. +// + +import SwiftUI +import Core + +public struct UpdateRequiredView: View { + + @Environment (\.isHorizontal) private var isHorizontal + private let router: DiscoveryRouter + private let config: Config + private let showAccountLink: Bool + + public init(router: DiscoveryRouter, config: Config, showAccountLink: Bool = true) { + self.router = router + self.config = config + self.showAccountLink = showAccountLink + } + + public var body: some View { + ZStack { + VStack(spacing: 10) { + CoreAssets.warningFilled.swiftUIImage + .resizable() + .frame(width: isHorizontal ? 50 : 110, + height: isHorizontal ? 50 : 110) + Text(DiscoveryLocalization.updateRequiredTitle) + .font(Theme.Fonts.titleMedium) + Text(DiscoveryLocalization.updateRequiredDescription) + .font(Theme.Fonts.titleSmall) + .multilineTextAlignment(.center) + + HStack(spacing: 28) { + if showAccountLink { + Button(action: { + NotificationCenter.default.post(name: .onAppUpgradeAccountSettingsTapped, object: "block") + router.back(animated: false) + }, label: { + HStack { + Text(DiscoveryLocalization.updateAccountSettings) + .font(Theme.Fonts.labelLarge) + }.padding(8) + }) + } + StyledButton(DiscoveryLocalization.updateButton, action: { + openAppStore() + }).fixedSize() + }.padding(.top, isHorizontal ? 10 : 44) + + }.padding(40) + .frame(maxWidth: 400) + }.navigationTitle(DiscoveryLocalization.updateDeprecatedApp) + .navigationBarBackButtonHidden() + } + + private func openAppStore() { + guard let appStoreURL = URL(string: config.appStoreLink) else { return } + UIApplication.shared.open(appStoreURL) + } +} + +#if DEBUG +struct UpdateRequiredView_Previews: PreviewProvider { + static var previews: some View { + UpdateRequiredView( + router: DiscoveryRouterMock(), + config: ConfigMock() + ) + .loadFonts() + } +} +#endif diff --git a/Discovery/Discovery/SwiftGen/Strings.swift b/Discovery/Discovery/SwiftGen/Strings.swift index b3abc48df..c53a55352 100644 --- a/Discovery/Discovery/SwiftGen/Strings.swift +++ b/Discovery/Discovery/SwiftGen/Strings.swift @@ -21,6 +21,26 @@ public enum DiscoveryLocalization { /// /// Created by  Stepanok Ivan on 19.09.2022. public static let title = DiscoveryLocalization.tr("Localizable", "TITLE", fallback: "Discover") + /// Account Settings + public static let updateAccountSettings = DiscoveryLocalization.tr("Localizable", "UPDATE_ACCOUNT_SETTINGS", fallback: "Account Settings") + /// Update + public static let updateButton = DiscoveryLocalization.tr("Localizable", "UPDATE_BUTTON", fallback: "Update") + /// Deprecated App Version + public static let updateDeprecatedApp = DiscoveryLocalization.tr("Localizable", "UPDATE_DEPRECATED_APP", fallback: "Deprecated App Version") + /// We recommend that you update to the latest version. Upgrade now to receive the latest features and fixes. + public static let updateNeededDescription = DiscoveryLocalization.tr("Localizable", "UPDATE_NEEDED_DESCRIPTION", fallback: "We recommend that you update to the latest version. Upgrade now to receive the latest features and fixes.") + /// Not Now + public static let updateNeededNotNow = DiscoveryLocalization.tr("Localizable", "UPDATE_NEEDED_NOT_NOW", fallback: "Not Now") + /// App Update + public static let updateNeededTitle = DiscoveryLocalization.tr("Localizable", "UPDATE_NEEDED_TITLE", fallback: "App Update") + /// New update available! Upgrade now to receive the latest features and fixes + public static let updateNewAvaliable = DiscoveryLocalization.tr("Localizable", "UPDATE_NEW_AVALIABLE", fallback: "New update available! Upgrade now to receive the latest features and fixes") + /// This version of the OpenEdX app is out-of-date. To continue learning and get the latest features and fixes, please upgrade to the latest version. + public static let updateRequiredDescription = DiscoveryLocalization.tr("Localizable", "UPDATE_REQUIRED_DESCRIPTION", fallback: "This version of the OpenEdX app is out-of-date. To continue learning and get the latest features and fixes, please upgrade to the latest version.") + /// App Update Required + public static let updateRequiredTitle = DiscoveryLocalization.tr("Localizable", "UPDATE_REQUIRED_TITLE", fallback: "App Update Required") + /// Why do I need to update? + public static let updateWhyNeed = DiscoveryLocalization.tr("Localizable", "UPDATE_WHY_NEED", fallback: "Why do I need to update?") public enum Header { /// Discover new public static let title1 = DiscoveryLocalization.tr("Localizable", "HEADER.TITLE_1", fallback: "Discover new") diff --git a/Discovery/Discovery/en.lproj/Localizable.strings b/Discovery/Discovery/en.lproj/Localizable.strings index d5502912c..074eb6cbd 100644 --- a/Discovery/Discovery/en.lproj/Localizable.strings +++ b/Discovery/Discovery/en.lproj/Localizable.strings @@ -13,3 +13,16 @@ "SEARCH.TITLE" = "Search results"; "SEARCH.EMPTY_DESCRIPTION" = "Start typing to find the course"; + +"UPDATE_REQUIRED_TITLE" = "App Update Required"; +"UPDATE_REQUIRED_DESCRIPTION" = "This version of the OpenEdX app is out-of-date. To continue learning and get the latest features and fixes, please upgrade to the latest version."; +"UPDATE_WHY_NEED" = "Why do I need to update?"; +"UPDATE_DEPRECATED_APP" = "Deprecated App Version"; +"UPDATE_BUTTON" = "Update"; +"UPDATE_ACCOUNT_SETTINGS" = "Account Settings"; + +"UPDATE_NEEDED_TITLE" = "App Update"; +"UPDATE_NEEDED_DESCRIPTION" = "We recommend that you update to the latest version. Upgrade now to receive the latest features and fixes."; +"UPDATE_NEEDED_NOT_NOW" = "Not Now"; + +"UPDATE_NEW_AVALIABLE" = "New update available! Upgrade now to receive the latest features and fixes"; diff --git a/Discovery/Discovery/uk.lproj/Localizable.strings b/Discovery/Discovery/uk.lproj/Localizable.strings index 8f5218a53..fd43d1635 100644 --- a/Discovery/Discovery/uk.lproj/Localizable.strings +++ b/Discovery/Discovery/uk.lproj/Localizable.strings @@ -13,3 +13,16 @@ "SEARCH.TITLE" = "Результати пошуку"; "SEARCH.EMPTY_DESCRIPTION" = "Почніть вводити текст, щоб знайти курс"; + +"UPDATE_REQUIRED_TITLE" = "Потрібне оновлення додатка"; +"UPDATE_REQUIRED_DESCRIPTION" = "Ця версія додатка OpenEdX застаріла. Щоб продовжити навчання та отримати останні функції та виправлення, оновіться до останньої версії."; +"UPDATE_WHY_NEED" = "Чому я маю оновити програму?"; +"UPDATE_DEPRECATED_APP" = "Застаріла версія додатка"; +"UPDATE_BUTTON" = "Оновити"; +"UPDATE_ACCOUNT_SETTINGS" = "Налаштування"; + +"UPDATE_NEEDED_TITLE" = "Оновлення додатку"; +"UPDATE_NEEDED_DESCRIPTION" = "Ми рекомендуємо вам оновити додаток до останньої версії. Оновіть зараз, щоб отримати нові функції та виправлення."; +"UPDATE_NEEDED_NOT_NOW" = "Не зараз"; + +"UPDATE_NEW_AVALIABLE" = "Доступне нове оновлення! Оновіть зараз, щоб отримати найновіші функції та виправлення"; diff --git a/Discovery/DiscoveryTests/Presentation/DiscoveryViewModelTests.swift b/Discovery/DiscoveryTests/Presentation/DiscoveryViewModelTests.swift index a31924505..01ed44919 100644 --- a/Discovery/DiscoveryTests/Presentation/DiscoveryViewModelTests.swift +++ b/Discovery/DiscoveryTests/Presentation/DiscoveryViewModelTests.swift @@ -26,7 +26,11 @@ final class DiscoveryViewModelTests: XCTestCase { let interactor = DiscoveryInteractorProtocolMock() let connectivity = Connectivity() let analytics = DiscoveryAnalyticsMock() - let viewModel = DiscoveryViewModel(interactor: interactor, connectivity: connectivity, analytics: analytics) + let viewModel = DiscoveryViewModel(router: DiscoveryRouterMock(), + config: ConfigMock(), + interactor: interactor, + connectivity: connectivity, + analytics: analytics) let items = [ CourseItem(name: "Test", @@ -71,8 +75,11 @@ final class DiscoveryViewModelTests: XCTestCase { let interactor = DiscoveryInteractorProtocolMock() let connectivity = Connectivity() let analytics = DiscoveryAnalyticsMock() - let viewModel = DiscoveryViewModel(interactor: interactor, connectivity: connectivity, analytics: analytics) - + let viewModel = DiscoveryViewModel(router: DiscoveryRouterMock(), + config: ConfigMock(), + interactor: interactor, + connectivity: connectivity, + analytics: analytics) let items = [ CourseItem(name: "Test", org: "org", @@ -115,8 +122,11 @@ final class DiscoveryViewModelTests: XCTestCase { let interactor = DiscoveryInteractorProtocolMock() let connectivity = ConnectivityProtocolMock() let analytics = DiscoveryAnalyticsMock() - let viewModel = DiscoveryViewModel(interactor: interactor, connectivity: connectivity, analytics: analytics) - + let viewModel = DiscoveryViewModel(router: DiscoveryRouterMock(), + config: ConfigMock(), + interactor: interactor, + connectivity: connectivity, + analytics: analytics) let items = [ CourseItem(name: "Test", org: "org", @@ -161,7 +171,11 @@ final class DiscoveryViewModelTests: XCTestCase { let interactor = DiscoveryInteractorProtocolMock() let connectivity = Connectivity() let analytics = DiscoveryAnalyticsMock() - let viewModel = DiscoveryViewModel(interactor: interactor, connectivity: connectivity, analytics: analytics) + let viewModel = DiscoveryViewModel(router: DiscoveryRouterMock(), + config: ConfigMock(), + interactor: interactor, + connectivity: connectivity, + analytics: analytics) let noInternetError = AFError.sessionInvalidated(error: URLError(.notConnectedToInternet)) @@ -180,7 +194,11 @@ final class DiscoveryViewModelTests: XCTestCase { let interactor = DiscoveryInteractorProtocolMock() let connectivity = Connectivity() let analytics = DiscoveryAnalyticsMock() - let viewModel = DiscoveryViewModel(interactor: interactor, connectivity: connectivity, analytics: analytics) + let viewModel = DiscoveryViewModel(router: DiscoveryRouterMock(), + config: ConfigMock(), + interactor: interactor, + connectivity: connectivity, + analytics: analytics) let noInternetError = AFError.sessionInvalidated(error: NSError()) diff --git a/OpenEdX.xcodeproj/project.pbxproj b/OpenEdX.xcodeproj/project.pbxproj index 08ffca434..d3b1013cc 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, ); }; }; + 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 */; }; 025DE1A528DB4DAE0053E0F4 /* Profile.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 025DE1A328DB4DAE0053E0F4 /* Profile.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -73,6 +74,7 @@ 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; }; 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 = ""; }; 025C77A028E463E900B3DFA3 /* CourseOutline.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = CourseOutline.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 025DE1A328DB4DAE0053E0F4 /* Profile.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Profile.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -153,6 +155,7 @@ isa = PBXGroup; children = ( 0727878D28D347C7002E9142 /* MainScreenView.swift */, + 024E691F2AEFC3FB00FA0B59 /* MainScreenViewModel.swift */, ); path = View; sourceTree = ""; @@ -390,6 +393,7 @@ 0293A2032A6FCA590090A336 /* CorePersistence.swift in Sources */, 0770DE1E28D084E8006D8A5D /* AppAssembly.swift in Sources */, 025AD4AC2A6FB95C00AB8FA7 /* DatabaseManager.swift in Sources */, + 024E69202AEFC3FB00FA0B59 /* MainScreenViewModel.swift in Sources */, 0770DE2028D0858A006D8A5D /* Router.swift in Sources */, 0727876D28D23312002E9142 /* Environment.swift in Sources */, 0293A2092A6FCDE50090A336 /* DashboardPersistence.swift in Sources */, @@ -521,7 +525,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.1; + MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.app.stage; PRODUCT_NAME = "$(TARGET_NAME) Stage"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -609,7 +613,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.1; + MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.app.stage; PRODUCT_NAME = "$(TARGET_NAME) Stage"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -703,7 +707,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.1; + MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.app.dev; PRODUCT_NAME = "$(TARGET_NAME) Dev"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -791,7 +795,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.1; + MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.app.dev; PRODUCT_NAME = "$(TARGET_NAME) Dev"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -939,7 +943,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.1; + MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.app; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -973,7 +977,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.1; + MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.app; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; diff --git a/OpenEdX/DI/ScreenAssembly.swift b/OpenEdX/DI/ScreenAssembly.swift index eb4e04396..33f396f20 100644 --- a/OpenEdX/DI/ScreenAssembly.swift +++ b/OpenEdX/DI/ScreenAssembly.swift @@ -33,11 +33,21 @@ class ScreenAssembly: Assembly { ) } + // MARK: MainScreenView + container.register(MainScreenViewModel.self) { r in + MainScreenViewModel( + analytics: r.resolve(MainScreenAnalytics.self)!, + config: r.resolve(Config.self)!, + profileInteractor: r.resolve(ProfileInteractorProtocol.self)! + ) + } + // MARK: SignIn container.register(SignInViewModel.self) { r in SignInViewModel( interactor: r.resolve(AuthInteractorProtocol.self)!, router: r.resolve(AuthorizationRouter.self)!, + config: r.resolve(Config.self)!, analytics: r.resolve(AuthorizationAnalytics.self)!, validator: r.resolve(Validator.self)! ) @@ -81,6 +91,8 @@ class ScreenAssembly: Assembly { } container.register(DiscoveryViewModel.self) { r in DiscoveryViewModel( + router: r.resolve(DiscoveryRouter.self)!, + config: r.resolve(Config.self)!, interactor: r.resolve(DiscoveryInteractorProtocol.self)!, connectivity: r.resolve(ConnectivityProtocol.self)!, analytics: r.resolve(DiscoveryAnalytics.self)! diff --git a/OpenEdX/MainScreenAnalytics.swift b/OpenEdX/MainScreenAnalytics.swift index 39dd9e484..fc7bdb38c 100644 --- a/OpenEdX/MainScreenAnalytics.swift +++ b/OpenEdX/MainScreenAnalytics.swift @@ -14,3 +14,12 @@ public protocol MainScreenAnalytics { func mainProgramsTabClicked() func mainProfileTabClicked() } + +#if DEBUG +public class MainScreenAnalyticsMock: MainScreenAnalytics { + public func mainDiscoveryTabClicked() {} + public func mainDashboardTabClicked() {} + public func mainProgramsTabClicked() {} + public func mainProfileTabClicked() {} +} +#endif diff --git a/OpenEdX/RouteController.swift b/OpenEdX/RouteController.swift index 367569cf6..6d7d0297b 100644 --- a/OpenEdX/RouteController.swift +++ b/OpenEdX/RouteController.swift @@ -67,7 +67,8 @@ class RouteController: UIViewController { let controller = UIHostingController(rootView: whatsNewView) navigation.viewControllers = [controller] } else { - let controller = UIHostingController(rootView: MainScreenView()) + let viewModel = Container.shared.resolve(MainScreenViewModel.self)! + let controller = UIHostingController(rootView: MainScreenView(viewModel: viewModel)) navigation.viewControllers = [controller] } present(navigation, animated: false) diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index 1dbb690d8..062dfcea2 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -75,7 +75,8 @@ public class Router: AuthorizationRouter, navigationController.viewControllers = [controller] navigationController.setViewControllers([controller], animated: true) } else { - let controller = UIHostingController(rootView: MainScreenView()) + let viewModel = Container.shared.resolve(MainScreenViewModel.self)! + let controller = UIHostingController(rootView: MainScreenView(viewModel: viewModel)) navigationController.viewControllers = [controller] navigationController.setViewControllers([controller], animated: true) } @@ -425,6 +426,21 @@ public class Router: AuthorizationRouter, navigationController.pushViewController(controller, animated: true) } + public func showUpdateRequiredView(showAccountLink: Bool = true) { + let view = UpdateRequiredView( + router: self, + config: Container.shared.resolve(Config.self)!, + showAccountLink: showAccountLink + ) + let controller = UIHostingController(rootView: view) + navigationController.pushViewController(controller, animated: false) + } + + public func showUpdateRecomendedView() { + let view = UpdateRecommendedView(router: self, config: Container.shared.resolve(Config.self)!) + self.presentView(transitionStyle: .crossDissolve, view: view) + } + private func prepareToPresent (_ toPresent: ToPresent, transitionStyle: UIModalTransitionStyle) -> UIViewController { let hosting = UIHostingController(rootView: toPresent) diff --git a/OpenEdX/View/MainScreenView.swift b/OpenEdX/View/MainScreenView.swift index e05451782..2d19c0c8f 100644 --- a/OpenEdX/View/MainScreenView.swift +++ b/OpenEdX/View/MainScreenView.swift @@ -18,6 +18,8 @@ struct MainScreenView: View { @State private var selection: MainTab = .discovery @State private var settingsTapped: Bool = false + @State private var disableAllTabs: Bool = false + @State private var updateAvaliable: Bool = false enum MainTab { case discovery @@ -26,9 +28,10 @@ struct MainScreenView: View { case profile } - private let analytics = Container.shared.resolve(MainScreenAnalytics.self)! - - init() { + @ObservedObject private var viewModel: MainScreenViewModel + + init(viewModel: MainScreenViewModel) { + self.viewModel = viewModel UITabBar.appearance().isTranslucent = false UITabBar.appearance().barTintColor = UIColor(Theme.Colors.textInputUnfocusedBackground) UITabBar.appearance().backgroundColor = UIColor(Theme.Colors.textInputUnfocusedBackground) @@ -37,21 +40,26 @@ struct MainScreenView: View { var body: some View { TabView(selection: $selection) { - DiscoveryView( - viewModel: Container.shared.resolve(DiscoveryViewModel.self)!, - router: Container.shared.resolve(DiscoveryRouter.self)! - ) + ZStack { + DiscoveryView(viewModel: Container.shared.resolve(DiscoveryViewModel.self)!) + if updateAvaliable { + UpdateNotificationView(config: viewModel.config) + } + } .tabItem { CoreAssets.discovery.swiftUIImage.renderingMode(.template) Text(CoreLocalization.Mainscreen.discovery) } .tag(MainTab.discovery) - VStack { + ZStack { DashboardView( viewModel: Container.shared.resolve(DashboardViewModel.self)!, router: Container.shared.resolve(DashboardRouter.self)! ) + if updateAvaliable { + UpdateNotificationView(config: viewModel.config) + } } .tabItem { CoreAssets.dashboard.swiftUIImage.renderingMode(.template) @@ -59,8 +67,11 @@ struct MainScreenView: View { } .tag(MainTab.dashboard) - VStack { + ZStack { Text(CoreLocalization.Mainscreen.inDeveloping) + if updateAvaliable { + UpdateNotificationView(config: viewModel.config) + } } .tabItem { CoreAssets.programs.swiftUIImage.renderingMode(.template) @@ -96,18 +107,35 @@ struct MainScreenView: View { } }) } + .onReceive(NotificationCenter.default.publisher(for: .onAppUpgradeAccountSettingsTapped)) { _ in + selection = .profile + disableAllTabs = true + } + .onReceive(NotificationCenter.default.publisher(for: .onNewVersionAvaliable)) { _ in + updateAvaliable = true + } + .onChange(of: selection) { _ in + if disableAllTabs { + selection = .profile + } + } .onChange(of: selection, perform: { selection in switch selection { case .discovery: - analytics.mainDiscoveryTabClicked() + viewModel.trackMainDiscoveryTabClicked() case .dashboard: - analytics.mainDashboardTabClicked() + viewModel.trackMainDashboardTabClicked() case .programs: - analytics.mainProgramsTabClicked() + viewModel.trackMainProgramsTabClicked() case .profile: - analytics.mainProfileTabClicked() + viewModel.trackMainProfileTabClicked() } }) + .onFirstAppear { + Task { + await viewModel.prefetchDataForOffline() + } + } } private func titleBar() -> String { @@ -122,10 +150,4 @@ struct MainScreenView: View { return ProfileLocalization.title } } - - struct MainScreenView_Previews: PreviewProvider { - static var previews: some View { - MainScreenView() - } - } } diff --git a/OpenEdX/View/MainScreenViewModel.swift b/OpenEdX/View/MainScreenViewModel.swift new file mode 100644 index 000000000..0296a6509 --- /dev/null +++ b/OpenEdX/View/MainScreenViewModel.swift @@ -0,0 +1,44 @@ +// +// MainScreenViewModel.swift +// OpenEdX +// +// Created by  Stepanok Ivan on 30.10.2023. +// + +import Foundation +import Core +import Profile + +class MainScreenViewModel: ObservableObject { + + private let analytics: MainScreenAnalytics + let config: Config + let profileInteractor: ProfileInteractorProtocol + + init(analytics: MainScreenAnalytics, config: Config, profileInteractor: ProfileInteractorProtocol) { + self.analytics = analytics + self.config = config + self.profileInteractor = profileInteractor + } + + func trackMainDiscoveryTabClicked() { + analytics.mainDiscoveryTabClicked() + } + func trackMainDashboardTabClicked() { + analytics.mainDashboardTabClicked() + } + func trackMainProgramsTabClicked() { + analytics.mainProgramsTabClicked() + } + func trackMainProfileTabClicked() { + analytics.mainProfileTabClicked() + } + + @MainActor + func prefetchDataForOffline() async { + if profileInteractor.getMyProfileOffline() == nil { + _ = try? await profileInteractor.getMyProfile() + } + } + +} diff --git a/Profile/Profile/Data/ProfileRepository.swift b/Profile/Profile/Data/ProfileRepository.swift index bf1a02ec2..935b9b039 100644 --- a/Profile/Profile/Data/ProfileRepository.swift +++ b/Profile/Profile/Data/ProfileRepository.swift @@ -12,7 +12,7 @@ import Alamofire public protocol ProfileRepositoryProtocol { func getUserProfile(username: String) async throws -> UserProfile func getMyProfile() async throws -> UserProfile - func getMyProfileOffline() throws -> UserProfile + func getMyProfileOffline() -> UserProfile? func logOut() async throws func uploadProfilePicture(pictureData: Data) async throws func deleteProfilePicture() async throws -> Bool @@ -61,12 +61,8 @@ public class ProfileRepository: ProfileRepositoryProtocol { return user.domain } - public func getMyProfileOffline() throws -> UserProfile { - if let user = storage.userProfile { - return user.domain - } else { - throw NoCachedDataError() - } + public func getMyProfileOffline() -> UserProfile? { + return storage.userProfile?.domain } public func logOut() async throws { @@ -173,7 +169,7 @@ class ProfileRepositoryMock: ProfileRepositoryProtocol { isFullProfile: false) } - func getMyProfileOffline() throws -> Core.UserProfile { + func getMyProfileOffline() -> Core.UserProfile? { return UserProfile( avatarUrl: "", name: "John Lennon", diff --git a/Profile/Profile/Domain/ProfileInteractor.swift b/Profile/Profile/Domain/ProfileInteractor.swift index 0f8fd4708..18e09aec2 100644 --- a/Profile/Profile/Domain/ProfileInteractor.swift +++ b/Profile/Profile/Domain/ProfileInteractor.swift @@ -13,7 +13,7 @@ import UIKit public protocol ProfileInteractorProtocol { func getUserProfile(username: String) async throws -> UserProfile func getMyProfile() async throws -> UserProfile - func getMyProfileOffline() throws -> UserProfile + func getMyProfileOffline() -> UserProfile? func logOut() async throws func getSpokenLanguages() -> [PickerFields.Option] func getCountries() -> [PickerFields.Option] @@ -41,8 +41,8 @@ public class ProfileInteractor: ProfileInteractorProtocol { return try await repository.getMyProfile() } - public func getMyProfileOffline() throws -> UserProfile { - return try repository.getMyProfileOffline() + public func getMyProfileOffline() -> UserProfile? { + return repository.getMyProfileOffline() } public func logOut() async throws { diff --git a/Profile/Profile/Presentation/Profile/ProfileView.swift b/Profile/Profile/Presentation/Profile/ProfileView.swift index 2d61ed18c..3456a1444 100644 --- a/Profile/Profile/Presentation/Profile/ProfileView.swift +++ b/Profile/Profile/Presentation/Profile/ProfileView.swift @@ -79,16 +79,16 @@ public struct ProfileView: View { .padding(.horizontal, 24) .font(Theme.Fonts.labelLarge) VStack(alignment: .leading, spacing: 27) { - Button(action: { - viewModel.trackProfileVideoSettingsClicked() - viewModel.router.showSettings() - }, label: { - HStack { + Button(action: { + viewModel.trackProfileVideoSettingsClicked() + viewModel.router.showSettings() + }, label: { + HStack { Text(ProfileLocalization.settingsVideo) Spacer() Image(systemName: "chevron.right") - } - }) + } + }) }.cardStyle( bgColor: Theme.Colors.textInputUnfocusedBackground, @@ -150,6 +150,55 @@ public struct ProfileView: View { .buttonStyle(PlainButtonStyle()) .foregroundColor(.primary) } + + // MARK: Version + Rectangle() + .frame(height: 1) + .foregroundColor(Theme.Colors.textSecondary) + Button(action: { + viewModel.openAppStore() + }, label: { + HStack { + VStack(alignment: .leading, spacing: 0) { + HStack { + if viewModel.versionState == .updateRequired { + CoreAssets.warningFilled.swiftUIImage + .resizable() + .frame(width: 24, height: 24) + } + Text("\(ProfileLocalization.Settings.version) \(viewModel.currentVersion)") + } + switch viewModel.versionState { + case .actual: + HStack { + CoreAssets.checkmark.swiftUIImage + .renderingMode(.template) + .foregroundColor(.green) + Text(ProfileLocalization.Settings.upToDate) + .font(Theme.Fonts.labelMedium) + .foregroundStyle(Theme.Colors.textSecondary) + } + case .updateNeeded: + Text("\(ProfileLocalization.Settings.tapToUpdate) \(viewModel.latestVersion)") + .font(Theme.Fonts.labelMedium) + .foregroundStyle(Theme.Colors.accentColor) + case .updateRequired: + Text(ProfileLocalization.Settings.tapToInstall) + .font(Theme.Fonts.labelMedium) + .foregroundStyle(Theme.Colors.accentColor) + } + } + Spacer() + if viewModel.versionState != .actual { + Image(systemName: "arrow.up.circle") + .resizable() + .frame(width: 24, height: 24) + .foregroundStyle(Theme.Colors.accentColor) + } + + } + }).disabled(viewModel.versionState == .actual) + }.cardStyle( bgColor: Theme.Colors.textInputUnfocusedBackground, strokeColor: .clear @@ -157,37 +206,37 @@ public struct ProfileView: View { // MARK: - Log out VStack { - Button(action: { - viewModel.router.presentView(transitionStyle: .crossDissolve) { - AlertView( - alertTitle: ProfileLocalization.LogoutAlert.title, - alertMessage: ProfileLocalization.LogoutAlert.text, - positiveAction: CoreLocalization.Alert.accept, - onCloseTapped: { - viewModel.router.dismiss(animated: true) - }, - okTapped: { - viewModel.router.dismiss(animated: true) - Task { - await viewModel.logOut() - } - }, type: .logOut - ) - } - }, label: { - HStack { + Button(action: { + viewModel.router.presentView(transitionStyle: .crossDissolve) { + AlertView( + alertTitle: ProfileLocalization.LogoutAlert.title, + alertMessage: ProfileLocalization.LogoutAlert.text, + positiveAction: CoreLocalization.Alert.accept, + onCloseTapped: { + viewModel.router.dismiss(animated: true) + }, + okTapped: { + viewModel.router.dismiss(animated: true) + Task { + await viewModel.logOut() + } + }, type: .logOut + ) + } + }, label: { + HStack { Text(ProfileLocalization.logout) Spacer() Image(systemName: "rectangle.portrait.and.arrow.right") - } - }) + } + }) } .foregroundColor(Theme.Colors.alert) - .cardStyle(bgColor: Theme.Colors.textInputUnfocusedBackground, - strokeColor: .clear) - .padding(.top, 24) - .padding(.bottom, 60) + .cardStyle(bgColor: Theme.Colors.textInputUnfocusedBackground, + strokeColor: .clear) + .padding(.top, 24) + .padding(.bottom, 60) } Spacer() } @@ -195,21 +244,20 @@ public struct ProfileView: View { }.frameLimit(sizePortrait: 420) .padding(.top, 8) .onChange(of: settingsTapped, perform: { _ in - if let userModel = viewModel.userModel { - viewModel.trackProfileEditClicked() - viewModel.router.showEditProfile( - userModel: userModel, - avatar: viewModel.updatedAvatar, - profileDidEdit: { updatedProfile, updatedImage in - if let updatedProfile { - self.viewModel.userModel = updatedProfile - } - if let updatedImage { - self.viewModel.updatedAvatar = updatedImage - } + let userModel = viewModel.userModel ?? UserProfile() + viewModel.trackProfileEditClicked() + viewModel.router.showEditProfile( + userModel: userModel, + avatar: viewModel.updatedAvatar, + profileDidEdit: { updatedProfile, updatedImage in + if let updatedProfile { + self.viewModel.userModel = updatedProfile } - ) - } + if let updatedImage { + self.viewModel.updatedAvatar = updatedImage + } + } + ) }) .navigationBarHidden(false) .navigationBarBackButtonHidden(false) diff --git a/Profile/Profile/Presentation/Profile/ProfileViewModel.swift b/Profile/Profile/Presentation/Profile/ProfileViewModel.swift index 49e5dd254..e31d837be 100644 --- a/Profile/Profile/Presentation/Profile/ProfileViewModel.swift +++ b/Profile/Profile/Presentation/Profile/ProfileViewModel.swift @@ -5,7 +5,7 @@ // Created by  Stepanok Ivan on 22.09.2022. // -import Foundation +import Combine import Core import SwiftUI @@ -22,7 +22,17 @@ public class ProfileViewModel: ObservableObject { } } } + private var cancellables = Set() + enum VersionState { + case actual + case updateNeeded + case updateRequired + } + + @Published var versionState: VersionState = .actual + @Published var currentVersion: String = "" + @Published var latestVersion: String = "" let router: ProfileRouter let config: Config @@ -43,6 +53,29 @@ public class ProfileViewModel: ObservableObject { self.analytics = analytics self.config = config self.connectivity = connectivity + generateVersionState() + } + + func openAppStore() { + guard let appStoreURL = URL(string: config.appStoreLink) else { return } + UIApplication.shared.open(appStoreURL) + } + + func generateVersionState() { + guard let info = Bundle.main.infoDictionary else { return } + guard let currentVersion = info["CFBundleShortVersionString"] as? String else { return } + self.currentVersion = currentVersion + NotificationCenter.default.publisher(for: .onActualVersionReceived) + .sink { [weak self] notification in + guard let latestVersion = notification.object as? String else { return } + DispatchQueue.main.async { [weak self] in + self?.latestVersion = latestVersion + + if latestVersion != currentVersion { + self?.versionState = .updateNeeded + } + } + }.store(in: &cancellables) } func contactSupport() -> URL? { @@ -60,39 +93,34 @@ public class ProfileViewModel: ObservableObject { @MainActor func getMyProfile(withProgress: Bool = true) async { - isShowProgress = withProgress do { - if connectivity.isInternetAvaliable { - userModel = try await interactor.getMyProfile() - isShowProgress = false + let userModel = interactor.getMyProfileOffline() + if userModel == nil && connectivity.isInternetAvaliable { + isShowProgress = withProgress } else { - userModel = try interactor.getMyProfileOffline() - isShowProgress = false + self.userModel = userModel + } + if connectivity.isInternetAvaliable { + self.userModel = try await interactor.getMyProfile() } + isShowProgress = false } catch let error { isShowProgress = false - if error.isInternetError || error is NoCachedDataError { + if error.isUpdateRequeiredError { + self.versionState = .updateRequired + } else if error.isInternetError { errorMessage = CoreLocalization.Error.slowOrNoInternetConnection } else { errorMessage = CoreLocalization.Error.unknownError } - } } @MainActor func logOut() async { - do { - try await interactor.logOut() - router.showLoginScreen() - analytics.userLogout(force: false) - } catch let error { - if error.isInternetError { - errorMessage = CoreLocalization.Error.slowOrNoInternetConnection - } else { - errorMessage = CoreLocalization.Error.unknownError - } - } + try? await interactor.logOut() + router.showLoginScreen() + analytics.userLogout(force: false) } func trackProfileVideoSettingsClicked() { diff --git a/Profile/Profile/SwiftGen/Strings.swift b/Profile/Profile/SwiftGen/Strings.swift index 7f57c3ee7..80eadde89 100644 --- a/Profile/Profile/SwiftGen/Strings.swift +++ b/Profile/Profile/SwiftGen/Strings.swift @@ -118,6 +118,14 @@ public enum ProfileLocalization { public static let qualityAutoDescription = ProfileLocalization.tr("Localizable", "SETTINGS.QUALITY_AUTO_DESCRIPTION", fallback: "Recommended") /// Auto public static let qualityAutoTitle = ProfileLocalization.tr("Localizable", "SETTINGS.QUALITY_AUTO_TITLE", fallback: "Auto") + /// Tap to install required app update + public static let tapToInstall = ProfileLocalization.tr("Localizable", "SETTINGS.TAP_TO_INSTALL", fallback: "Tap to install required app update") + /// Tap to update to version + public static let tapToUpdate = ProfileLocalization.tr("Localizable", "SETTINGS.TAP_TO_UPDATE", fallback: "Tap to update to version") + /// Up-to-date + public static let upToDate = ProfileLocalization.tr("Localizable", "SETTINGS.UP_TO_DATE", fallback: "Up-to-date") + /// Version: + public static let version = ProfileLocalization.tr("Localizable", "SETTINGS.VERSION", fallback: "Version:") /// Auto (Recommended) public static let videoQualityDescription = ProfileLocalization.tr("Localizable", "SETTINGS.VIDEO_QUALITY_DESCRIPTION", fallback: "Auto (Recommended)") /// Video download quality diff --git a/Profile/Profile/en.lproj/Localizable.strings b/Profile/Profile/en.lproj/Localizable.strings index 9eef8b47f..750c061f9 100644 --- a/Profile/Profile/en.lproj/Localizable.strings +++ b/Profile/Profile/en.lproj/Localizable.strings @@ -68,3 +68,8 @@ "SETTINGS.QUALITY_540_TITLE" = "540p"; "SETTINGS.QUALITY_720_TITLE" = "720p"; "SETTINGS.QUALITY_720_DESCRIPTION" = "Best quality"; + +"SETTINGS.VERSION" = "Version:"; +"SETTINGS.UP_TO_DATE" = "Up-to-date"; +"SETTINGS.TAP_TO_UPDATE" = "Tap to update to version"; +"SETTINGS.TAP_TO_INSTALL" = "Tap to install required app update"; diff --git a/Profile/Profile/uk.lproj/Localizable.strings b/Profile/Profile/uk.lproj/Localizable.strings index 575f9dca8..0387a60d9 100644 --- a/Profile/Profile/uk.lproj/Localizable.strings +++ b/Profile/Profile/uk.lproj/Localizable.strings @@ -68,3 +68,8 @@ "SETTINGS.QUALITY_540_TITLE" = "540p"; "SETTINGS.QUALITY_720_TITLE" = "720p"; "SETTINGS.QUALITY_AUTO_DESCRIPTION" = "Найкраща якість"; + +"SETTINGS.VERSION" = "Версія:"; +"SETTINGS.UP_TO_DATE" = "Оновлено"; +"SETTINGS.TAP_TO_UPDATE" = "Клацніть, щоб оновити до версії"; +"SETTINGS.TAP_TO_INSTALL" = "Клацніть, щоб встановити обов'язкове оновлення програми"; diff --git a/Profile/ProfileTests/Presentation/Profile/ProfileViewModelTests.swift b/Profile/ProfileTests/Presentation/Profile/ProfileViewModelTests.swift index 3c9a7d0f4..a66d1929a 100644 --- a/Profile/ProfileTests/Presentation/Profile/ProfileViewModelTests.swift +++ b/Profile/ProfileTests/Presentation/Profile/ProfileViewModelTests.swift @@ -110,6 +110,7 @@ final class ProfileViewModelTests: XCTestCase { ) Given(connectivity, .isInternetAvaliable(getter: true)) + Given(interactor, .getMyProfileOffline(willReturn: user)) Given(interactor, .getMyProfile(willReturn: user)) await viewModel.getMyProfile() @@ -172,35 +173,22 @@ final class ProfileViewModelTests: XCTestCase { connectivity: connectivity ) - let noInternetError = AFError.sessionInvalidated(error: URLError(.notConnectedToInternet)) - - Given(connectivity, .isInternetAvaliable(getter: true)) - Given(interactor, .getMyProfile(willThrow: noInternetError) ) - - await viewModel.getMyProfile() - - Verify(interactor, 1, .getMyProfile()) - - XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.slowOrNoInternetConnection) - XCTAssertFalse(viewModel.isShowProgress) - XCTAssertTrue(viewModel.showError) - } - - func testGetMyProfileNoCacheError() async throws { - let interactor = ProfileInteractorProtocolMock() - let router = ProfileRouterMock() - let analytics = ProfileAnalyticsMock() - let connectivity = ConnectivityProtocolMock() - let viewModel = ProfileViewModel( - interactor: interactor, - router: router, - analytics: analytics, - config: ConfigMock(), - connectivity: connectivity + let user = UserProfile( + avatarUrl: "", + name: "Steve", + username: "Steve", + dateJoined: Date(), + yearOfBirth: 2000, + country: "Ua", + shortBiography: "Bio", + isFullProfile: false ) + let noInternetError = AFError.sessionInvalidated(error: URLError(.notConnectedToInternet)) + Given(connectivity, .isInternetAvaliable(getter: true)) - Given(interactor, .getMyProfile(willThrow: NoCachedDataError())) + Given(interactor, .getMyProfileOffline(willReturn: user)) + Given(interactor, .getMyProfile(willThrow: noInternetError)) await viewModel.getMyProfile() @@ -250,7 +238,6 @@ final class ProfileViewModelTests: XCTestCase { ) Given(connectivity, .isInternetAvaliable(getter: true)) - Given(interactor, .logOut(willProduce: {_ in})) await viewModel.logOut() @@ -258,52 +245,6 @@ final class ProfileViewModelTests: XCTestCase { XCTAssertFalse(viewModel.showError) } - func testLogOutNoInternetError() async throws { - let interactor = ProfileInteractorProtocolMock() - let router = ProfileRouterMock() - let analytics = ProfileAnalyticsMock() - let connectivity = ConnectivityProtocolMock() - let viewModel = ProfileViewModel( - interactor: interactor, - router: router, - analytics: analytics, - config: ConfigMock(), - connectivity: connectivity - ) - - let noInternetError = AFError.sessionInvalidated(error: URLError(.notConnectedToInternet)) - - Given(connectivity, .isInternetAvaliable(getter: true)) - Given(interactor, .logOut(willThrow: noInternetError)) - - await viewModel.logOut() - - XCTAssertTrue(viewModel.showError) - XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.slowOrNoInternetConnection) - } - - func testLogOutUnknownError() async throws { - let interactor = ProfileInteractorProtocolMock() - let router = ProfileRouterMock() - let analytics = ProfileAnalyticsMock() - let connectivity = ConnectivityProtocolMock() - let viewModel = ProfileViewModel( - interactor: interactor, - router: router, - analytics: analytics, - config: ConfigMock(), - connectivity: connectivity - ) - - Given(connectivity, .isInternetAvaliable(getter: true)) - Given(interactor, .logOut(willThrow: NSError())) - - await viewModel.logOut() - - XCTAssertTrue(viewModel.showError) - XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.unknownError) - } - func testTrackProfileVideoSettingsClicked() { let interactor = ProfileInteractorProtocolMock() let router = ProfileRouterMock() diff --git a/Profile/ProfileTests/ProfileMock.generated.swift b/Profile/ProfileTests/ProfileMock.generated.swift index 9c82a4dd6..1321a7d0f 100644 --- a/Profile/ProfileTests/ProfileMock.generated.swift +++ b/Profile/ProfileTests/ProfileMock.generated.swift @@ -1770,18 +1770,15 @@ open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { return __value } - open func getMyProfileOffline() throws -> UserProfile { + open func getMyProfileOffline() -> UserProfile? { addInvocation(.m_getMyProfileOffline) let perform = methodPerformValue(.m_getMyProfileOffline) as? () -> Void perform?() - var __value: UserProfile + var __value: UserProfile? = nil do { __value = try methodReturnValue(.m_getMyProfileOffline).casted() - } catch MockError.notStubed { - onFatalFailure("Stub return value not specified for getMyProfileOffline(). Use given") - Failure("Stub return value not specified for getMyProfileOffline(). Use given") } catch { - throw error + // do nothing } return __value } @@ -2016,7 +2013,7 @@ open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { public static func getMyProfile(willReturn: UserProfile...) -> MethodStub { return Given(method: .m_getMyProfile, products: willReturn.map({ StubProduct.return($0 as Any) })) } - public static func getMyProfileOffline(willReturn: UserProfile...) -> MethodStub { + public static func getMyProfileOffline(willReturn: UserProfile?...) -> MethodStub { return Given(method: .m_getMyProfileOffline, products: willReturn.map({ StubProduct.return($0 as Any) })) } public static func getSpokenLanguages(willReturn: [PickerFields.Option]...) -> MethodStub { @@ -2037,6 +2034,13 @@ 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 getMyProfileOffline(willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [UserProfile?] = [] + let given: Given = { return Given(method: .m_getMyProfileOffline, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (UserProfile?).self) + willProduce(stubber) + return given + } public static func getSpokenLanguages(willProduce: (Stubber<[PickerFields.Option]>) -> Void) -> MethodStub { let willReturn: [[PickerFields.Option]] = [] let given: Given = { return Given(method: .m_getSpokenLanguages, products: willReturn.map({ StubProduct.return($0 as Any) })) }() @@ -2078,16 +2082,6 @@ open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { willProduce(stubber) return given } - public static func getMyProfileOffline(willThrow: Error...) -> MethodStub { - return Given(method: .m_getMyProfileOffline, products: willThrow.map({ StubProduct.throw($0) })) - } - public static func getMyProfileOffline(willProduce: (StubberThrows) -> Void) -> MethodStub { - let willThrow: [Error] = [] - let given: Given = { return Given(method: .m_getMyProfileOffline, products: willThrow.map({ StubProduct.throw($0) })) }() - let stubber = given.stubThrows(for: (UserProfile).self) - willProduce(stubber) - return given - } public static func logOut(willThrow: Error...) -> MethodStub { return Given(method: .m_logOut, products: willThrow.map({ StubProduct.throw($0) })) } From 99e4afdad1965cdf96569586c76f4b268d2e8843 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Mon, 6 Nov 2023 11:10:13 +0200 Subject: [PATCH 010/158] Video start playing by subtitle taps (#145) --- .../Video/EncodedVideoPlayer.swift | 2 ++ .../Video/YouTubeVideoPlayer.swift | 26 +++++++++++-------- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/Course/Course/Presentation/Video/EncodedVideoPlayer.swift b/Course/Course/Presentation/Video/EncodedVideoPlayer.swift index 233469250..a2c40e113 100644 --- a/Course/Course/Presentation/Video/EncodedVideoPlayer.swift +++ b/Course/Course/Presentation/Video/EncodedVideoPlayer.swift @@ -91,6 +91,7 @@ public struct EncodedVideoPlayer: View { preferredTimescale: 10000 ) ) + viewModel.controller.player?.play() pauseScrolling() currentTime = (date.secondsSinceMidnight() + 1) }) @@ -108,6 +109,7 @@ public struct EncodedVideoPlayer: View { preferredTimescale: 10000 ) ) + viewModel.controller.player?.play() pauseScrolling() currentTime = (date.secondsSinceMidnight() + 1) }) diff --git a/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift b/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift index 9d12b3183..4380d0ef2 100644 --- a/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift +++ b/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift @@ -55,20 +55,24 @@ public struct YouTubeVideoPlayer: View { Spacer() } } - SubtittlesView( - languages: viewModel.languages, - currentTime: $viewModel.currentTime, - viewModel: viewModel, scrollTo: { date in - viewModel.youtubePlayer.seek(to: date.secondsSinceMidnight(), allowSeekAhead: true) - viewModel.pauseScrolling() - viewModel.currentTime = date.secondsSinceMidnight() + 1 + ZStack { + SubtittlesView( + languages: viewModel.languages, + currentTime: $viewModel.currentTime, + viewModel: viewModel, scrollTo: { date in + viewModel.youtubePlayer.seek(to: date.secondsSinceMidnight(), allowSeekAhead: true) + viewModel.youtubePlayer.play() + viewModel.pauseScrolling() + viewModel.currentTime = date.secondsSinceMidnight() + 1 + } + ) + if viewModel.isLoading { + ProgressBar(size: 40, lineWidth: 8) } - ) + } } } - if viewModel.isLoading { - ProgressBar(size: 40, lineWidth: 8) - } + } } } From 3b7efd459e7c9d049a7288748389204f1d4cd28b Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Mon, 6 Nov 2023 12:50:52 +0200 Subject: [PATCH 011/158] Screencasting (enable by default) (#144) * enable AirPlay screencasting * fix the bug where screencast continues to stream the video even if the user clicks next * remove commented code --- .../Presentation/Unit/CourseUnitView.swift | 23 +++++++++++++++---- .../Video/EncodedVideoPlayer.swift | 3 +++ .../Video/PlayerViewController.swift | 1 + .../Video/YouTubeVideoPlayerViewModel.swift | 2 +- 4 files changed, 24 insertions(+), 5 deletions(-) diff --git a/Course/Course/Presentation/Unit/CourseUnitView.swift b/Course/Course/Presentation/Unit/CourseUnitView.swift index 484fdda9f..c74f0e7b0 100644 --- a/Course/Course/Presentation/Unit/CourseUnitView.swift +++ b/Course/Course/Presentation/Unit/CourseUnitView.swift @@ -50,10 +50,10 @@ public struct CourseUnitView: View { let data = Array(viewModel.verticals[viewModel.verticalIndex].childs.enumerated()) ForEach(data, id: \.offset) { index, block in VStack(spacing: 0) { - if index >= viewModel.index - 1 && index <= viewModel.index + 1 { switch LessonType.from(block) { // MARK: YouTube case let .youtube(url, blockID): + if index >= viewModel.index - 1 && index <= viewModel.index + 1 { if viewModel.connectivity.isInternetAvaliable { YouTubeView( name: block.displayName, @@ -71,8 +71,12 @@ public struct CourseUnitView: View { } else { NoInternetView(playerStateSubject: playerStateSubject) } + } else { + EmptyView() + } // MARK: Encoded Video case let .video(encodedUrl, blockID): + if index == viewModel.index { let url = viewModel.urlForVideoFileOrFallback( blockId: blockID, url: encodedUrl @@ -94,23 +98,33 @@ public struct CourseUnitView: View { } else { NoInternetView(playerStateSubject: playerStateSubject) } + } // MARK: Web case .web(let url): + if index >= viewModel.index - 1 && index <= viewModel.index + 1 { if viewModel.connectivity.isInternetAvaliable { WebView(url: url, viewModel: viewModel) } else { NoInternetView(playerStateSubject: playerStateSubject) } + } else { + EmptyView() + } // MARK: Unknown case .unknown(let url): + if index >= viewModel.index - 1 && index <= viewModel.index + 1 { if viewModel.connectivity.isInternetAvaliable { UnknownView(url: url, viewModel: viewModel) Spacer() } else { NoInternetView(playerStateSubject: playerStateSubject) } + } else { + EmptyView() + } // MARK: Discussion case let .discussion(blockID, blockKey, title): + if index >= viewModel.index - 1 && index <= viewModel.index + 1 { if viewModel.connectivity.isInternetAvaliable { VStack { if showDiscussion { @@ -131,10 +145,11 @@ public struct CourseUnitView: View { } else { NoInternetView(playerStateSubject: playerStateSubject) } + } else { + EmptyView() } - } else { - EmptyView() - } + } + } .frame( width: isHorizontal ? reader.size.width - 16 : reader.size.width, diff --git a/Course/Course/Presentation/Video/EncodedVideoPlayer.swift b/Course/Course/Presentation/Video/EncodedVideoPlayer.swift index a2c40e113..a5c3c6b13 100644 --- a/Course/Course/Presentation/Video/EncodedVideoPlayer.swift +++ b/Course/Course/Presentation/Video/EncodedVideoPlayer.swift @@ -117,6 +117,9 @@ public struct EncodedVideoPlayer: View { } } }.padding(.horizontal, isHorizontal ? 0 : 8) + .onDisappear { + viewModel.controller.player?.allowsExternalPlayback = false + } } private func pauseScrolling() { diff --git a/Course/Course/Presentation/Video/PlayerViewController.swift b/Course/Course/Presentation/Video/PlayerViewController.swift index 01a27e640..bbabc73f4 100644 --- a/Course/Course/Presentation/Video/PlayerViewController.swift +++ b/Course/Course/Presentation/Video/PlayerViewController.swift @@ -73,6 +73,7 @@ struct PlayerViewController: UIViewControllerRepresentable { if asset?.url.absoluteString != videoURL?.absoluteString { if playerController.player == nil { playerController.player = AVPlayer() + playerController.player?.allowsExternalPlayback = true } playerController.player?.replaceCurrentItem(with: AVPlayerItem(url: videoURL!)) addPeriodicTimeObserver(playerController, currentProgress: { progress, seconds in diff --git a/Course/Course/Presentation/Video/YouTubeVideoPlayerViewModel.swift b/Course/Course/Presentation/Video/YouTubeVideoPlayerViewModel.swift index 06bc69f75..9b4440458 100644 --- a/Course/Course/Presentation/Video/YouTubeVideoPlayerViewModel.swift +++ b/Course/Course/Presentation/Video/YouTubeVideoPlayerViewModel.swift @@ -80,7 +80,7 @@ public class YouTubeVideoPlayerViewModel: VideoPlayerViewModel { playerStateSubject.sink(receiveValue: { [weak self] state in switch state { case .pause: - self?.youtubePlayer.pause() + self?.youtubePlayer.stop() case .kill, .none: break } From ab942a406415f51c117c651be8a7aff5904e2a9b Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Mon, 6 Nov 2023 12:55:01 +0200 Subject: [PATCH 012/158] Add HLS quality support (#134) * add HLS quality support * update default localization * Update VideoPlayerViewModelTests.swift * change 360 quality localizations * fix resolution changing logic * rename the getVideoResolution function --- Core/Core/Data/CoreStorage.swift | 11 +++++++++++ Core/Core/Data/Model/UserSettings.swift | 8 ++++---- .../Video/EncodedVideoPlayer.swift | 4 +++- .../Video/EncodedVideoPlayerViewModel.swift | 19 ++++++++++++++++++- .../Video/PlayerViewController.swift | 7 ++++++- .../Presentation/Video/SubtittlesView.swift | 3 ++- .../Video/VideoPlayerViewModel.swift | 3 +++ .../Video/YouTubeVideoPlayer.swift | 3 ++- .../Video/YouTubeVideoPlayerViewModel.swift | 2 ++ .../Unit/VideoPlayerViewModelTests.swift | 8 +++++++- OpenEdX/DI/ScreenAssembly.swift | 4 +++- OpenEdX/Data/AppStorage.swift | 2 +- Profile/Profile/Data/ProfileRepository.swift | 4 ++-- .../Settings/SettingsViewModel.swift | 10 +++++----- Profile/Profile/SwiftGen/Strings.swift | 8 ++++---- Profile/Profile/en.lproj/Localizable.strings | 4 ++-- Profile/Profile/uk.lproj/Localizable.strings | 4 ++-- 17 files changed, 77 insertions(+), 27 deletions(-) diff --git a/Core/Core/Data/CoreStorage.swift b/Core/Core/Data/CoreStorage.swift index 4ff71e963..3fe13dc7e 100644 --- a/Core/Core/Data/CoreStorage.swift +++ b/Core/Core/Data/CoreStorage.swift @@ -15,3 +15,14 @@ public protocol CoreStorage { var userSettings: UserSettings? {get set} func clear() } + +public struct CoreStorageMock: CoreStorage { + public var accessToken: String? = nil + public var refreshToken: String? = nil + public var cookiesDate: String? = nil + public var user: DataLayer.User? = nil + public var userSettings: UserSettings? = nil + public func clear() {} + + public init() {} +} diff --git a/Core/Core/Data/Model/UserSettings.swift b/Core/Core/Data/Model/UserSettings.swift index e709fca00..b44c30449 100644 --- a/Core/Core/Data/Model/UserSettings.swift +++ b/Core/Core/Data/Model/UserSettings.swift @@ -9,15 +9,15 @@ import Foundation public struct UserSettings: Codable { public var wifiOnly: Bool - public var downloadQuality: VideoQuality + public var streamingQuality: StreamingQuality - public init(wifiOnly: Bool, downloadQuality: VideoQuality) { + public init(wifiOnly: Bool, streamingQuality: StreamingQuality) { self.wifiOnly = wifiOnly - self.downloadQuality = downloadQuality + self.streamingQuality = streamingQuality } } -public enum VideoQuality: Codable { +public enum StreamingQuality: Codable { case auto case low case medium diff --git a/Course/Course/Presentation/Video/EncodedVideoPlayer.swift b/Course/Course/Presentation/Video/EncodedVideoPlayer.swift index a5c3c6b13..4740fb013 100644 --- a/Course/Course/Presentation/Video/EncodedVideoPlayer.swift +++ b/Course/Course/Presentation/Video/EncodedVideoPlayer.swift @@ -60,6 +60,7 @@ public struct EncodedVideoPlayer: View { PlayerViewController( videoURL: viewModel.url, controller: viewModel.controller, + bitrate: viewModel.getVideoResolution(), progress: { progress in if progress >= 0.8 { if !isViewedOnce { @@ -141,7 +142,8 @@ struct EncodedVideoPlayer_Previews: PreviewProvider { languages: [], playerStateSubject: CurrentValueSubject(nil), interactor: CourseInteractor(repository: CourseRepositoryMock()), - router: CourseRouterMock(), + router: CourseRouterMock(), + appStorage: CoreStorageMock(), connectivity: Connectivity() ), isOnScreen: true diff --git a/Course/Course/Presentation/Video/EncodedVideoPlayerViewModel.swift b/Course/Course/Presentation/Video/EncodedVideoPlayerViewModel.swift index b75a57384..6163c8f93 100644 --- a/Course/Course/Presentation/Video/EncodedVideoPlayerViewModel.swift +++ b/Course/Course/Presentation/Video/EncodedVideoPlayerViewModel.swift @@ -24,6 +24,7 @@ public class EncodedVideoPlayerViewModel: VideoPlayerViewModel { playerStateSubject: CurrentValueSubject, interactor: CourseInteractorProtocol, router: CourseRouter, + appStorage: CoreStorage, connectivity: ConnectivityProtocol ) { self.url = url @@ -32,7 +33,8 @@ public class EncodedVideoPlayerViewModel: VideoPlayerViewModel { courseID: courseID, languages: languages, interactor: interactor, - router: router, + router: router, + appStorage: appStorage, connectivity: connectivity) playerStateSubject.sink(receiveValue: { [weak self] state in @@ -46,4 +48,19 @@ public class EncodedVideoPlayerViewModel: VideoPlayerViewModel { } }).store(in: &subscription) } + + func getVideoResolution() -> CGSize { + switch appStorage.userSettings?.streamingQuality { + case .auto: + return CGSize(width: 1280, height: 720) + case .low: + return CGSize(width: 640, height: 360) + case .medium: + return CGSize(width: 854, height: 480) + case .high: + return CGSize(width: 1280, height: 720) + case .none: + return CGSize(width: 1280, height: 720) + } + } } diff --git a/Course/Course/Presentation/Video/PlayerViewController.swift b/Course/Course/Presentation/Video/PlayerViewController.swift index bbabc73f4..593fee127 100644 --- a/Course/Course/Presentation/Video/PlayerViewController.swift +++ b/Course/Course/Presentation/Video/PlayerViewController.swift @@ -11,17 +11,21 @@ import _AVKit_SwiftUI struct PlayerViewController: UIViewControllerRepresentable { var videoURL: URL? + var videoResolution: CGSize var controller: AVPlayerViewController var progress: ((Float) -> Void) var seconds: ((Double) -> Void) init( - videoURL: URL?, controller: AVPlayerViewController, + videoURL: URL?, + controller: AVPlayerViewController, + bitrate: CGSize, progress: @escaping ((Float) -> Void), seconds: @escaping ((Double) -> Void) ) { self.videoURL = videoURL self.controller = controller + self.videoResolution = bitrate self.progress = progress self.seconds = seconds } @@ -76,6 +80,7 @@ struct PlayerViewController: UIViewControllerRepresentable { playerController.player?.allowsExternalPlayback = true } playerController.player?.replaceCurrentItem(with: AVPlayerItem(url: videoURL!)) + playerController.player?.currentItem?.preferredMaximumResolution = videoResolution addPeriodicTimeObserver(playerController, currentProgress: { progress, seconds in self.progress(progress) self.seconds(seconds) diff --git a/Course/Course/Presentation/Video/SubtittlesView.swift b/Course/Course/Presentation/Video/SubtittlesView.swift index e7dca9735..200dee93c 100644 --- a/Course/Course/Presentation/Video/SubtittlesView.swift +++ b/Course/Course/Presentation/Video/SubtittlesView.swift @@ -120,7 +120,8 @@ struct SubtittlesView_Previews: PreviewProvider { blockID: "", courseID: "", languages: [], interactor: CourseInteractor(repository: CourseRepositoryMock()), - router: CourseRouterMock(), + router: CourseRouterMock(), + appStorage: CoreStorageMock(), connectivity: Connectivity() ), scrollTo: {_ in } ) diff --git a/Course/Course/Presentation/Video/VideoPlayerViewModel.swift b/Course/Course/Presentation/Video/VideoPlayerViewModel.swift index cddcdba6c..b68ba5a7c 100644 --- a/Course/Course/Presentation/Video/VideoPlayerViewModel.swift +++ b/Course/Course/Presentation/Video/VideoPlayerViewModel.swift @@ -17,6 +17,7 @@ public class VideoPlayerViewModel: ObservableObject { private let interactor: CourseInteractorProtocol public let connectivity: ConnectivityProtocol public let router: CourseRouter + public let appStorage: CoreStorage private var subtitlesDownloaded: Bool = false @Published var subtitles: [Subtitle] = [] @@ -37,6 +38,7 @@ public class VideoPlayerViewModel: ObservableObject { languages: [SubtitleUrl], interactor: CourseInteractorProtocol, router: CourseRouter, + appStorage: CoreStorage, connectivity: ConnectivityProtocol ) { self.blockID = blockID @@ -44,6 +46,7 @@ public class VideoPlayerViewModel: ObservableObject { self.languages = languages self.interactor = interactor self.router = router + self.appStorage = appStorage self.connectivity = connectivity self.prepareLanguages() } diff --git a/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift b/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift index 4380d0ef2..08a868665 100644 --- a/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift +++ b/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift @@ -88,7 +88,8 @@ struct YouTubeVideoPlayer_Previews: PreviewProvider { languages: [], playerStateSubject: CurrentValueSubject(nil), interactor: CourseInteractor(repository: CourseRepositoryMock()), - router: CourseRouterMock(), + router: CourseRouterMock(), + appStorage: CoreStorageMock(), connectivity: Connectivity()), isOnScreen: true) } diff --git a/Course/Course/Presentation/Video/YouTubeVideoPlayerViewModel.swift b/Course/Course/Presentation/Video/YouTubeVideoPlayerViewModel.swift index 9b4440458..9caf58001 100644 --- a/Course/Course/Presentation/Video/YouTubeVideoPlayerViewModel.swift +++ b/Course/Course/Presentation/Video/YouTubeVideoPlayerViewModel.swift @@ -32,6 +32,7 @@ public class YouTubeVideoPlayerViewModel: VideoPlayerViewModel { playerStateSubject: CurrentValueSubject, interactor: CourseInteractorProtocol, router: CourseRouter, + appStorage: CoreStorage, connectivity: ConnectivityProtocol ) { self.url = url @@ -61,6 +62,7 @@ public class YouTubeVideoPlayerViewModel: VideoPlayerViewModel { languages: languages, interactor: interactor, router: router, + appStorage: appStorage, connectivity: connectivity ) diff --git a/Course/CourseTests/Presentation/Unit/VideoPlayerViewModelTests.swift b/Course/CourseTests/Presentation/Unit/VideoPlayerViewModelTests.swift index 83295daf7..2a6b2f722 100644 --- a/Course/CourseTests/Presentation/Unit/VideoPlayerViewModelTests.swift +++ b/Course/CourseTests/Presentation/Unit/VideoPlayerViewModelTests.swift @@ -37,7 +37,8 @@ final class VideoPlayerViewModelTests: XCTestCase { courseID: "", languages: [], interactor: interactor, - router: router, + router: router, + appStorage: CoreStorageMock(), connectivity: connectivity) await viewModel.getSubtitles(subtitlesUrl: "url") @@ -64,6 +65,7 @@ final class VideoPlayerViewModelTests: XCTestCase { languages: [], interactor: interactor, router: router, + appStorage: CoreStorageMock(), connectivity: connectivity) await viewModel.getSubtitles(subtitlesUrl: "url") @@ -85,6 +87,7 @@ final class VideoPlayerViewModelTests: XCTestCase { languages: [], interactor: interactor, router: router, + appStorage: CoreStorageMock(), connectivity: connectivity) viewModel.languages = [ @@ -112,6 +115,7 @@ final class VideoPlayerViewModelTests: XCTestCase { languages: [], interactor: interactor, router: router, + appStorage: CoreStorageMock(), connectivity: connectivity) Given(interactor, .blockCompletionRequest(courseID: .any, blockID: .any, willProduce: {_ in})) @@ -131,6 +135,7 @@ final class VideoPlayerViewModelTests: XCTestCase { languages: [], interactor: interactor, router: router, + appStorage: CoreStorageMock(), connectivity: connectivity) Given(interactor, .blockCompletionRequest(courseID: .any, blockID: .any, willThrow: NSError())) @@ -155,6 +160,7 @@ final class VideoPlayerViewModelTests: XCTestCase { languages: [], interactor: interactor, router: router, + appStorage: CoreStorageMock(), connectivity: connectivity) Given(interactor, .blockCompletionRequest(courseID: .any, blockID: .any, willThrow: noInternetError)) diff --git a/OpenEdX/DI/ScreenAssembly.swift b/OpenEdX/DI/ScreenAssembly.swift index 33f396f20..f826a23b6 100644 --- a/OpenEdX/DI/ScreenAssembly.swift +++ b/OpenEdX/DI/ScreenAssembly.swift @@ -281,6 +281,7 @@ class ScreenAssembly: Assembly { playerStateSubject: playerStateSubject, interactor: r.resolve(CourseInteractorProtocol.self)!, router: r.resolve(CourseRouter.self)!, + appStorage: r.resolve(CoreStorage.self)!, connectivity: r.resolve(ConnectivityProtocol.self)! ) } @@ -295,7 +296,8 @@ class ScreenAssembly: Assembly { languages: languages, playerStateSubject: playerStateSubject, interactor: r.resolve(CourseInteractorProtocol.self)!, - router: r.resolve(CourseRouter.self)!, + router: r.resolve(CourseRouter.self)!, + appStorage: r.resolve(CoreStorage.self)!, connectivity: r.resolve(ConnectivityProtocol.self)! ) } diff --git a/OpenEdX/Data/AppStorage.swift b/OpenEdX/Data/AppStorage.swift index 0815b4b7f..f674ebd9b 100644 --- a/OpenEdX/Data/AppStorage.swift +++ b/OpenEdX/Data/AppStorage.swift @@ -95,7 +95,7 @@ public class AppStorage: CoreStorage, ProfileStorage, WhatsNewStorage { public var userSettings: UserSettings? { get { guard let userSettings = userDefaults.data(forKey: KEY_SETTINGS) else { - let defaultSettings = UserSettings(wifiOnly: true, downloadQuality: .auto) + let defaultSettings = UserSettings(wifiOnly: true, streamingQuality: .auto) let encoder = JSONEncoder() if let encoded = try? encoder.encode(defaultSettings) { userDefaults.set(encoded, forKey: KEY_SETTINGS) diff --git a/Profile/Profile/Data/ProfileRepository.swift b/Profile/Profile/Data/ProfileRepository.swift index 935b9b039..e445f5225 100644 --- a/Profile/Profile/Data/ProfileRepository.swift +++ b/Profile/Profile/Data/ProfileRepository.swift @@ -144,7 +144,7 @@ public class ProfileRepository: ProfileRepositoryProtocol { if let userSettings = storage.userSettings { return userSettings } else { - return UserSettings(wifiOnly: true, downloadQuality: VideoQuality.auto) + return UserSettings(wifiOnly: true, streamingQuality: StreamingQuality.auto) } } @@ -233,7 +233,7 @@ class ProfileRepositoryMock: ProfileRepositoryProtocol { public func deleteAccount(password: String) async throws -> Bool { return false } public func getSettings() -> UserSettings { - return UserSettings(wifiOnly: true, downloadQuality: .auto) + return UserSettings(wifiOnly: true, streamingQuality: .auto) } public func saveSettings(_ settings: UserSettings) {} } diff --git a/Profile/Profile/Presentation/Settings/SettingsViewModel.swift b/Profile/Profile/Presentation/Settings/SettingsViewModel.swift index 99e11ba38..3126c0a00 100644 --- a/Profile/Profile/Presentation/Settings/SettingsViewModel.swift +++ b/Profile/Profile/Presentation/Settings/SettingsViewModel.swift @@ -22,15 +22,15 @@ public class SettingsViewModel: ObservableObject { } } - @Published var selectedQuality: VideoQuality { + @Published var selectedQuality: StreamingQuality { willSet { if newValue != selectedQuality { - userSettings.downloadQuality = newValue + userSettings.streamingQuality = newValue interactor.saveSettings(userSettings) } } } - let quality = Array([VideoQuality.auto, VideoQuality.low, VideoQuality.medium, VideoQuality.high].enumerated()) + let quality = Array([StreamingQuality.auto, StreamingQuality.low, StreamingQuality.medium, StreamingQuality.high].enumerated()) var errorMessage: String? { didSet { @@ -51,11 +51,11 @@ public class SettingsViewModel: ObservableObject { self.userSettings = interactor.getSettings() self.wifiOnly = userSettings.wifiOnly - self.selectedQuality = userSettings.downloadQuality + self.selectedQuality = userSettings.streamingQuality } } -extension VideoQuality { +extension StreamingQuality { func title() -> String { switch self { diff --git a/Profile/Profile/SwiftGen/Strings.swift b/Profile/Profile/SwiftGen/Strings.swift index 80eadde89..8ff79c726 100644 --- a/Profile/Profile/SwiftGen/Strings.swift +++ b/Profile/Profile/SwiftGen/Strings.swift @@ -104,8 +104,8 @@ public enum ProfileLocalization { public static let title = ProfileLocalization.tr("Localizable", "LOGOUT_ALERT.TITLE", fallback: "Comfirm log out") } public enum Settings { - /// Smallest video quality - public static let quality360Description = ProfileLocalization.tr("Localizable", "SETTINGS.QUALITY_360_DESCRIPTION", fallback: "Smallest video quality") + /// Lower data usage + public static let quality360Description = ProfileLocalization.tr("Localizable", "SETTINGS.QUALITY_360_DESCRIPTION", fallback: "Lower data usage") /// 360p public static let quality360Title = ProfileLocalization.tr("Localizable", "SETTINGS.QUALITY_360_TITLE", fallback: "360p") /// 540p @@ -128,8 +128,8 @@ public enum ProfileLocalization { public static let version = ProfileLocalization.tr("Localizable", "SETTINGS.VERSION", fallback: "Version:") /// Auto (Recommended) public static let videoQualityDescription = ProfileLocalization.tr("Localizable", "SETTINGS.VIDEO_QUALITY_DESCRIPTION", fallback: "Auto (Recommended)") - /// Video download quality - public static let videoQualityTitle = ProfileLocalization.tr("Localizable", "SETTINGS.VIDEO_QUALITY_TITLE", fallback: "Video download quality") + /// Video streaming quality + public static let videoQualityTitle = ProfileLocalization.tr("Localizable", "SETTINGS.VIDEO_QUALITY_TITLE", fallback: "Video streaming quality") /// Video settings public static let videoSettingsTitle = ProfileLocalization.tr("Localizable", "SETTINGS.VIDEO_SETTINGS_TITLE", fallback: "Video settings") /// Only download content when wi-fi is turned on diff --git a/Profile/Profile/en.lproj/Localizable.strings b/Profile/Profile/en.lproj/Localizable.strings index 750c061f9..c626255de 100644 --- a/Profile/Profile/en.lproj/Localizable.strings +++ b/Profile/Profile/en.lproj/Localizable.strings @@ -58,13 +58,13 @@ "SETTINGS.VIDEO_SETTINGS_TITLE" = "Video settings"; "SETTINGS.WIFI_TITLE" = "Wi-fi only download"; "SETTINGS.WIFI_DESCRIPTION" = "Only download content when wi-fi is turned on"; -"SETTINGS.VIDEO_QUALITY_TITLE" = "Video download quality"; +"SETTINGS.VIDEO_QUALITY_TITLE" = "Video streaming quality"; "SETTINGS.VIDEO_QUALITY_DESCRIPTION" = "Auto (Recommended)"; "SETTINGS.QUALITY_AUTO_TITLE" = "Auto"; "SETTINGS.QUALITY_AUTO_DESCRIPTION" = "Recommended"; "SETTINGS.QUALITY_360_TITLE" = "360p"; -"SETTINGS.QUALITY_360_DESCRIPTION" = "Smallest video quality"; +"SETTINGS.QUALITY_360_DESCRIPTION" = "Lower data usage"; "SETTINGS.QUALITY_540_TITLE" = "540p"; "SETTINGS.QUALITY_720_TITLE" = "720p"; "SETTINGS.QUALITY_720_DESCRIPTION" = "Best quality"; diff --git a/Profile/Profile/uk.lproj/Localizable.strings b/Profile/Profile/uk.lproj/Localizable.strings index 0387a60d9..8b5df15fd 100644 --- a/Profile/Profile/uk.lproj/Localizable.strings +++ b/Profile/Profile/uk.lproj/Localizable.strings @@ -58,13 +58,13 @@ "SETTINGS.VIDEO_SETTINGS_TITLE" = "Налаштування відео"; "SETTINGS.WIFI_TITLE" = "Тільки Wi-fi"; "SETTINGS.WIFI_DESCRIPTION" = "Завантажувати відео, лише коли Wi-Fi увімкнено"; -"SETTINGS.VIDEO_QUALITY_TITLE" = "Якість відео"; +"SETTINGS.VIDEO_QUALITY_TITLE" = "Якість потокового відео"; "SETTINGS.VIDEO_QUALITY_DESCRIPTION" = "Авто (Рекомендовано)"; "SETTINGS.QUALITY_AUTO_TITLE" = "Авто"; "SETTINGS.QUALITY_AUTO_DESCRIPTION" = "Рекомендовано"; "SETTINGS.QUALITY_360_TITLE" = "360p"; -"SETTINGS.QUALITY_360_DESCRIPTION" = "Найменша якість відео"; +"SETTINGS.QUALITY_360_DESCRIPTION" = "економія трафіку"; "SETTINGS.QUALITY_540_TITLE" = "540p"; "SETTINGS.QUALITY_720_TITLE" = "720p"; "SETTINGS.QUALITY_AUTO_DESCRIPTION" = "Найкраща якість"; From 1f51cfe8e771545d9dc51af2433c18bd6a562718 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Mon, 6 Nov 2023 18:19:08 +0200 Subject: [PATCH 013/158] Update UserProfileView.swift (#150) Co-authored-by: stepanokdev <100592747+Stepanokdev@users.noreply.github.com> --- .../Presentation/Profile/UserProfile/UserProfileView.swift | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Profile/Profile/Presentation/Profile/UserProfile/UserProfileView.swift b/Profile/Profile/Presentation/Profile/UserProfile/UserProfileView.swift index da5a7f9dc..17fc43bae 100644 --- a/Profile/Profile/Presentation/Profile/UserProfile/UserProfileView.swift +++ b/Profile/Profile/Presentation/Profile/UserProfile/UserProfileView.swift @@ -19,6 +19,8 @@ public struct UserProfileView: View { public var body: some View { ZStack(alignment: .top) { + Theme.Colors.background + .ignoresSafeArea() // MARK: - Page Body RefreshableScrollViewCompat(action: { await viewModel.getUserProfile(withProgress: false) @@ -97,10 +99,6 @@ public struct UserProfileView: View { await viewModel.getUserProfile() } } - .background( - Theme.Colors.background - .ignoresSafeArea() - ) } } From 7256a71b7a5c52126f727ea7e3c0d52ee811224b Mon Sep 17 00:00:00 2001 From: Muhammad Umer Date: Wed, 8 Nov 2023 14:26:43 +0500 Subject: [PATCH 014/158] chore: course dates feature (#149) * chore: update view top margin, handle case for today dat with the blocks, refactoring * chore: address feedback, disable block if content is verified only * refactor: remove unused import --- Course/Course/Data/CourseRepository.swift | 28 +-- Course/Course/Domain/Model/CourseDates.swift | 45 ++--- .../Presentation/Dates/CourseDatesView.swift | 182 ++++++++++-------- .../Unit/CourseDateViewModelTests.swift | 38 ++-- 4 files changed, 158 insertions(+), 135 deletions(-) diff --git a/Course/Course/Data/CourseRepository.swift b/Course/Course/Data/CourseRepository.swift index 80ec3c3ef..e07f75bb8 100644 --- a/Course/Course/Data/CourseRepository.swift +++ b/Course/Course/Data/CourseRepository.swift @@ -1085,7 +1085,7 @@ And there are various ways of describing it-- call it oral poetry or }, { "assignment_type": "Problem Set", - "complete": false, + "complete": true, "date": "2023-09-14T23:30:00Z", "date_type": "assignment-due-date", "description": "", @@ -1096,19 +1096,19 @@ And there are various ways of describing it-- call it oral poetry or "extra_info": null, "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@problem+block@bd89c1dd129240f99bb8c5cbe3f56530" }, - { - "assignment_type": "Problem Set", - "complete": false, - "date": "2023-09-14T23:30:00Z", - "date_type": "assignment-due-date", - "description": "", - "learner_has_access": true, - "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@ca19e125470846f2a36ad1225410e39aa", - "link_text": "", - "title": "Problem Set 1.1", - "extra_info": null, - "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@problem+block@bd89c1dd129240f99bb8c5cbe3f56530a" - }, + { + "assignment_type": "Problem Set", + "complete": false, + "date": "2023-09-14T23:30:00Z", + "date_type": "assignment-due-date", + "description": "", + "learner_has_access": true, + "link": "", + "link_text": "", + "title": "Problem Set 1.1", + "extra_info": null, + "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@problem+block@bd89c1dd129240f99bb8c5cbe3f56530a" + }, { "assignment_type": "Problem Set", "complete": false, diff --git a/Course/Course/Domain/Model/CourseDates.swift b/Course/Course/Domain/Model/CourseDates.swift index e60265941..0909610d4 100644 --- a/Course/Course/Domain/Model/CourseDates.swift +++ b/Course/Course/Domain/Model/CourseDates.swift @@ -19,9 +19,14 @@ public struct CourseDates { var hasToday = false let today = Date.today + let calendar = Calendar.current + let todayComponents = calendar.dateComponents([.year, .month, .day], from: .today) + for block in courseDateBlocks { let date = block.date - if date == today { + let dateComponents = calendar.dateComponents([.year, .month, .day], from: date) + + if dateComponents == todayComponents { hasToday = true } @@ -66,7 +71,10 @@ extension Date { } var isToday: Bool { - return Date.compare(self, to: .today) == .orderedSame + let calendar = Calendar.current + let selfComponents = calendar.dateComponents([.year, .month, .day], from: self) + let todayComponents = calendar.dateComponents([.year, .month, .day], from: .today) + return selfComponents == todayComponents } var isInFuture: Bool { @@ -74,7 +82,9 @@ extension Date { } } -public struct CourseDateBlock { +public struct CourseDateBlock: Identifiable { + public let id: UUID = UUID() + let assignmentType: String? let complete: Bool? let date: Date @@ -86,12 +96,8 @@ public struct CourseDateBlock { let extraInfo: String? let firstComponentBlockID: String - var blockTitle: String { - if isToday { - return CoreLocalization.CourseDates.today - } else { - return blockStatus.title - } + var formattedDate: String { + return date.dateToString(style: .shortWeekdayMonthDayYear) } var isInPast: Bool { @@ -138,6 +144,10 @@ public struct CourseDateBlock { return !isUnreleased && isLearnerAssignment } + var isAvailable: Bool { + return learnerHasAccess && (!isUnreleased || !isLearnerAssignment) + } + var blockStatus: BlockStatus { if isComplete { return .completed @@ -191,21 +201,4 @@ public enum BlockStatus { default: return .event } } - - var title: String { - switch self { - case .completed: - return CoreLocalization.CourseDates.completed - case .pastDue: - return CoreLocalization.CourseDates.pastDue - case .dueNext: - return CoreLocalization.CourseDates.dueNext - case .unreleased: - return CoreLocalization.CourseDates.unreleased - case .verifiedOnly: - return CoreLocalization.CourseDates.verifiedOnly - default: - return "" - } - } } diff --git a/Course/Course/Presentation/Dates/CourseDatesView.swift b/Course/Course/Presentation/Dates/CourseDatesView.swift index 34774e1f0..f3a7d9d59 100644 --- a/Course/Course/Presentation/Dates/CourseDatesView.swift +++ b/Course/Course/Presentation/Dates/CourseDatesView.swift @@ -21,7 +21,7 @@ public struct CourseDatesView: View { viewModel: CourseDatesViewModel ) { self.courseID = courseID - self._viewModel = StateObject(wrappedValue: { viewModel }()) + self._viewModel = StateObject(wrappedValue: viewModel) } public var body: some View { @@ -35,6 +35,7 @@ public struct CourseDatesView: View { } } else if let courseDates = viewModel.courseDates, !courseDates.courseDateBlocks.isEmpty { CourseDateListView(viewModel: viewModel, courseDates: courseDates) + .padding(.top, 10) } } if viewModel.showError { @@ -59,7 +60,6 @@ public struct CourseDatesView: View { Theme.Colors.background .ignoresSafeArea() ) - .frame(maxWidth: .infinity, maxHeight: .infinity) } } @@ -74,28 +74,23 @@ struct Line: Shape { } struct TimeLineView: View { + let block: CourseDateBlock let date: Date let firstDate: Date? let lastDate: Date? + let allHaveSameStatus: Bool var body: some View { ZStack(alignment: .top) { - if lastDate == date { - VStack { - Line() - .stroke(style: StrokeStyle(lineWidth: 1)) - .frame(maxHeight: 10.0, alignment: .top) - Spacer() - } - } else if firstDate == date { - Line() - .stroke(style: StrokeStyle(lineWidth: 1)) - .frame(maxHeight: .infinity, alignment: .top) - .padding(.top, 10) - } else { + VStack { Line() .stroke(style: StrokeStyle(lineWidth: 1)) - .frame(maxHeight: .infinity, alignment: .top) + .frame(maxHeight: lastDate == date ? 10 : .infinity, alignment: .top) + .padding(.top, firstDate == date && lastDate != date ? 10 : 0) + + if lastDate == date { + Spacer() + } } Circle() @@ -104,9 +99,17 @@ struct TimeLineView: View { if date.isToday { return Theme.Colors.warning } else if date.isInPast { - return Color.gray - } else { + switch block.blockStatus { + case .completed: return allHaveSameStatus ? Color.white : Color.gray + case .courseStartDate: return Color.white + case .verifiedOnly: return Color.black + case .pastDue: return Color.gray + default: return Color.gray + } + } else if date.isInFuture { return Color.black + } else { + return Color.white } }()) .overlay(Circle().stroke(Color.black, lineWidth: 1)) @@ -126,18 +129,19 @@ struct CourseDateListView: View { VStack(alignment: .leading, spacing: 0) { ForEach(viewModel.sortedDates, id: \.self) { date in let blocks = courseDates.sortedDateToCourseDateBlockDict[date]! + let block = blocks[0] HStack(alignment: .center) { - TimeLineView(date: date, - firstDate: viewModel.sortedDates.first, - lastDate: viewModel.sortedDates.last) - let ignoredStatuses: [BlockStatus] = [.courseStartDate, .courseEndDate] - let block = blocks[0] let allHaveSameStatus = blocks .filter { !ignoredStatuses.contains($0.blockStatus) } .allSatisfy { $0.blockStatus == block.blockStatus } + TimeLineView(block: block, date: date, + firstDate: viewModel.sortedDates.first, + lastDate: viewModel.sortedDates.last, + allHaveSameStatus: allHaveSameStatus) + BlockStatusView(block: block, allHaveSameStatus: allHaveSameStatus, blocks: blocks) @@ -161,13 +165,13 @@ struct BlockStatusView: View { var body: some View { VStack(alignment: .leading) { HStack { - Text(block.date.dateToString(style: .shortWeekdayMonthDayYear)) - .font(.subheadline) + Text(block.formattedDate) + .font(Theme.Fonts.bodyLarge) .bold() if block.isToday { - Text(block.blockTitle) - .font(.footnote) + Text(CoreLocalization.CourseDates.today) + .font(Theme.Fonts.bodySmall) .foregroundColor(Color.black) .padding(EdgeInsets(top: 2, leading: 6, bottom: 2, trailing: 8)) .background(Theme.Colors.warning) @@ -176,91 +180,113 @@ struct BlockStatusView: View { if allHaveSameStatus { let lockImageText = block.isVerifiedOnly ? Text(Image(systemName: "lock.fill")) : Text("") - Text("\(lockImageText) \(block.blockTitle)") - .font(.footnote) - .foregroundColor(determineForegroundColor(for: block.blockStatus)) + Text("\(lockImageText) \(block.blockStatus.title)") + .font(Theme.Fonts.bodySmall) + .foregroundColor(block.blockStatus.foregroundColor) .padding(EdgeInsets(top: 2, leading: 6, bottom: 2, trailing: 8)) - .background(determineBackgroundColor(for: block.blockStatus)) + .background(block.blockStatus.backgroundColor) .cornerRadius(5) } } - ForEach(blocks, id: \.firstComponentBlockID) { block in + ForEach(blocks) { block in styleBlock(block: block, allHaveSameStatus: allHaveSameStatus) } + .padding(.top, 0.2) } .padding(.vertical, 0) .padding(.leading, 5) .padding(.bottom, 10) } - private func determineForegroundColor(for status: BlockStatus) -> Color { - switch status { - case .verifiedOnly: return Color.white - case .pastDue: return Color.black - case .dueNext: return Color.white - default: return Color.white.opacity(0) - } - } - - private func determineBackgroundColor(for status: BlockStatus) -> Color { - switch status { - case .verifiedOnly: return Color.black.opacity(0.5) - case .pastDue: return Color.gray.opacity(0.4) - case .dueNext: return Color.black.opacity(0.5) - default: return Color.white.opacity(0) - } - } - func styleBlock(block: CourseDateBlock, allHaveSameStatus: Bool) -> some View { - var attrString = AttributedString("") + var attributedString = AttributedString("") if let prefix = block.assignmentType, !prefix.isEmpty { - attrString += AttributedString("\(prefix): ") + attributedString += AttributedString("\(prefix): ") } - attrString += block.canShowLink ? getAttributedUnderlineString(string: block.title) : AttributedString(block.title) + attributedString += styleTitle(block: block) if !allHaveSameStatus { - attrString += " " - let (status, foregroundColor, backgroundColor) = getStatusDetails(for: block.blockStatus) - attrString += getAttributedString(string: status, forgroundColor: foregroundColor, backgroundColor: backgroundColor) + attributedString.appendSpaces(2) + attributedString += applyStyle( + string: block.blockStatus.title, + forgroundColor: block.blockStatus.foregroundColor, + backgroundColor: block.blockStatus.backgroundColor) } - return Text(attrString).padding(.bottom, 2).font(.footnote) + return Text(attributedString) + .font(Theme.Fonts.bodyMedium) + .foregroundColor({ + if block.isAssignment { + return block.isAvailable ? Color.black : Color.gray.opacity(0.6) + } else { + return Color.black + } + }()) + .onTapGesture { + + } } - func getStatusDetails(for blockStatus: BlockStatus) -> (String, Color, Color) { - switch blockStatus { - case .verifiedOnly: - return (CoreLocalization.CourseDates.verifiedOnly, Color.white, Color.black.opacity(0.5)) - case .pastDue: - return (CoreLocalization.CourseDates.pastDue, Color.black, Color.gray.opacity(0.4)) - case .dueNext: - return (CoreLocalization.CourseDates.dueNext, Color.white, Color.black.opacity(0.5)) - case .unreleased: - return (CoreLocalization.CourseDates.unreleased, Color.white.opacity(0), Color.white.opacity(0)) - default: - return ("", Color.white.opacity(0), Color.white.opacity(0)) + func styleTitle(block: CourseDateBlock) -> AttributedString { + var attributedString = AttributedString(block.title) + attributedString.font = Theme.Fonts.bodyMedium + if block.canShowLink && !block.firstComponentBlockID.isEmpty { + attributedString.underlineStyle = .single } - } - - func getAttributedUnderlineString(string: String) -> AttributedString { - var attributedString = AttributedString(string) - attributedString.font = .footnote - attributedString.underlineStyle = .single return attributedString } - - func getAttributedString(string: String, forgroundColor: Color, backgroundColor: Color) -> AttributedString { + + func applyStyle(string: String, forgroundColor: Color, backgroundColor: Color) -> AttributedString { var attributedString = AttributedString(string) - attributedString.font = .footnote + attributedString.font = Theme.Fonts.bodySmall attributedString.foregroundColor = forgroundColor attributedString.backgroundColor = backgroundColor return attributedString } } +fileprivate extension BlockStatus { + var title: String { + switch self { + case .completed: return CoreLocalization.CourseDates.completed + case .pastDue: return CoreLocalization.CourseDates.pastDue + case .dueNext: return CoreLocalization.CourseDates.dueNext + case .unreleased: return CoreLocalization.CourseDates.unreleased + case .verifiedOnly: return CoreLocalization.CourseDates.verifiedOnly + default: return "" + } + } + + var foregroundColor: Color { + switch self { + case .completed: return Color.white + case .verifiedOnly: return Color.white + case .pastDue: return Color.black + case .dueNext: return Color.white + default: return Color.white.opacity(0) + } + } + + var backgroundColor: Color { + switch self { + case .completed: return Color.black.opacity(0.5) + case .verifiedOnly: return Color.black.opacity(0.5) + case .pastDue: return Color.gray.opacity(0.4) + case .dueNext: return Color.black.opacity(0.5) + default: return Color.white.opacity(0) + } + } +} + +fileprivate extension AttributedString { + mutating func appendSpaces(_ count: Int = 1) { + self += AttributedString(String(repeating: " ", count: count)) + } +} + #if DEBUG struct CourseDatesView_Previews: PreviewProvider { static var previews: some View { diff --git a/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift b/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift index 0b6f6f9bf..690b93325 100644 --- a/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift +++ b/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift @@ -5,12 +5,11 @@ // Created by Muhammad Umer on 10/24/23. // -import SwiftyMocky import XCTest +import Alamofire +import SwiftyMocky @testable import Core @testable import Course -import Alamofire -import SwiftUI final class CourseDateViewModelTests: XCTestCase { func testGetCourseDatesSuccess() async throws { @@ -70,7 +69,7 @@ final class CourseDateViewModelTests: XCTestCase { Verify(interactor, .getCourseDates(courseID: .any)) XCTAssertTrue(viewModel.showError) - XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.unknownError) + XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.unknownError, "Error view should be shown on unknown error.") } func testNoInternetConnectionError() async throws { @@ -95,7 +94,7 @@ final class CourseDateViewModelTests: XCTestCase { Verify(interactor, .getCourseDates(courseID: .any)) XCTAssertTrue(viewModel.showError) - XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.slowOrNoInternetConnection) + XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.slowOrNoInternetConnection, "Error message should be set to 'slow or no internet connection'.") } func testSortedDateTodayToCourseDateBlockDict() { @@ -195,7 +194,7 @@ final class CourseDateViewModelTests: XCTestCase { let block = CourseDateBlock( assignmentType: nil, complete: nil, - date: Date(), + date: Date.today, dateType: "assignment-due-date", description: "", learnerHasAccess: true, @@ -219,12 +218,12 @@ final class CourseDateViewModelTests: XCTestCase { learnerHasAccess: false, link: "www.example.com", linkText: nil, - title: "TestBlock", + title: CoreLocalization.CourseDates.today, extraInfo: nil, firstComponentBlockID: "blockIDTest" ) - XCTAssertEqual(block.blockTitle, "Today", "Block title for 'today' should be 'Today'") + XCTAssertEqual(block.title, "Today", "Block title for 'today' should be 'Today'") } func testBadgeLogicForCompleted() { @@ -366,7 +365,7 @@ final class CourseDateViewModelTests: XCTestCase { firstComponentBlockID: "blockIDTest" ) - XCTAssertTrue(block.isVerifiedOnly) + XCTAssertTrue(block.isVerifiedOnly, "Block should be identified as 'verified only' when the learner has no access.") } func testIsCompleted() { @@ -384,7 +383,7 @@ final class CourseDateViewModelTests: XCTestCase { firstComponentBlockID: "blockIDTest" ) - XCTAssertTrue(block.isComplete) + XCTAssertTrue(block.isComplete, "Block should be marked as completed.") } func testBadgeLogicForUnreleasedAssignment() { @@ -402,7 +401,7 @@ final class CourseDateViewModelTests: XCTestCase { firstComponentBlockID: "blockIDTest" ) - XCTAssertEqual(block.blockStatus, .unreleased) + XCTAssertEqual(block.blockStatus, .unreleased, "Block status should be set to 'unreleased' for unreleased assignments.") } func testNoLinkForUnavailableAssignment() { @@ -420,7 +419,8 @@ final class CourseDateViewModelTests: XCTestCase { firstComponentBlockID: "blockIDTest" ) - XCTAssertFalse(block.canShowLink) + XCTAssertEqual(block.blockStatus, .verifiedOnly) + XCTAssertFalse(block.canShowLink, "Block should not show a link if the assignment is unavailable.") } func testNoLinkAvailableForUnreleasedAssignment() { @@ -430,7 +430,7 @@ final class CourseDateViewModelTests: XCTestCase { date: Date.today, dateType: "assignment-due-date", description: "", - learnerHasAccess: false, + learnerHasAccess: true, link: "", linkText: nil, title: "TestBlock", @@ -438,23 +438,27 @@ final class CourseDateViewModelTests: XCTestCase { firstComponentBlockID: "blockIDTest" ) - XCTAssertFalse(block.canShowLink) + XCTAssertEqual(block.blockStatus, .unreleased) + XCTAssertFalse(block.canShowLink, "Block should not show a link if the assignment is unreleased.") } func testTodayProperty() { let today = Date.today let currentDay = Calendar.current.startOfDay(for: Date()) + XCTAssertTrue(today.isToday, "The today property should return true for isToday.") XCTAssertEqual(today, currentDay, "The today property should equal the start of the current day.") } func testDateIsInPastProperty() { let pastDate = Date().addingTimeInterval(-100000) - XCTAssertTrue(pastDate.isInPast) + XCTAssertTrue(pastDate.isInPast, "The past date should return true for isInPast.") + XCTAssertFalse(pastDate.isToday, "The past date should return false for isInPast.") } func testDateIsInFutureProperty() { let futureDate = Date().addingTimeInterval(100000) - XCTAssertTrue(futureDate.isInFuture) + XCTAssertTrue(futureDate.isInFuture, "The future date should return false for isInFuture.") + XCTAssertFalse(futureDate.isToday, "The future date should return false for isInFuture.") } func testBlockStatusMapping() { @@ -464,6 +468,6 @@ final class CourseDateViewModelTests: XCTestCase { XCTAssertEqual(BlockStatus.status(of: "verification-deadline-date"), .verificationDeadlineDate, "Incorrect mapping for 'verification-deadline-date'") XCTAssertEqual(BlockStatus.status(of: "verified-upgrade-deadline"), .verifiedUpgradeDeadline, "Incorrect mapping for 'verified-upgrade-deadline'") XCTAssertEqual(BlockStatus.status(of: "assignment-due-date"), .assignment, "Incorrect mapping for 'assignment-due-date'") - XCTAssertEqual(BlockStatus.status(of: ""), .event, "Incorrect mapping for ''") + XCTAssertEqual(BlockStatus.status(of: ""), .event, "Incorrect mapping for 'event'") } } From cdcfdb60bcf8ce6851f9eb8c46fe50b6931ade11 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Wed, 8 Nov 2023 16:35:34 +0200 Subject: [PATCH 015/158] Update SubtittlesView.swift (#152) Co-authored-by: stepanokdev <100592747+Stepanokdev@users.noreply.github.com> --- Course/Course/Presentation/Video/SubtittlesView.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Course/Course/Presentation/Video/SubtittlesView.swift b/Course/Course/Presentation/Video/SubtittlesView.swift index 200dee93c..492d08f19 100644 --- a/Course/Course/Presentation/Video/SubtittlesView.swift +++ b/Course/Course/Presentation/Video/SubtittlesView.swift @@ -16,6 +16,8 @@ public struct Subtitle { public struct SubtittlesView: View { + @Environment (\.isHorizontal) private var isHorizontal + @ObservedObject private var viewModel: VideoPlayerViewModel private var scrollTo: ((Date) -> Void) = { _ in } @@ -37,7 +39,7 @@ public struct SubtittlesView: View { public var body: some View { ScrollViewReader { scroll in - VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 16) { HStack { Text(viewModel.subtitles.isEmpty ? "" : CourseLocalization.Subtitles.title) .font(Theme.Fonts.titleMedium) @@ -97,6 +99,7 @@ public struct SubtittlesView: View { } }.padding(.horizontal, 24) .padding(.top, 16) + .padding(.bottom, isHorizontal ? 100 : 16) } } From 0d28f7dcb1feb27730dc4d14aaae5942991f08a6 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Fri, 17 Nov 2023 13:51:00 +0200 Subject: [PATCH 016/158] Feature/in app review system (#148) * add AppReviewView * add viewmodel * work in progress * Add third party mail clients support * In-App Review system * add localizations * Update Assets.swift * bug fixes [header] the system bar disappears if a user used the rate us pop-up [rate_us] the user isn't able to submit a feedback in the landscape mode [email_feedback] the email template formatting must have a user/device info [email_agent] iOS agent displays bit a user cannot use it (the icon isn't clickable) * Update AppReview logic Added a check to ensure that the rating window is displayed no more than once every 4 months. Corrected copyright in ThirdPartyMailer * fixes move requestReview logic to AppReviewView replace if/else logic to switch in AppReviewViewModel * Fix merge conflicts push SKStoreReviewController logic to extension for iOS 15 compatibility --------- Co-authored-by: stepanokdev <100592747+Stepanokdev@users.noreply.github.com> --- Core/Core.xcodeproj/project.pbxproj | 56 ++++++ .../favorite.imageset/Contents.json | 12 ++ .../favorite.imageset/favorite.svg | 4 + .../Assets.xcassets/mailClients/Contents.json | 6 + .../airmail.imageset/Contents.json | 12 ++ .../mailClients/airmail.imageset/preview.jpg | Bin 0 -> 25803 bytes .../defaultMail.imageset/Contents.json | 12 ++ .../defaultMail.imageset/Mail_(iOS).svg | 180 ++++++++++++++++++ .../fastmail.imageset/Contents.json | 12 ++ .../fastmail.imageset/fastmail.jpeg | Bin 0 -> 29875 bytes .../googlegmail.imageset/Contents.json | 12 ++ .../googlegmail.imageset/gmail-2015-07-30.png | Bin 0 -> 16139 bytes .../ms-outlook.imageset/Contents.json | 12 ++ .../microsoft-outlook-2015-02-09.png | Bin 0 -> 14913 bytes .../mailClients/proton.imageset/Contents.json | 12 ++ .../mailClients/proton.imageset/unnamed.png | Bin 0 -> 27518 bytes .../readdle-spark.imageset/Contents.json | 12 ++ ...mail-smart-email-inbox-2023-01-05.png.jpeg | Bin 0 -> 50586 bytes .../mailClients/ymail.imageset/Contents.json | 12 ++ .../mailClients/ymail.imageset/image-2.png | Bin 0 -> 160347 bytes .../star.imageset/Contents.json | 12 ++ .../Assets.xcassets/star.imageset/star.svg | 3 + .../star_outline.imageset/Contents.json | 15 ++ .../star_outline.imageset/star_outline.svg | 3 + Core/Core/Data/CoreStorage.swift | 18 +- .../SKStoreReviewControllerExtension.swift | 20 ++ Core/Core/SwiftGen/Assets.swift | 11 ++ Core/Core/SwiftGen/Strings.swift | 34 ++++ .../View/Base/AppReview/AppReviewView.swift | 144 ++++++++++++++ .../Base/AppReview/AppReviewViewModel.swift | 155 +++++++++++++++ .../AppReview/Elements/AppReviewButton.swift | 43 +++++ .../Elements/SelectMailClientView.swift | 88 +++++++++ .../AppReview/Elements/StarRatingView.swift | 34 ++++ .../ThirdPartyMailClient.swift | 147 ++++++++++++++ .../ThirdPartyMailer/ThirdPartyMailer.swift | 53 ++++++ Core/Core/en.lproj/Localizable.strings | 17 ++ Core/Core/uk.lproj/Localizable.strings | 18 ++ Course/Course/Presentation/CourseRouter.swift | 4 + .../Video/EncodedVideoPlayer.swift | 6 +- .../Video/YouTubeVideoPlayerViewModel.swift | 4 + OpenEdX/Data/AppStorage.swift | 31 +++ OpenEdX/Info.plist | 10 + OpenEdX/Router.swift | 12 ++ 43 files changed, 1229 insertions(+), 7 deletions(-) create mode 100644 Core/Core/Assets.xcassets/favorite.imageset/Contents.json create mode 100644 Core/Core/Assets.xcassets/favorite.imageset/favorite.svg create mode 100644 Core/Core/Assets.xcassets/mailClients/Contents.json create mode 100644 Core/Core/Assets.xcassets/mailClients/airmail.imageset/Contents.json create mode 100644 Core/Core/Assets.xcassets/mailClients/airmail.imageset/preview.jpg create mode 100644 Core/Core/Assets.xcassets/mailClients/defaultMail.imageset/Contents.json create mode 100644 Core/Core/Assets.xcassets/mailClients/defaultMail.imageset/Mail_(iOS).svg create mode 100644 Core/Core/Assets.xcassets/mailClients/fastmail.imageset/Contents.json create mode 100644 Core/Core/Assets.xcassets/mailClients/fastmail.imageset/fastmail.jpeg create mode 100644 Core/Core/Assets.xcassets/mailClients/googlegmail.imageset/Contents.json create mode 100644 Core/Core/Assets.xcassets/mailClients/googlegmail.imageset/gmail-2015-07-30.png create mode 100644 Core/Core/Assets.xcassets/mailClients/ms-outlook.imageset/Contents.json create mode 100644 Core/Core/Assets.xcassets/mailClients/ms-outlook.imageset/microsoft-outlook-2015-02-09.png create mode 100644 Core/Core/Assets.xcassets/mailClients/proton.imageset/Contents.json create mode 100644 Core/Core/Assets.xcassets/mailClients/proton.imageset/unnamed.png create mode 100644 Core/Core/Assets.xcassets/mailClients/readdle-spark.imageset/Contents.json create mode 100644 Core/Core/Assets.xcassets/mailClients/readdle-spark.imageset/spark-mail-smart-email-inbox-2023-01-05.png.jpeg create mode 100644 Core/Core/Assets.xcassets/mailClients/ymail.imageset/Contents.json create mode 100644 Core/Core/Assets.xcassets/mailClients/ymail.imageset/image-2.png create mode 100644 Core/Core/Assets.xcassets/star.imageset/Contents.json create mode 100644 Core/Core/Assets.xcassets/star.imageset/star.svg create mode 100644 Core/Core/Assets.xcassets/star_outline.imageset/Contents.json create mode 100644 Core/Core/Assets.xcassets/star_outline.imageset/star_outline.svg create mode 100644 Core/Core/Extensions/SKStoreReviewControllerExtension.swift create mode 100644 Core/Core/View/Base/AppReview/AppReviewView.swift create mode 100644 Core/Core/View/Base/AppReview/AppReviewViewModel.swift create mode 100644 Core/Core/View/Base/AppReview/Elements/AppReviewButton.swift create mode 100644 Core/Core/View/Base/AppReview/Elements/SelectMailClientView.swift create mode 100644 Core/Core/View/Base/AppReview/Elements/StarRatingView.swift create mode 100644 Core/Core/View/Base/AppReview/ThirdPartyMailer/ThirdPartyMailClient.swift create mode 100644 Core/Core/View/Base/AppReview/ThirdPartyMailer/ThirdPartyMailer.swift diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index eed739e2e..a402a3b72 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -17,6 +17,9 @@ 02284C182A3B1AE00007117F /* UIApplicationExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02284C172A3B1AE00007117F /* UIApplicationExtension.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 */; }; + 0233D5712AF13EC800BAC8BD /* SelectMailClientView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0233D5702AF13EC800BAC8BD /* SelectMailClientView.swift */; }; + 0233D5732AF13EEE00BAC8BD /* AppReviewButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0233D5722AF13EEE00BAC8BD /* AppReviewButton.swift */; }; 0236961928F9A26900EEF206 /* AuthRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0236961828F9A26900EEF206 /* AuthRepository.swift */; }; 0236961B28F9A28B00EEF206 /* AuthInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0236961A28F9A28B00EEF206 /* AuthInteractor.swift */; }; 0236961D28F9A2D200EEF206 /* Data_AuthResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0236961C28F9A2D200EEF206 /* Data_AuthResponse.swift */; }; @@ -62,18 +65,23 @@ 028F9F39293A452B00DE65D0 /* ResetPassword.swift in Sources */ = {isa = PBXBuildFile; fileRef = 028F9F38293A452B00DE65D0 /* ResetPassword.swift */; }; 0295B1DC297FF114003B0C65 /* SF-Pro.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 0295B1DA297FF0E9003B0C65 /* SF-Pro.ttf */; }; 0295C885299B99DD00ABE571 /* RefreshableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0295C884299B99DD00ABE571 /* RefreshableScrollView.swift */; }; + 02A463112AEA966C00331037 /* AppReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A463102AEA966C00331037 /* AppReviewView.swift */; }; 02A4833529B8A73400D33F33 /* CorePersistenceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A4833429B8A73400D33F33 /* CorePersistenceProtocol.swift */; }; 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 */; }; + 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 */; }; 02B3E3B32930198600A50475 /* AVPlayerViewControllerExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B3E3B22930198600A50475 /* AVPlayerViewControllerExtension.swift */; }; 02B3F16E2AB489A400DDDD4E /* RefreshableScrollViewCompat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B3F16D2AB489A400DDDD4E /* RefreshableScrollViewCompat.swift */; }; 02C2DC0829B63D6200F4445D /* WebViewHTML.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C2DC0729B63D6200F4445D /* WebViewHTML.swift */; }; 02C917F029CDA99E00DBB8BD /* Data_Dashboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C917EF29CDA99E00DBB8BD /* Data_Dashboard.swift */; }; 02CF46C829546AA200A698EE /* NoCachedDataError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02CF46C729546AA200A698EE /* NoCachedDataError.swift */; }; + 02D400612B0678190029D168 /* SKStoreReviewControllerExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02D400602B0678190029D168 /* SKStoreReviewControllerExtension.swift */; }; 02D800CC29348F460099CF16 /* ImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02D800CB29348F460099CF16 /* ImagePicker.swift */; }; 02E225B0291D29EB0067769A /* UrlExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02E225AF291D29EB0067769A /* UrlExtension.swift */; }; + 02E93F852AEBAEBC006C4750 /* AppReviewViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02E93F842AEBAEBC006C4750 /* AppReviewViewModel.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 */; }; @@ -137,6 +145,9 @@ 02284C172A3B1AE00007117F /* UIApplicationExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIApplicationExtension.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 = ""; }; + 0233D5702AF13EC800BAC8BD /* SelectMailClientView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectMailClientView.swift; sourceTree = ""; }; + 0233D5722AF13EEE00BAC8BD /* AppReviewButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppReviewButton.swift; sourceTree = ""; }; 0236961828F9A26900EEF206 /* AuthRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthRepository.swift; sourceTree = ""; }; 0236961A28F9A28B00EEF206 /* AuthInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthInteractor.swift; sourceTree = ""; }; 0236961C28F9A2D200EEF206 /* Data_AuthResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_AuthResponse.swift; sourceTree = ""; }; @@ -181,18 +192,23 @@ 028F9F38293A452B00DE65D0 /* ResetPassword.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetPassword.swift; sourceTree = ""; }; 0295B1DA297FF0E9003B0C65 /* SF-Pro.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "SF-Pro.ttf"; sourceTree = ""; }; 0295C884299B99DD00ABE571 /* RefreshableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshableScrollView.swift; sourceTree = ""; }; + 02A463102AEA966C00331037 /* AppReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppReviewView.swift; sourceTree = ""; }; 02A4833429B8A73400D33F33 /* CorePersistenceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CorePersistenceProtocol.swift; sourceTree = ""; }; 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 = ""; }; + 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 = ""; }; 02B3E3B22930198600A50475 /* AVPlayerViewControllerExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AVPlayerViewControllerExtension.swift; sourceTree = ""; }; 02B3F16D2AB489A400DDDD4E /* RefreshableScrollViewCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshableScrollViewCompat.swift; sourceTree = ""; }; 02C2DC0729B63D6200F4445D /* WebViewHTML.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewHTML.swift; sourceTree = ""; }; 02C917EF29CDA99E00DBB8BD /* Data_Dashboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_Dashboard.swift; sourceTree = ""; }; 02CF46C729546AA200A698EE /* NoCachedDataError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoCachedDataError.swift; sourceTree = ""; }; + 02D400602B0678190029D168 /* SKStoreReviewControllerExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SKStoreReviewControllerExtension.swift; sourceTree = ""; }; 02D800CB29348F460099CF16 /* ImagePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePicker.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 = ""; }; 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 = ""; }; @@ -266,6 +282,16 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 0233D56D2AF13EA400BAC8BD /* Elements */ = { + isa = PBXGroup; + children = ( + 0233D56E2AF13EB200BAC8BD /* StarRatingView.swift */, + 0233D5702AF13EC800BAC8BD /* SelectMailClientView.swift */, + 0233D5722AF13EEE00BAC8BD /* AppReviewButton.swift */, + ); + path = Elements; + sourceTree = ""; + }; 0236961728F9A21600EEF206 /* Repository */ = { isa = PBXGroup; children = ( @@ -341,6 +367,7 @@ 0727878228D31287002E9142 /* DispatchQueue+App.swift */, 07DDFCBC29A780BB00572595 /* UINavigationController+Animation.swift */, 02284C172A3B1AE00007117F /* UIApplicationExtension.swift */, + 02D400602B0678190029D168 /* SKStoreReviewControllerExtension.swift */, ); path = Extensions; sourceTree = ""; @@ -353,6 +380,15 @@ path = Fonts; sourceTree = ""; }; + 02AFCC162AEFDB0F000360F0 /* ThirdPartyMailer */ = { + isa = PBXGroup; + children = ( + 02AFCC172AEFDB24000360F0 /* ThirdPartyMailClient.swift */, + 02AFCC192AEFDC18000360F0 /* ThirdPartyMailer.swift */, + ); + path = ThirdPartyMailer; + sourceTree = ""; + }; 02CF46C92954A42100A698EE /* Persistence */ = { isa = PBXGroup; children = ( @@ -363,6 +399,17 @@ path = Persistence; sourceTree = ""; }; + 02E93F862AEBAED4006C4750 /* AppReview */ = { + isa = PBXGroup; + children = ( + 0233D56D2AF13EA400BAC8BD /* Elements */, + 02AFCC162AEFDB0F000360F0 /* ThirdPartyMailer */, + 02A463102AEA966C00331037 /* AppReviewView.swift */, + 02E93F842AEBAEBC006C4750 /* AppReviewViewModel.swift */, + ); + path = AppReview; + sourceTree = ""; + }; 0727876E28D233EC002E9142 /* Configuration */ = { isa = PBXGroup; children = ( @@ -536,6 +583,7 @@ 027BD3C42909707700392132 /* Shake.swift */, 023A1135291432B200D0D354 /* RegistrationTextField.swift */, 023A1137291432FD00D0D354 /* FieldConfiguration.swift */, + 02E93F862AEBAED4006C4750 /* AppReview */, ); path = Base; sourceTree = ""; @@ -776,6 +824,7 @@ 022C64E429AE0191000F532B /* TextWithUrls.swift in Sources */, 0283348028D4DCD200C828FC /* ViewExtension.swift in Sources */, 02A4833529B8A73400D33F33 /* CorePersistenceProtocol.swift in Sources */, + 0233D5732AF13EEE00BAC8BD /* AppReviewButton.swift in Sources */, 02512FF0299533DF0024D438 /* CoreDataHandlerProtocol.swift in Sources */, 0260E58028FD792800BBBE18 /* WebUnitViewModel.swift in Sources */, 02A4833A29B8A9AB00D33F33 /* DownloadManager.swift in Sources */, @@ -783,6 +832,7 @@ 027BD3BE2909478B00392132 /* UIResponder+CurrentResponder.swift in Sources */, 070019AE28F701B200D5FC78 /* Certificate.swift in Sources */, 076F297F2A1F80C800967E7D /* Pagination.swift in Sources */, + 02AFCC1A2AEFDC18000360F0 /* ThirdPartyMailer.swift in Sources */, 0770DE5F28D0B22C006D8A5D /* Strings.swift in Sources */, 02C917F029CDA99E00DBB8BD /* Data_Dashboard.swift in Sources */, 024FCD0028EF1CD300232339 /* WebBrowser.swift in Sources */, @@ -793,6 +843,7 @@ 021D925728DCF12900ACC565 /* AlertView.swift in Sources */, 027BD3A82909474200392132 /* KeyboardAvoidingViewController.swift in Sources */, 0770DE7B28D0C78C006D8A5D /* Theme.swift in Sources */, + 02E93F852AEBAEBC006C4750 /* AppReviewViewModel.swift in Sources */, 0770DE2528D08FBA006D8A5D /* CoreStorage.swift in Sources */, 020306CC2932C0C4000949EA /* PickerView.swift in Sources */, 027BD3C52909707700392132 /* Shake.swift in Sources */, @@ -812,9 +863,12 @@ 0248C92329C075EF00DC8402 /* CourseBlockModel.swift in Sources */, 072787B628D37A0E002E9142 /* Validator.swift in Sources */, 0236961D28F9A2D200EEF206 /* Data_AuthResponse.swift in Sources */, + 02AFCC182AEFDB24000360F0 /* ThirdPartyMailClient.swift in Sources */, + 0233D5712AF13EC800BAC8BD /* SelectMailClientView.swift in Sources */, 02B2B594295C5C7A00914876 /* Thread.swift in Sources */, 027DB33528D8C8FE002B6862 /* Data_MyCourse.swift in Sources */, 027BD3BD2909478B00392132 /* UIView+EnclosingScrollView.swift in Sources */, + 02D400612B0678190029D168 /* SKStoreReviewControllerExtension.swift in Sources */, 02A4833C29B8C57800D33F33 /* DownloadView.swift in Sources */, 027BD3AD2909475000392132 /* KeyboardScroller.swift in Sources */, 070019A528F6F17900D5FC78 /* Data_Media.swift in Sources */, @@ -837,8 +891,10 @@ 0236961928F9A26900EEF206 /* AuthRepository.swift in Sources */, 023A1136291432B200D0D354 /* RegistrationTextField.swift in Sources */, 02C2DC0829B63D6200F4445D /* WebViewHTML.swift in Sources */, + 02A463112AEA966C00331037 /* AppReviewView.swift in Sources */, 025B36752A13B7D5001A640E /* UnitButtonView.swift in Sources */, 028F9F39293A452B00DE65D0 /* ResetPassword.swift in Sources */, + 0233D56F2AF13EB200BAC8BD /* StarRatingView.swift in Sources */, 027BD3B82909476200392132 /* DismissKeyboardTapViewModifier.swift in Sources */, 024BE3DF29B2615500BCDEE2 /* CGColorExtension.swift in Sources */, 0770DE6128D0B2CB006D8A5D /* Assets.swift in Sources */, diff --git a/Core/Core/Assets.xcassets/favorite.imageset/Contents.json b/Core/Core/Assets.xcassets/favorite.imageset/Contents.json new file mode 100644 index 000000000..b1b3b208a --- /dev/null +++ b/Core/Core/Assets.xcassets/favorite.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "favorite.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Core/Core/Assets.xcassets/favorite.imageset/favorite.svg b/Core/Core/Assets.xcassets/favorite.imageset/favorite.svg new file mode 100644 index 000000000..70056997f --- /dev/null +++ b/Core/Core/Assets.xcassets/favorite.imageset/favorite.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Core/Core/Assets.xcassets/mailClients/Contents.json b/Core/Core/Assets.xcassets/mailClients/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/Core/Core/Assets.xcassets/mailClients/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Core/Core/Assets.xcassets/mailClients/airmail.imageset/Contents.json b/Core/Core/Assets.xcassets/mailClients/airmail.imageset/Contents.json new file mode 100644 index 000000000..e0ebe7ad4 --- /dev/null +++ b/Core/Core/Assets.xcassets/mailClients/airmail.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "preview.jpg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Core/Core/Assets.xcassets/mailClients/airmail.imageset/preview.jpg b/Core/Core/Assets.xcassets/mailClients/airmail.imageset/preview.jpg new file mode 100644 index 0000000000000000000000000000000000000000..a355fff107dfdc13e47283b9c7c8659af4d35a5d GIT binary patch literal 25803 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + diff --git a/Core/Core/Assets.xcassets/mailClients/fastmail.imageset/Contents.json b/Core/Core/Assets.xcassets/mailClients/fastmail.imageset/Contents.json new file mode 100644 index 000000000..77fd4f1b9 --- /dev/null +++ b/Core/Core/Assets.xcassets/mailClients/fastmail.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "fastmail.jpeg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Core/Core/Assets.xcassets/mailClients/fastmail.imageset/fastmail.jpeg b/Core/Core/Assets.xcassets/mailClients/fastmail.imageset/fastmail.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..c689b851eb6ade231b09ffd76df8be60bb7cc71c GIT binary patch literal 29875 zcmeFZ2Uru^);=BtMFmBgAT26YP!R+v0#OkW5l~d3AVft#L`0gjkSL&Z5D-uVB27R_ zl-?t~3ep9nBy^-CKtd8yeuL+pdygK^z4v_geE;YFJ>e!X&2-fPx>oCBYOPF~X1(*uqQhgB30A3LOY^!y=hQ$2%o zMn>06ub#Sa>9mrz;+f0mwa;juQ#3eynqT<*~Bt2W{eJVdZ9F)`7r4 zJ2$g@+rHff{9)O|%C>n6`_^sSIe-facY!vsu(EDqW8J)Y<7ZgBfzLr~+?#p!DV*HG zd+{dwen&pV#}P?e#ZSGj<~Q!dOB}uBPEkS zXYfn6fWN==?1z4F1O3{>#>UFVzR@q1O>P?<=VsfyPhkts$&2ha9eMXFKHkcADkAB9 z^)~UN#(4f)PMzC#ODJI_2^(Ge*0X=FV?O_*p8dUJ|LE5cNE@{2+s3*HcuK6Sz>{JF zw$1FDH#YXI?BBMnf7`Zy+c-A19e>}Lz?ZN9-vQjb1^B;X8~e7ufA(*mFoywAKFAyd zak8=i!o+t_eTiC(;M7bX%gI=tqaLObH zf&Q=T%+CLt&F=cY+3fDWu-Pa+;|>|0VWDfni^&J9Atw|KK#D;mc}_+LMi_o0Q{JD( zpKZh$pWQX|>dslsx+uyW21Lo8Qw-wctC&|ld<0^x}Xm>`=?N8sDuFm@ba7(*enGw0J8@SgP)Ca75$Sts9yoSug)J1j#ITjZ!)P$V__ zD*&3klWVdLW_u} zDSQ;DRfWW3D7<*5AbL&@6Ev0xX1m`TmXKjb+UaRu_VhB?_rp~tX!OdII)=sz)wi<# z1b1~72A37bY}*UIBobgWY4HYJuO*gAxa>#_rXThoR%o^-;j8t#^oj2&Cv^~y$i9qi zy#y~qlcrV^%3%*t!vpRm> z?STd-*Bg;TR?trk7bB;F%QWId~NBnQd*@mpHl(*)BG9G6h7Z_cPUe|Z@?g5kuBp_m{(n#q0)VX{Yz z=lwmf823!0}3+VKO<0!`rJPTtF7MA+=;>N?YULGZk?yo(RMsrbr)Jw z>y&N|e2Xj2TK`eSK8T&xr(WcknRoxHR)_4EM*kjLL#=kr$&~ft^?8I8SsV+l>j>lt zpmAd>wXd8yakPG$nQO;dy7zmo`h1u*8O+$$O%Q-`e<81!zr)iEeELYQN3TDgIeTCi zME5Q|pt#tlkFTgObYGA{p?K)2w~s%weBf6`n8lGRpJW)C8S%Urqu9SFqa1V zSjq0u8iZK4lHc_kgQktVB5nuY?g=lI=(p5Cc)hTp#HcYrh5gSfUOikdqHz^g-~KEj zyT$m$fzO&9m!!191SIMV zPM0L^&+{6K)XW+S4}{?jjoAkMYmmF5dZP^b*cs}v3gNAAq{$L2ePTpzQm|Z`FX4d4 zS1@fGA`{FK=kEOSCR*e86>&gzP%{G*OC3+6I(a`ZPZj?#J*qKo^GHluP~(HvDbv>Q zMI*79q1ke+-l<-y37Gd>rLu|N^JJ3ku=fo4T#xLg^_RgplyeE4Q^+>cr{B6-y-Cc^aT?+G` zcwba&j_>7Kj9ae>BId=ffnzR{N`!K`T5aZvkV+nhwFje*itCuox>ye{rs=U9SIr-vWsBzEolYKL!TP7?&uN2Cz+sI7<+7ofXtP< zVGd%tGy)l6XHo^FfS*q%3iYOYmfkLnU9Myp*{l?|(V)qd;y73hBmlgX=$qocq!&~D zIb!n%F{_*=a6M=3B=U{+&N1k#{&{z}f$VDUT^{4$fpiy!%@qI4=C=EAZ)lF=O}-oB2sBl?ou=zdBI zf~Mr!@#~JGqqMy0BsqtoN5n0&{=BM;G5Y64SaCZ+fbWK)saXG>Ll&Jd-c?xEC(^PZ zxWPKwJ5fme&;-c>z50b*K4vtZMi3sq_x`TqX&1@=1>cMwPj)LKSqCq11M z(38_w1h>C_01^SonlkR;5;fYKU#dI7pCcoX3mppx^N4jc7aqYbch2#8FXSa7IDL9* zL!3_%AM5a4VEGUBE$o3bK#cdybd$a6J?*Qq(yxc;JGcr)a3$@TI5Pji=eQ6plf1Id zdUtSd2?0`V6LmSyyh}gXa3~iiYBVqb`TA%+)PMD$9kDNnYAA-QXq3w8@I{sP$zR39 zB9*P(J^FW!<)4GUc=5GM|4bD!H^yUk}c zPx4z}UOR+C^7?C+!j474uGc9cd6IAKu;|@F+6&Km5Wg*IPMc-zstdY~2?~a7rg`IG zFm-{@d*^NUL2hIG4nIwvL0-F!q$&mhalRuj*yADl!q(@OI+8Q zwxnb=y*%4^Nn|fUqR!=F4e{2Nqw6{NZYC* z(Z<+J=6!Rp3~qUeAVPRj(IBxVo4;f7|}N_ndv^p-ZMpO__%L`?1O{Yv*ZFbO0&c zhVjA$5*>Km?b`b)L_S71hxYG29=^@#t(ry6g-s~jpeM}{*3(;GS^0&>bhs``TT#hL zcfY^x89~_=r>){XTw5@bkh`$YZ|p%9@Y7_qJ1e#+F4U*~yADado9h}3(?6|Vfb`In zi}=9s8AI}l2TgvkUM@6vPBLDOwVBRe_F^d1S8SFQ`P^1$AzVVVuq#_0_Oz!Y>F`14 z8z+IoA6N|jPIrfzveBR=L?a;33gL<-IGO6jkDt8N6KmcWUxY?|+UdTjPBtX!18b)8 zq6ItoXvSTJMH#82Re6EBXfBsOBSY%}x2kConz!T3+bpJxtJ> zyPn7j4=OaH-#_@H*_sTU37W7uKsvhmQO>aQUbj(KPS@awR-oVqYT`uV#EL23MT?{M z<@raFgATC#39?7-D|jH5WZqw;es>(&3m;agCK)f3SF+xVHLSc*)Ku-TgxSZQb;Fp^ zI+`zqCUl!K`uf<4w79yhHjx$((6WdJ28E>z0@$0n7c#OJx>)7=dS*nRGn)p%uB&D% zecc+vVRuL1o?}BL2%M>@H?abXB0t7$YQ5CDyyn^AMnlnyCwu6^zN!l2aSh{FG7rVB zAV1Jr7`ySs073A2fNWXQr(ip1<#$W<5LWOCYo=x^Z>xetRI^$RRR%pZ7cFUQ=_!7&KJyyh(du z)!FZ%;3LN%3uqLxP;0jyh1JIm-iIYMq|G-iS&+H(qaP z+i5zt&NC4)&iG!Ai^c;d#h9Qk?4wg8QM%#^xUvs{Xb!xvL6S?7$5jhsAk zw#!W`sFp=1Osvk|9=Zvr))C+-z1{@uT>mD+CpR-XP)_pd*V1&aFy&J0sExA|pBsDx zb~W0+HE`_X-dTF*ir5D{WUCgRd+fMM=sb-d`+Tq)thhM){6+S{W!j2fSZ#7xb{$I} zCdue2atBQTpHkf?wjCcYW{?m`>pG?}6!>cCqa0VoqKEXm4IJGG=~^+irxleu&sN#A>|F2AP%zSNxb!AODvLepz-D#+s=c> zNCZyV4C3O|#A{)CfW+p^$)G2x(UNLvABQW=yp3kIo;nibcO??A$glr0gZvZ2&bo{t zbT> z$b12soTxcNoGb=VMKyA74H(h(LbNig9{<;kXC%Tf$tvkGK>%v%GUK&D>$cF*wFl5xS@!&TF$tB}QpSWW9M}iLt zeQEa5Xf_K-5z8HB@6Mc;wUXrb&;(6C0^mclG-zjPe<>L}_K|^}oU@P~wX%NDdS5m1 z;u8G>atWPoO5$Rp9qiPQ;#r>Hpq~YuUzY#QfI@#Tpp_d2H04(zP3A;M5GB^6qkd~a zK^JxSe%)D^|C-E|yQJNz?7(BU+XDP4C$*x-bx+se;K;t%$ZI?KxaK@rnsIqwNgO18 zdV=m#r2vMadriMkRn|5kB_8y#BIBJldzU5Ry+?46wNUspA3;vCEi$I`2z6&YDHm`1 ziBc?^(McJ}m}mK%-thbT-Luc5)s7d_UFOTgsW5imv?oa-nw#&`UjyvLb(^!l>Fuw* z{!5Q0Cgn-9T9Tb;I3#zl(JOs!muBV1ij)Hq#+*kW{)pGhawb6;h8H8Bo5%4br^esT zeMwtJbU`*L!yb!i#Gc)+AIg4F-Z5q5T|3#i&Ct{1-NY86;C&jL2gtFP?w(ywE0edadkT-ZoBAfBs0TO#Mz3{LxK=w*S7k`GHsnJE9(pf zVp9$*W*=$s%G`o^3)jLCud-8=%sG@ct>}d zckbUntrmc48!Nc+HWzwnmHKt-^Y_~K*f;XFXfi=1To|p|9B=KkS<{EX)^UjF`!Rh4 zdQZp=Z@oFaMMHx-7h!6F^*z~3(&?5`Lx2$}{VC^(Wd18sztH6n*JZ-7LQCxM&Mjp< z%5nh<_DL4#gW3GL@0uq_B0cTr6r7^+bj}<$I3v4&GDI|CSWx4-bxIX|Hx6NuTuN^0 zQQXVd_2TR85*au5AWyTGZ>P_PsbWKK1)kaudi(as+muH@u(sJl`oof6nNc;;U){l{ zU^wsknJPq*Q_7Yp(h1Hy=k}w^uje0aC(beS#&cz2TwjzuLzX>#W?>G?#>gG^Uk%@i zr#WSB_lsSU%&zo;V~)QAs6AXq=Kq8{`XAjxb|w|kUnv;{9AC1Pov3$8_hxv>Y;NFwB_|oH8&{r(_;6j&P4gU(at>%AJZrd_5J$T-4Bz10 zEu5o`+a5&Km<-IXYi$q?Aew8}YR>U9`T9F#M~>UMH?~BCaAhv)EgC5&S-d9%f7mf5 z&)>Y`EqB`JVoBcpmTeSVNA_(3qnUq#NI&4y(A{t$Og5_GHk$6S28kh%g-SF|<9mD4 z1Z?OYi-5PyO$qrU!1FIyZaE?^W7`V;?DgyBw0r$yEhlcj`_!j1#{@mZjoExc%yQ*2 zLFWL9#Cgoz3m}z~5>y^bz zL+voU3h{ksrw%{uGh*(~@+JT2^&Z9b4y-!k9b3Q52Ba+XjeWUVx}eBaj?&$8iP&+M z))A?;F9Q4WoSrbB>rJ2RbT;m}9f*qlYGi@YV+9bP+RW=+^*_7jLU!YaEIItfIYO@axMJE4xMum>FjFdl39qe8K*8q z`tc8Sf-@j^BQC#F-3C1j#W_5Kx{i36x+uCg)PGPCyi~H4E4erakqDI^LvORSV8h2m zj4kjeh=xVj@@JpmynW^fkVz_7h_U<w~%R{er4_Im^XlNeHhd!UyeME>QUQr zNu-#?XnV0f?ARKRDVPABkUM+cchAX0suH*3cS(YcW3`zsQXJ@*G;suDXL8&z}k z&9dxs9>>7wLRZC;P z^ZX@ylj5133l&zWBQ^GiI&6SA?Ym68oIg1jyHäAf1Y3KeRX%F0B>XufaBQ8| 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%0Da*= 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 literal 0 HcmV?d00001 diff --git a/Core/Core/Assets.xcassets/mailClients/ms-outlook.imageset/Contents.json b/Core/Core/Assets.xcassets/mailClients/ms-outlook.imageset/Contents.json new file mode 100644 index 000000000..6bd18085b --- /dev/null +++ b/Core/Core/Assets.xcassets/mailClients/ms-outlook.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "microsoft-outlook-2015-02-09.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Core/Core/Assets.xcassets/mailClients/ms-outlook.imageset/microsoft-outlook-2015-02-09.png b/Core/Core/Assets.xcassets/mailClients/ms-outlook.imageset/microsoft-outlook-2015-02-09.png new file mode 100644 index 0000000000000000000000000000000000000000..fdbdb4616632dae0842f7939ac5ec6d893ab8f4e GIT binary patch literal 14913 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 d4M};0YQTP7>a2(LXnUDPYRt`48gp literal 0 HcmV?d00001 diff --git a/Core/Core/Assets.xcassets/mailClients/ymail.imageset/Contents.json b/Core/Core/Assets.xcassets/mailClients/ymail.imageset/Contents.json new file mode 100644 index 000000000..61ceb1f24 --- /dev/null +++ b/Core/Core/Assets.xcassets/mailClients/ymail.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "image-2.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Core/Core/Assets.xcassets/mailClients/ymail.imageset/image-2.png b/Core/Core/Assets.xcassets/mailClients/ymail.imageset/image-2.png new file mode 100644 index 0000000000000000000000000000000000000000..065c53e60ddb3d50eba33ec75397390c7c2c8c5b GIT binary patch literal 160347 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 literal 0 HcmV?d00001 diff --git a/Core/Core/Assets.xcassets/star.imageset/Contents.json b/Core/Core/Assets.xcassets/star.imageset/Contents.json new file mode 100644 index 000000000..fb87640df --- /dev/null +++ b/Core/Core/Assets.xcassets/star.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "star.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Core/Core/Assets.xcassets/star.imageset/star.svg b/Core/Core/Assets.xcassets/star.imageset/star.svg new file mode 100644 index 000000000..1d79798d1 --- /dev/null +++ b/Core/Core/Assets.xcassets/star.imageset/star.svg @@ -0,0 +1,3 @@ + + + diff --git a/Core/Core/Assets.xcassets/star_outline.imageset/Contents.json b/Core/Core/Assets.xcassets/star_outline.imageset/Contents.json new file mode 100644 index 000000000..dde435c5d --- /dev/null +++ b/Core/Core/Assets.xcassets/star_outline.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "star_outline.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Core/Core/Assets.xcassets/star_outline.imageset/star_outline.svg b/Core/Core/Assets.xcassets/star_outline.imageset/star_outline.svg new file mode 100644 index 000000000..aa8b674a5 --- /dev/null +++ b/Core/Core/Assets.xcassets/star_outline.imageset/star_outline.svg @@ -0,0 +1,3 @@ + + + diff --git a/Core/Core/Data/CoreStorage.swift b/Core/Core/Data/CoreStorage.swift index 3fe13dc7e..a2d14dc86 100644 --- a/Core/Core/Data/CoreStorage.swift +++ b/Core/Core/Data/CoreStorage.swift @@ -11,18 +11,24 @@ public protocol CoreStorage { var accessToken: String? {get set} var refreshToken: String? {get set} var cookiesDate: String? {get set} + var reviewLastShownVersion: String? {get set} + var lastReviewDate: Date? {get set} var user: DataLayer.User? {get set} var userSettings: UserSettings? {get set} func clear() } -public struct CoreStorageMock: CoreStorage { - public var accessToken: String? = nil - public var refreshToken: String? = nil - public var cookiesDate: String? = nil - public var user: DataLayer.User? = nil - public var userSettings: UserSettings? = nil +#if DEBUG +public class CoreStorageMock: CoreStorage { + public var accessToken: String? + public var refreshToken: String? + public var cookiesDate: String? + public var reviewLastShownVersion: String? + public var lastReviewDate: Date? + public var user: DataLayer.User? + public var userSettings: UserSettings? public func clear() {} public init() {} } +#endif diff --git a/Core/Core/Extensions/SKStoreReviewControllerExtension.swift b/Core/Core/Extensions/SKStoreReviewControllerExtension.swift new file mode 100644 index 000000000..be214f661 --- /dev/null +++ b/Core/Core/Extensions/SKStoreReviewControllerExtension.swift @@ -0,0 +1,20 @@ +// +// SKStoreReviewControllerExtension.swift +// Core +// +// Created by  Stepanok Ivan on 16.11.2023. +// + +import Foundation +import StoreKit + +extension SKStoreReviewController { + public static func requestReviewInCurrentScene() { + if let scene = UIApplication.shared.connectedScenes + .first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene { + DispatchQueue.main.async { + requestReview(in: scene) + } + } + } +} diff --git a/Core/Core/SwiftGen/Assets.swift b/Core/Core/SwiftGen/Assets.swift index 61c33b049..bd88577ed 100644 --- a/Core/Core/SwiftGen/Assets.swift +++ b/Core/Core/SwiftGen/Assets.swift @@ -104,10 +104,21 @@ public enum CoreAssets { public static let check = ImageAsset(name: "check") public static let clearInput = ImageAsset(name: "clearInput") public static let edit = ImageAsset(name: "edit") + public static let favorite = ImageAsset(name: "favorite") public static let goodWork = ImageAsset(name: "goodWork") + public static let airmail = ImageAsset(name: "airmail") + public static let defaultMail = ImageAsset(name: "defaultMail") + public static let fastmail = ImageAsset(name: "fastmail") + public static let googlegmail = ImageAsset(name: "googlegmail") + public static let msOutlook = ImageAsset(name: "ms-outlook") + public static let proton = ImageAsset(name: "proton") + public static let readdleSpark = ImageAsset(name: "readdle-spark") + public static let ymail = ImageAsset(name: "ymail") public static let noCourseImage = ImageAsset(name: "noCourseImage") public static let notAvaliable = ImageAsset(name: "notAvaliable") public static let playVideo = ImageAsset(name: "playVideo") + public static let star = ImageAsset(name: "star") + public static let starOutline = ImageAsset(name: "star_outline") public static let warningFilled = ImageAsset(name: "warning_filled") } // swiftlint:enable identifier_name line_length nesting type_body_length type_name diff --git a/Core/Core/SwiftGen/Strings.swift b/Core/Core/SwiftGen/Strings.swift index 41a0fcb7d..fc1a73163 100644 --- a/Core/Core/SwiftGen/Strings.swift +++ b/Core/Core/SwiftGen/Strings.swift @@ -137,6 +137,40 @@ public enum CoreLocalization { /// Search public static let search = CoreLocalization.tr("Localizable", "PICKER.SEARCH", fallback: "Search") } + public enum Review { + /// What could have been better? + public static let better = CoreLocalization.tr("Localizable", "REVIEW.BETTER", fallback: "What could have been better?") + /// We’re sorry to hear your learning experience has had some issues. We appreciate all feedback. + public static let feedbackDescription = CoreLocalization.tr("Localizable", "REVIEW.FEEDBACK_DESCRIPTION", fallback: "We’re sorry to hear your learning experience has had some issues. We appreciate all feedback.") + /// Leave Us Feedback + public static let feedbackTitle = CoreLocalization.tr("Localizable", "REVIEW.FEEDBACK_TITLE", fallback: "Leave Us Feedback") + /// Not now + public static let notNow = CoreLocalization.tr("Localizable", "REVIEW.NOT_NOW", fallback: "Not now") + /// We received your feedback and will use it to help improve your learning experience going forward. Thank you for sharing! + public static let thanksForFeedbackDescription = CoreLocalization.tr("Localizable", "REVIEW.THANKS_FOR_FEEDBACK_DESCRIPTION", fallback: "We received your feedback and will use it to help improve your learning experience going forward. Thank you for sharing!") + /// Thank You + public static let thanksForFeedbackTitle = CoreLocalization.tr("Localizable", "REVIEW.THANKS_FOR_FEEDBACK_TITLE", fallback: "Thank You") + /// Thank you for sharing your feedback with us. Would you like to share your review of this app with other users on the app store? + public static let thanksForVoteDescription = CoreLocalization.tr("Localizable", "REVIEW.THANKS_FOR_VOTE_DESCRIPTION", fallback: "Thank you for sharing your feedback with us. Would you like to share your review of this app with other users on the app store?") + /// Thank You + public static let thanksForVoteTitle = CoreLocalization.tr("Localizable", "REVIEW.THANKS_FOR_VOTE_TITLE", fallback: "Thank You") + /// Your feedback matters to us. Would you take a moment to rate the app by tapping a star below? Thanks for your support! + public static let voteDescription = CoreLocalization.tr("Localizable", "REVIEW.VOTE_DESCRIPTION", fallback: "Your feedback matters to us. Would you take a moment to rate the app by tapping a star below? Thanks for your support!") + /// Enjoying Open edX? + public static let voteTitle = CoreLocalization.tr("Localizable", "REVIEW.VOTE_TITLE", fallback: "Enjoying Open edX?") + public enum Button { + /// Rate Us + public static let rateUs = CoreLocalization.tr("Localizable", "REVIEW.BUTTON.RATE_US", fallback: "Rate Us") + /// Share Feedback + public static let shareFeedback = CoreLocalization.tr("Localizable", "REVIEW.BUTTON.SHARE_FEEDBACK", fallback: "Share Feedback") + /// Submit + public static let submit = CoreLocalization.tr("Localizable", "REVIEW.BUTTON.SUBMIT", fallback: "Submit") + } + public enum Email { + /// Select email client: + public static let title = CoreLocalization.tr("Localizable", "REVIEW.EMAIL.TITLE", fallback: "Select email client:") + } + } public enum View { public enum Snackbar { /// Try Again diff --git a/Core/Core/View/Base/AppReview/AppReviewView.swift b/Core/Core/View/Base/AppReview/AppReviewView.swift new file mode 100644 index 000000000..7884caf97 --- /dev/null +++ b/Core/Core/View/Base/AppReview/AppReviewView.swift @@ -0,0 +1,144 @@ +// +// AppReviewView.swift +// Core +// +// Created by  Stepanok Ivan on 26.10.2023. +// + +import SwiftUI +import StoreKit + +public struct AppReviewView: View { + + @ObservedObject private var viewModel: AppReviewViewModel + + @Environment (\.isHorizontal) private var isHorizontal + @Environment (\.presentationMode) private var presentationMode + + public init(viewModel: AppReviewViewModel) { + self.viewModel = viewModel + } + + public var body: some View { + ZStack { + Color.black.opacity(0.5) + .ignoresSafeArea() + .onTapGesture { + presentationMode.wrappedValue.dismiss() + } + if viewModel.showSelectMailClientView { + SelectMailClientView(clients: viewModel.clients, onMailTapped: { client in + viewModel.openMailClient(client) + }) + } else { + VStack(spacing: 20) { + if viewModel.state == .thanksForFeedback || viewModel.state == .thanksForVote { + CoreAssets.favorite.swiftUIImage + .resizable() + .frame(width: isHorizontal ? 50 : 100, + height: isHorizontal ? 50 : 100) + .foregroundColor(Theme.Colors.accentColor) + .onForeground { + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + self.presentationMode.wrappedValue.dismiss() + } + } + } + Text(viewModel.state.title) + .font(Theme.Fonts.titleMedium) + Text(viewModel.state.description) + .font(Theme.Fonts.titleSmall) + .foregroundColor(Theme.Colors.avatarStroke) + .multilineTextAlignment(.center) + switch viewModel.state { + case .vote: + StarRatingView(rating: $viewModel.rating) + + HStack(spacing: 28) { + Text(CoreLocalization.Review.notNow) + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.accentColor) + .onTapGesture { presentationMode.wrappedValue.dismiss() } + + AppReviewButton(type: .submit, action: { + viewModel.reviewAction() + }, isActive: .constant(viewModel.rating != 0)) + } + + case .feedback: + TextEditor(text: $viewModel.feedback) + .padding(.horizontal, 12) + .padding(.vertical, 4) + .hideScrollContentBackground() + .background( + Theme.Shapes.textInputShape + .fill(Theme.Colors.commentCellBackground) + ) + .overlay( + ZStack(alignment: .topLeading) { + Theme.Shapes.textInputShape + .stroke(lineWidth: 1) + .fill( + Theme.Colors.textInputStroke + ) + if viewModel.feedback.isEmpty { + Text(CoreLocalization.Review.better) + .font(Theme.Fonts.bodyMedium) + .foregroundColor(Theme.Colors.textSecondary) + .padding(16) + } + } + ) + .frame(height: viewModel.showReview ? (isHorizontal ? 80 : 162) : 0) + .opacity(viewModel.showReview ? 1 : 0) + + HStack(spacing: 28) { + Text(CoreLocalization.Review.notNow) + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.accentColor) + .onTapGesture { presentationMode.wrappedValue.dismiss() } + + AppReviewButton(type: .shareFeedback, action: { + viewModel.writeFeedbackToMail() + }, isActive: .constant(viewModel.feedback.count >= 3)) + } + + case .thanksForVote, .thanksForFeedback: + HStack(spacing: 28) { + Text(CoreLocalization.Review.notNow) + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.accentColor) + .onTapGesture { presentationMode.wrappedValue.dismiss() } + + AppReviewButton(type: .rateUs, action: { + presentationMode.wrappedValue.dismiss() + SKStoreReviewController.requestReviewInCurrentScene() + viewModel.storage.lastReviewDate = Date() + }, isActive: .constant(true)) + } + } + + }.onTapGesture { + UIApplication.shared.sendAction( + #selector(UIResponder.resignFirstResponder), + to: nil, from: nil, for: nil + ) + } + .padding(isHorizontal ? 20 : 40) + .background(Theme.Colors.background) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .frame(maxWidth: 400) + .padding(isHorizontal ? 14 : 24) + .shadow(color: Color.black.opacity(0.4), radius: 12, x: 0, y: 0) + } + } + } +} + +#if DEBUG +struct AppReviewView_Previews: PreviewProvider { + static var previews: some View { + AppReviewView(viewModel: AppReviewViewModel(config: ConfigMock(), storage: CoreStorageMock())) + } +} +#endif diff --git a/Core/Core/View/Base/AppReview/AppReviewViewModel.swift b/Core/Core/View/Base/AppReview/AppReviewViewModel.swift new file mode 100644 index 000000000..8469e6620 --- /dev/null +++ b/Core/Core/View/Base/AppReview/AppReviewViewModel.swift @@ -0,0 +1,155 @@ +// +// AppReviewViewModel.swift +// Core +// +// Created by  Stepanok Ivan on 27.10.2023. +// + +import SwiftUI +import StoreKit + +public class AppReviewViewModel: ObservableObject { + + enum ReviewState { + case vote + case feedback + case thanksForVote + case thanksForFeedback + + var title: String { + switch self { + case .vote: + CoreLocalization.Review.voteTitle + case .feedback: + CoreLocalization.Review.feedbackTitle + case .thanksForVote, .thanksForFeedback: + CoreLocalization.Review.thanksForVoteTitle + } + } + + var description: String { + switch self { + case .vote: + CoreLocalization.Review.voteDescription + case .feedback: + CoreLocalization.Review.feedbackDescription + case .thanksForVote: + CoreLocalization.Review.thanksForVoteDescription + case .thanksForFeedback: + CoreLocalization.Review.thanksForFeedbackDescription + } + } + } + + @Published var state: ReviewState = .vote + @Published var rating: Int = 0 + @Published var showReview: Bool = false + @Published var showSelectMailClientView: Bool = false + @Published var feedback: String = "" + @Published var clients: [ThirdPartyMailClient] = [] + let allClients = ThirdPartyMailClient.clients + + private let config: Config + var storage: CoreStorage + + public init(config: Config, storage: CoreStorage) { + self.config = config + self.storage = storage + } + + public func shouldShowRatingView() -> Bool { + guard let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String else { + return false + } + guard let lastShownVersion = storage.reviewLastShownVersion else { + storage.reviewLastShownVersion = currentVersion + + if let lastReviewDate = storage.lastReviewDate { + return hasPassedFourMonths(from: lastReviewDate) + } else { + return true + } + } + return isNewerVersion(currentVersion: currentVersion, lastVersion: lastShownVersion) + } + + private func hasPassedFourMonths(from date: Date) -> Bool { + let currentDate = Date() + let calendar = Calendar.current + + if let futureDate = calendar.date(byAdding: .month, value: 4, to: date) { + return currentDate >= futureDate + } + + return false + } + + func reviewAction() { + withAnimation(Animation.easeIn(duration: 0.2)) { + if rating <= 3 { + state = .feedback + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + withAnimation(Animation.easeIn(duration: 0.1)) { + self.showReview = true + } + } + } else { + state = .thanksForVote + } + } + } + + func writeFeedbackToMail() { + self.clients = allClients.filter({ ThirdPartyMailer.isMailClientAvailable($0) }) + if !clients.isEmpty { + withAnimation(Animation.bouncy(duration: 0.2)) { + showSelectMailClientView = true + } + } else { + openMailClient(ThirdPartyMailClient.systemDefault) + } + } + + func openMailClient(_ with: ThirdPartyMailClient) { + + let osVersion = UIDevice.current.systemVersion + let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" + let deviceModel = UIDevice.current.model + let feedbackDetails = "\n\n OS version: \(osVersion)\nApp version: \(appVersion)\nDevice model: \(deviceModel)" + + let mailUrl = with.composeURL( + to: config.feedbackEmail, + subject: "Feedback", + body: feedback + feedbackDetails, + cc: nil, + bcc: nil + ) + UIApplication.shared.open(mailUrl) + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + self.showSelectMailClientView = false + self.state = .thanksForFeedback + } + } + + private func isNewerVersion(currentVersion: String, lastVersion: String) -> Bool { + // Split versions into components + let currentComponents = currentVersion.split(separator: ".").compactMap { Int($0) } + let lastComponents = lastVersion.split(separator: ".").compactMap { Int($0) } + + // Check that the number of components is the same + guard currentComponents.count == lastComponents.count else { + return false + } + + // Check the condition + if currentComponents[0] > lastComponents[0] + 1 { + return true // Greater by one major version + } else if currentComponents[0] == lastComponents[0] + 1 { + return true // Equal to the major version but greater by two minor versions + } else if currentComponents[1] > lastComponents[1] + 1 { + return true // Greater by two minor versions + } + + return false + } +} diff --git a/Core/Core/View/Base/AppReview/Elements/AppReviewButton.swift b/Core/Core/View/Base/AppReview/Elements/AppReviewButton.swift new file mode 100644 index 000000000..2077e2f7d --- /dev/null +++ b/Core/Core/View/Base/AppReview/Elements/AppReviewButton.swift @@ -0,0 +1,43 @@ +// +// AppReviewButton.swift +// Core +// +// Created by  Stepanok Ivan on 31.10.2023. +// + +import SwiftUI + +struct AppReviewButton: View { + let type: ButtonType + let action: () -> Void + @Binding var isActive: Bool + + enum ButtonType { + case submit, shareFeedback, rateUs + } + + var body: some View { + Button(action: { + if isActive { action() } + }, label: { + Group { + HStack(spacing: 4) { + Text(type == .submit ? CoreLocalization.Review.Button.submit + : (type == .shareFeedback ? CoreLocalization.Review.Button.shareFeedback : CoreLocalization.Review.Button.rateUs )) + .foregroundColor(isActive ? Color.white : Color.black.opacity(0.6)) + .font(Theme.Fonts.labelLarge) + .padding(3) + + }.padding(.horizontal, 20) + .padding(.vertical, 9) + }.fixedSize() + .background(isActive + ? Theme.Colors.accentColor + : Theme.Colors.cardViewStroke) + .accessibilityElement(children: .ignore) + .accessibilityLabel(type == .submit ? CoreLocalization.Review.Button.submit + : (type == .shareFeedback ? CoreLocalization.Review.Button.shareFeedback : CoreLocalization.Review.Button.rateUs )) + .cornerRadius(8) + }) + } +} diff --git a/Core/Core/View/Base/AppReview/Elements/SelectMailClientView.swift b/Core/Core/View/Base/AppReview/Elements/SelectMailClientView.swift new file mode 100644 index 000000000..064b57554 --- /dev/null +++ b/Core/Core/View/Base/AppReview/Elements/SelectMailClientView.swift @@ -0,0 +1,88 @@ +// +// SelectMailClientView.swift +// Core +// +// Created by  Stepanok Ivan on 31.10.2023. +// + +import SwiftUI + +struct SelectMailClientView: View { + + let clients: [ThirdPartyMailClient] + + var onMailTapped: (ThirdPartyMailClient) -> Void + + init(clients: [ThirdPartyMailClient], onMailTapped: @escaping (ThirdPartyMailClient) -> Void) { + self.clients = clients + self.onMailTapped = onMailTapped + } + + @State var isOpen: Bool = false + + var body: some View { + ZStack { + VStack { + Spacer() + VStack(alignment: .leading, spacing: 0) { + Text(CoreLocalization.Review.Email.title) + .font(Theme.Fonts.labelLarge) + .padding(.leading, 16) + .padding(.top, 8) + ScrollView(.horizontal) { + HStack { + Button(action: { + onMailTapped(.systemDefault) + }, label: { + Image(.defaultMail).resizable() + .frame(width: 50, height: 50) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .padding(.leading, 14) + .shadow(color: .black.opacity(0.2), radius: 8) + }) + + ForEach(clients, id: \.name) { client in + Group { + Button(action: { + onMailTapped(client) + }, label: { + client.icon?.resizable() + }) + }.frame(width: 50, height: 50) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .shadow(color: .black.opacity(0.2), radius: 8) + .padding(.leading, 4) + .padding(.vertical, 16) + } + } + } + + }.background( Theme.Colors.background) + .offset(y: isOpen ? 0 : 200) + } + }.onAppear { + withAnimation(Animation.bouncy.delay(0.3)) { + isOpen = true + } + } + } +} + +struct SelectMailClientView_Previews: PreviewProvider { + static var previews: some View { + + let clients: [ThirdPartyMailClient] = [ + ThirdPartyMailClient(name: "googlegmail", icon: Image(.googlegmail), URLScheme: ""), + ThirdPartyMailClient(name: "readdle-spark", icon: Image(.readdleSpark), URLScheme: ""), + ThirdPartyMailClient(name: "airmail", icon: Image(.airmail), URLScheme: ""), + ThirdPartyMailClient(name: "ms-outlook", icon: Image(.msOutlook), URLScheme: ""), + ThirdPartyMailClient(name: "ymail", icon: Image(.ymail), URLScheme: ""), + ThirdPartyMailClient(name: "fastmail", icon: Image(.fastmail), URLScheme: ""), + ThirdPartyMailClient(name: "protonmail", icon: Image(.proton), URLScheme: "") + ] + + SelectMailClientView(clients: clients, onMailTapped: { _ in + + }) + } +} diff --git a/Core/Core/View/Base/AppReview/Elements/StarRatingView.swift b/Core/Core/View/Base/AppReview/Elements/StarRatingView.swift new file mode 100644 index 000000000..b7cf88831 --- /dev/null +++ b/Core/Core/View/Base/AppReview/Elements/StarRatingView.swift @@ -0,0 +1,34 @@ +// +// StarRatingView.swift +// Core +// +// Created by  Stepanok Ivan on 31.10.2023. +// + +import SwiftUI + +struct StarRatingView: View { + @Binding var rating: Int + + var body: some View { + HStack { + ForEach(1 ..< 6) { index in + Group { + if index <= rating { + CoreAssets.star.swiftUIImage + .resizable() + .frame(width: 48, height: 48) + } else { + CoreAssets.starOutline.swiftUIImage + .resizable() + .frame(width: 48, height: 48) + .foregroundColor(Theme.Colors.textPrimary) + } + } + .onTapGesture { + self.rating = index + } + } + } + } +} diff --git a/Core/Core/View/Base/AppReview/ThirdPartyMailer/ThirdPartyMailClient.swift b/Core/Core/View/Base/AppReview/ThirdPartyMailer/ThirdPartyMailClient.swift new file mode 100644 index 000000000..76d48270e --- /dev/null +++ b/Core/Core/View/Base/AppReview/ThirdPartyMailer/ThirdPartyMailClient.swift @@ -0,0 +1,147 @@ +// +// ThirdPartyMailClient.swift +// +// Copyright (c) 2016-2022 Vincent Tourraine (https://www.vtourraine.net) +// +// Licensed under MIT License + +import SwiftUI + +/// A third-party mail client, offering a custom URL scheme. +public struct ThirdPartyMailClient { + + /// The name of the mail client. + public let name: String + + /// The custom URL scheme of the mail client. + public let URLScheme: String + + /// The URL “root” (after the URL scheme and the colon). + let URLRoot: String? + + /// The URL query items key for the recipient. + let URLRecipientKey: String? + + /// The URL query items key for the subject, or `nil` if this client doesn’t support setting the subject. + let URLSubjectKey: String? + + /// The URL query items key for the message body, or `nil` if this client doesn’t support setting the message body. + let URLBodyKey: String? + + let icon: Image? + + public init(name: String, icon: Image?, URLScheme: String, URLRoot: String? = nil, URLRecipientKey: String? = nil, URLSubjectKey: String? = "subject", URLBodyKey: String? = "body") { + self.name = name + self.icon = icon + self.URLScheme = URLScheme + self.URLRoot = URLRoot + self.URLRecipientKey = URLRecipientKey + self.URLSubjectKey = URLSubjectKey + self.URLBodyKey = URLBodyKey + } + + /// Returns the open URL for the mail client, based on its custom URL scheme. + /// - Returns: A `URL` opening the mail client. + public func openURL() -> URL { + var components = URLComponents() + components.scheme = URLScheme + return components.url! + } + + /// Returns the compose URL for the mail client, based on its custom URL scheme. + /// - Parameters: + /// - recipient: The recipient for the email message (optional). + /// - subject: The subject for the email message (optional). + /// - body: The body for the email message (optional). + /// - cc: The carbon copy recipient for the email message (optional). + /// - bcc: The blind carbon copy recipient for the email message (optional). + /// - Returns: A `URL` opening the mail client for the given parameters. + public func composeURL(to recipient: String? = nil, subject: String? = nil, body: String? = nil, cc: String? = nil, bcc: String? = nil) -> URL { + var components = URLComponents(string: "\(URLScheme):\(URLRoot ?? "")") + components?.scheme = self.URLScheme + + if URLRecipientKey == nil { + if let recipient = recipient { + components = URLComponents(string: "\(URLScheme):\(URLRoot ?? "")\(recipient)") + } + } + + var queryItems: [URLQueryItem] = [] + + if let recipient = recipient, let URLRecipientKey = URLRecipientKey { + if URLRecipientKey == ":" { + // Special format for ProtonMail + // https://github.com/vtourraine/ThirdPartyMailer/issues/32 + components = URLComponents(string: "\(URLScheme):\(URLRoot ?? ""):\(recipient)") + } + else { + queryItems.append(URLQueryItem(name: URLRecipientKey, value: recipient)) + } + } + + if let subject = subject, let URLSubjectKey = URLSubjectKey { + queryItems.append(URLQueryItem(name: URLSubjectKey, value: subject)) + } + + if let body = body, let URLBodyKey = URLBodyKey { + queryItems.append(URLQueryItem(name: URLBodyKey, value: body)) + } + + if let cc = cc { + queryItems.append(URLQueryItem(name: "cc", value: cc)) + } + + if let bcc = bcc { + queryItems.append(URLQueryItem(name: "bcc", value: bcc)) + } + + if queryItems.isEmpty == false { + components?.queryItems = queryItems + } + + return components!.url! + } +} + +public extension ThirdPartyMailClient { + static var systemDefault: ThirdPartyMailClient { + get { + // mailto: + return ThirdPartyMailClient(name: "System Default", icon: Image(.defaultMail), URLScheme: "mailto") + } + } + + /// Returns an array of predefined mail clients. + static var clients: [ThirdPartyMailClient] { + get { + return [ + // sparrow:[to]?subject=[subject]&body=[body] + ThirdPartyMailClient(name: "Sparrow", icon: nil, URLScheme: "sparrow"), + + // googlegmail:///co?to=[to]&subject=[subject]&body=[body] + ThirdPartyMailClient(name: "Gmail", icon: Image(.googlegmail), URLScheme: "googlegmail", URLRoot: "///co", URLRecipientKey: "to"), + + // x-dispatch:///compose?to=[to]&subject=[subject]&body=[body] + ThirdPartyMailClient(name: "Dispatch", icon: nil, URLScheme: "x-dispatch", URLRoot: "///compose", URLRecipientKey: "to"), + + // readdle-spark://compose?subject=[subject]&body=[body]&recipient=[recipient] + ThirdPartyMailClient(name: "Spark", icon: Image(.readdleSpark), URLScheme: "readdle-spark", URLRoot: "//compose", URLRecipientKey: "recipient"), + + // airmail://compose?subject=[subject]&from=[from]&to=[to]&cc=[cc]&bcc=[bcc]&plainBody=[plainBody]&htmlBody=[htmlBody] + ThirdPartyMailClient(name: "Airmail", icon: Image(.airmail), URLScheme: "airmail", URLRoot: "//compose", URLRecipientKey: "to", URLBodyKey: "plainBody"), + + // ms-outlook://compose?subject=[subject]&body=[body]&to=[to] + ThirdPartyMailClient(name: "Microsoft Outlook", icon: Image(.msOutlook), URLScheme: "ms-outlook", URLRoot: "//compose", URLRecipientKey: "to"), + + // ymail://mail/compose?subject=[subject]&body=[body]&to=[to] + ThirdPartyMailClient(name: "Yahoo Mail", icon: Image(.ymail), URLScheme: "ymail", URLRoot: "//mail/compose", URLRecipientKey: "to"), + + // fastmail://mail/compose?subject=[subject]&body=[body]&to=[to] + ThirdPartyMailClient(name: "Fastmail", icon: Image(.fastmail), URLScheme: "fastmail", URLRoot: "//mail/compose", URLRecipientKey: "to"), + + // protonmail://mailto:foobar@foobar.org?subject=SubjectTitleOfEMail&body=MessageBodyFooBar + ThirdPartyMailClient(name: "ProtonMail", icon: Image(.proton), URLScheme: "protonmail", URLRoot: "//mailto", URLRecipientKey: ":") + ] + } + } +} diff --git a/Core/Core/View/Base/AppReview/ThirdPartyMailer/ThirdPartyMailer.swift b/Core/Core/View/Base/AppReview/ThirdPartyMailer/ThirdPartyMailer.swift new file mode 100644 index 000000000..ded79fcda --- /dev/null +++ b/Core/Core/View/Base/AppReview/ThirdPartyMailer/ThirdPartyMailer.swift @@ -0,0 +1,53 @@ +// +// ThirdPartyMailClient.swift +// +// Copyright (c) 2016-2022 Vincent Tourraine (https://www.vtourraine.net) +// +// Licensed under MIT License + +import UIKit + +/// Tests third party mail clients availability, and opens third party mail clients in compose mode. +@available(iOSApplicationExtension, unavailable) +open class ThirdPartyMailer { + + /// Tests the availability of a third-party mail client. + /// - Parameters: + /// - client: The third-party client to test. + /// - Returns: `true` if the application can open the client; otherwise, `false`. + open class func isMailClientAvailable(_ client: ThirdPartyMailClient) -> Bool { + var components = URLComponents() + components.scheme = client.URLScheme + + guard let URL = components.url + else { return false } + + let application = UIApplication.shared + return application.canOpenURL(URL) + } + + /// Opens a third-party mail client. + /// - Parameters: + /// - client: The third-party client to open. + /// - completion: The block to execute with the results (optional, default value is `nil`). + open class func open(_ client: ThirdPartyMailClient = .systemDefault, completionHandler completion: ((Bool) -> Void)? = nil) { + let url = client.openURL() + let application = UIApplication.shared + application.open(url, options: [:], completionHandler: completion) + } + + /// Opens a third-party mail client in compose mode. + /// - Parameters: + /// - client: The third-party client to open. + /// - recipient: The email address of the recipient (optional, default value is `nil`). + /// - subject: The email subject (optional, default value is `nil`). + /// - body: The email body (optional, default value is `nil`). + /// - cc: The email address of the recipient carbon copy (optional, default value is `nil`). + /// - bcc: The email address of the recipient blind carbon copy (optional, default value is `nil`). + /// - completion: The block to execute with the results (optional, default value is `nil`). + open class func openCompose(_ client: ThirdPartyMailClient = .systemDefault, recipient: String? = nil, subject: String? = nil, body: String? = nil, cc: String? = nil, bcc: String? = nil, with application: UIApplication = .shared, completionHandler completion: ((Bool) -> Void)? = nil) { + let url = client.composeURL(to: recipient, subject: subject, body: body, cc: cc, bcc: bcc) + let application = UIApplication.shared + application.open(url, options: [:], completionHandler: completion) + } +} diff --git a/Core/Core/en.lproj/Localizable.strings b/Core/Core/en.lproj/Localizable.strings index 6c055ab25..38ca23c6f 100644 --- a/Core/Core/en.lproj/Localizable.strings +++ b/Core/Core/en.lproj/Localizable.strings @@ -69,9 +69,26 @@ "WEBVIEW.ALERT.OK" = "Ok"; "WEBVIEW.ALERT.CANCEL" = "Cancel"; +"REVIEW.VOTE_TITLE" = "Enjoying Open edX?"; +"REVIEW.VOTE_DESCRIPTION" = "Your feedback matters to us. Would you take a moment to rate the app by tapping a star below? Thanks for your support!"; +"REVIEW.FEEDBACK_TITLE" = "Leave Us Feedback"; +"REVIEW.FEEDBACK_DESCRIPTION" = "We’re sorry to hear your learning experience has had some issues. We appreciate all feedback."; +"REVIEW.THANKS_FOR_VOTE_TITLE" = "Thank You"; +"REVIEW.THANKS_FOR_VOTE_DESCRIPTION" = "Thank you for sharing your feedback with us. Would you like to share your review of this app with other users on the app store?"; +"REVIEW.THANKS_FOR_FEEDBACK_TITLE" = "Thank You"; +"REVIEW.THANKS_FOR_FEEDBACK_DESCRIPTION" = "We received your feedback and will use it to help improve your learning experience going forward. Thank you for sharing!"; +"REVIEW.BETTER" = "What could have been better?"; +"REVIEW.NOT_NOW" = "Not now"; + +"REVIEW.BUTTON.SUBMIT" = "Submit"; +"REVIEW.BUTTON.SHARE_FEEDBACK" = "Share Feedback"; +"REVIEW.BUTTON.RATE_US" = "Rate Us"; +"REVIEW.EMAIL.TITLE" = "Select email client:"; + "COURSE_DATES.TODAY" = "Today"; "COURSE_DATES.COMPLETED" = "Completed"; "COURSE_DATES.PAST_DUE" = "Past due"; "COURSE_DATES.DUE_NEXT" = "Due next"; "COURSE_DATES.UNRELEASED" = "Unreleased"; "COURSE_DATES.VERIFIED_ONLY" = "Verified Only"; + diff --git a/Core/Core/uk.lproj/Localizable.strings b/Core/Core/uk.lproj/Localizable.strings index 40ff490a3..e3a75f764 100644 --- a/Core/Core/uk.lproj/Localizable.strings +++ b/Core/Core/uk.lproj/Localizable.strings @@ -69,9 +69,27 @@ "WEBVIEW.ALERT.OK" = "Так"; "WEBVIEW.ALERT.CANCEL" = "Скасувати"; + +"REVIEW.VOTE_TITLE" = "Вам подобається Open edX?"; +"REVIEW.VOTE_DESCRIPTION" = "Ваш відгук важливий для нас. Можливо, ви візьмете хвилинку, щоб оцінити додаток, натиснувши на зірку нижче? Дякуємо за вашу підтримку!"; +"REVIEW.FEEDBACK_TITLE" = "Залиште відгук"; +"REVIEW.FEEDBACK_DESCRIPTION" = "Нам шкода чути, що ваше навчання мало деякі проблеми. Ми вдячні за будь-який відгук."; +"REVIEW.THANKS_FOR_VOTE_TITLE" = "Дякуємо"; +"REVIEW.THANKS_FOR_VOTE_DESCRIPTION" = "Дякуємо, що поділилися своїми враженнями з нами. Бажаєте залишити свій відгук про цей додаток для інших користувачів в магазині додатків?"; +"REVIEW.THANKS_FOR_FEEDBACK_TITLE" = "Дякуємо"; +"REVIEW.THANKS_FOR_FEEDBACK_DESCRIPTION" = "Ми отримали ваш відгук і використовуватимемо його для покращення вашого навчального досвіду в майбутньому!"; +"REVIEW.BETTER" = "Що можна було б зробити краще?"; +"REVIEW.NOT_NOW" = "Не зараз"; + +"REVIEW.BUTTON.SUBMIT" = "Надіслати"; +"REVIEW.BUTTON.SHARE_FEEDBACK" = "Поділитися відгуком"; +"REVIEW.BUTTON.RATE_US" = "Оцінити нас"; +"REVIEW.EMAIL.TITLE" = "Виберіть поштового клієнта:"; + "COURSE_DATES.TODAY" = "Today"; "COURSE_DATES.COMPLETED" = "Completed"; "COURSE_DATES.PAST_DUE" = "Past due"; "COURSE_DATES.DUE_NEXT" = "Due next"; "COURSE_DATES.UNRELEASED" = "Unreleased"; "COURSE_DATES.VERIFIED_ONLY" = "Verified Only"; + diff --git a/Course/Course/Presentation/CourseRouter.swift b/Course/Course/Presentation/CourseRouter.swift index 2c9a60982..ea454a3ca 100644 --- a/Course/Course/Presentation/CourseRouter.swift +++ b/Course/Course/Presentation/CourseRouter.swift @@ -10,6 +10,8 @@ import Core public protocol CourseRouter: BaseRouter { + func presentAppReview() + func showCourseScreens( courseID: String, isActive: Bool?, @@ -65,6 +67,8 @@ public class CourseRouterMock: BaseRouterMock, CourseRouter { public override init() {} + public func presentAppReview() {} + public func showCourseScreens( courseID: String, isActive: Bool?, diff --git a/Course/Course/Presentation/Video/EncodedVideoPlayer.swift b/Course/Course/Presentation/Video/EncodedVideoPlayer.swift index 4740fb013..c40fcabfa 100644 --- a/Course/Course/Presentation/Video/EncodedVideoPlayer.swift +++ b/Course/Course/Presentation/Video/EncodedVideoPlayer.swift @@ -53,7 +53,7 @@ public struct EncodedVideoPlayer: View { public var body: some View { ZStack { - GeometryReader {reader in + GeometryReader { reader in VStack { HStack { VStack { @@ -70,9 +70,13 @@ public struct EncodedVideoPlayer: View { isViewedOnce = true } } + if progress == 1 { + viewModel.router.presentAppReview() + } }, seconds: { seconds in currentTime = seconds }) + .statusBarHidden(false) .aspectRatio(16 / 9, contentMode: .fit) .frame(minWidth: isHorizontal ? reader.size.width * 0.6 : 380) .cornerRadius(12) diff --git a/Course/Course/Presentation/Video/YouTubeVideoPlayerViewModel.swift b/Course/Course/Presentation/Video/YouTubeVideoPlayerViewModel.swift index 9caf58001..f30c65f98 100644 --- a/Course/Course/Presentation/Video/YouTubeVideoPlayerViewModel.swift +++ b/Course/Course/Presentation/Video/YouTubeVideoPlayerViewModel.swift @@ -103,10 +103,14 @@ public class YouTubeVideoPlayerViewModel: VideoPlayerViewModel { if !isViewedOnce { Task { await self.blockCompletionRequest() + } isViewedOnce = true } } + if (time / duration) >= 0.999 { + self.router.presentAppReview() + } } }).store(in: &subscription) diff --git a/OpenEdX/Data/AppStorage.swift b/OpenEdX/Data/AppStorage.swift index f674ebd9b..4bbb43eba 100644 --- a/OpenEdX/Data/AppStorage.swift +++ b/OpenEdX/Data/AppStorage.swift @@ -33,6 +33,35 @@ public class AppStorage: CoreStorage, ProfileStorage, WhatsNewStorage { } } } + + public var reviewLastShownVersion: String? { + get { + return userDefaults.string(forKey: KEY_REVIEW_LAST_SHOWN_VERSION) + } + set(newValue) { + if let newValue { + userDefaults.set(newValue, forKey: KEY_REVIEW_LAST_SHOWN_VERSION) + } else { + userDefaults.removeObject(forKey: KEY_REVIEW_LAST_SHOWN_VERSION) + } + } + } + + public var lastReviewDate: Date? { + get { + guard let dateString = userDefaults.string(forKey: KEY_REVIEW_LAST_REVIEW_DATE) else { + return nil + } + return Date(iso8601: dateString) + } + set(newValue) { + if let newValue { + userDefaults.set(newValue.dateToString(style: .iso8601), forKey: KEY_REVIEW_LAST_REVIEW_DATE) + } else { + userDefaults.removeObject(forKey: KEY_REVIEW_LAST_REVIEW_DATE) + } + } + } public var refreshToken: String? { get { @@ -148,5 +177,7 @@ public class AppStorage: CoreStorage, ProfileStorage, WhatsNewStorage { private let KEY_USER_PROFILE = "userProfile" private let KEY_USER = "refreshToken" private let KEY_SETTINGS = "userSettings" + private let KEY_REVIEW_LAST_SHOWN_VERSION = "reviewLastShownVersion" + private let KEY_REVIEW_LAST_REVIEW_DATE = "lastReviewDate" private let KEY_WHATSNEW_VERSION = "whatsNewVersion" } diff --git a/OpenEdX/Info.plist b/OpenEdX/Info.plist index b94522839..452ba08ae 100644 --- a/OpenEdX/Info.plist +++ b/OpenEdX/Info.plist @@ -14,5 +14,15 @@ UIViewControllerBasedStatusBarAppearance + LSApplicationQueriesSchemes + + googlegmail + readdle-spark + airmail + ms-outlook + ymail + fastmail + protonmail + diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index 062dfcea2..2fb8f7983 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -88,6 +88,18 @@ public class Router: AuthorizationRouter, navigationController.setViewControllers([controller], animated: false) } + public func presentAppReview() { + let config = Container.shared.resolve(Config.self)! + let storage = Container.shared.resolve(CoreStorage.self)! + let vm = AppReviewViewModel(config: config, storage: storage) + if vm.shouldShowRatingView() { + presentView( + transitionStyle: .crossDissolve, + view: AppReviewView(viewModel: vm) + ) + } + } + public func presentAlert( alertTitle: String, alertMessage: String, From d8267da818fb4d498d7c0b5074e1d9af1ce48bb9 Mon Sep 17 00:00:00 2001 From: Muhammad Umer Date: Tue, 21 Nov 2023 15:36:40 +0500 Subject: [PATCH 017/158] feat: configuration management (#158) * feat: add config to process config * chore: handle configuration provided as argument, handle firebase crashlytics google app id, remove process_config.sh in favour of build script * chore: update implementation to provide config mappings based on Xcode scheme and a default config * chore: update config test cases * chore: make pass dictionary to features config * chore: update process_config.py and releated files * chore: add documentation fix: fix documentation link fix: fix documentation link fix: remove type fix: remove type fix: cleanup * fix: add default config_settings.yaml * chore: address feedback * chore: update config initialization * chore: address feedback * chore: address feedback --- .gitignore | 5 + .../Presentation/Base/FieldsView.swift | 4 +- .../Presentation/Login/SignInViewModel.swift | 4 +- .../Registration/SignUpView.swift | 4 +- .../Registration/SignUpViewModel.swift | 4 +- Core/Core.xcodeproj/project.pbxproj | 47 ++- Core/Core/Configuration/CSSInjector.swift | 9 +- Core/Core/Configuration/Config.swift | 55 ---- .../Config/AgreementConfig.swift | 31 ++ Core/Core/Configuration/Config/Config.swift | 151 +++++++++ .../Configuration/Config/FeaturesConfig.swift | 27 ++ .../Configuration/Config/FirebaseConfig.swift | 105 ++++++ .../Core/Data/Repository/AuthRepository.swift | 4 +- Core/Core/Network/API.swift | 4 +- Core/Core/Network/RequestInterceptor.swift | 4 +- Core/Core/Tests/ConfigTests.swift | 77 +++++ Core/Core/View/Base/WebUnitViewModel.swift | 4 +- Course/Course/Data/CourseRepository.swift | 4 +- .../Container/CourseContainerViewModel.swift | 4 +- .../Details/CourseDetailsViewModel.swift | 4 +- .../Dashboard/Data/DashboardRepository.swift | 4 +- .../Discovery/Data/DiscoveryRepository.swift | 4 +- .../Presentation/DiscoveryViewModel.swift | 4 +- .../UpdateViews/UpdateNotificationView.swift | 4 +- .../UpdateViews/UpdateRecommendedView.swift | 4 +- .../UpdateViews/UpdateRequiredView.swift | 4 +- .../Data/Network/DiscussionRepository.swift | 4 +- .../Base/BaseResponsesViewModel.swift | 4 +- .../Responses/ResponsesViewModel.swift | 2 +- .../Comments/Thread/ThreadViewModel.swift | 2 +- .../CreateNewThreadViewModel.swift | 4 +- .../DiscussionTopicsViewModel.swift | 4 +- .../Presentation/Posts/PostsViewModel.swift | 4 +- Documentation/CONFIGURATION_MANAGEMENT.md | 108 +++++++ OpenEdX.xcodeproj/project.pbxproj | 26 +- OpenEdX/AppDelegate.swift | 9 +- OpenEdX/DI/AppAssembly.swift | 10 +- OpenEdX/DI/NetworkAssembly.swift | 4 +- OpenEdX/DI/ScreenAssembly.swift | 38 +-- OpenEdX/Environment.swift | 89 ----- OpenEdX/RouteController.swift | 4 +- OpenEdX/Router.swift | 8 +- OpenEdX/View/MainScreenViewModel.swift | 4 +- Podfile.lock | 2 +- Profile/Profile/Data/ProfileRepository.swift | 4 +- .../Presentation/Profile/ProfileView.swift | 4 +- .../Profile/ProfileViewModel.swift | 4 +- README.md | 2 +- config_script/process_config.py | 306 ++++++++++++++++++ default_config/config_settings.yaml | 5 + default_config/dev/config.yaml | 4 + default_config/dev/file_mappings.yaml | 3 + default_config/prod/config.yaml | 4 + default_config/prod/file_mappings.yaml | 3 + default_config/stage/config.yaml | 4 + default_config/stage/file_mappings.yaml | 3 + 56 files changed, 1000 insertions(+), 247 deletions(-) delete mode 100644 Core/Core/Configuration/Config.swift create mode 100644 Core/Core/Configuration/Config/AgreementConfig.swift create mode 100644 Core/Core/Configuration/Config/Config.swift create mode 100644 Core/Core/Configuration/Config/FeaturesConfig.swift create mode 100644 Core/Core/Configuration/Config/FirebaseConfig.swift create mode 100644 Core/Core/Tests/ConfigTests.swift create mode 100644 Documentation/CONFIGURATION_MANAGEMENT.md delete mode 100644 OpenEdX/Environment.swift create mode 100644 config_script/process_config.py create mode 100644 default_config/config_settings.yaml create mode 100644 default_config/dev/config.yaml create mode 100644 default_config/dev/file_mappings.yaml create mode 100644 default_config/prod/config.yaml create mode 100644 default_config/prod/file_mappings.yaml create mode 100644 default_config/stage/config.yaml create mode 100644 default_config/stage/file_mappings.yaml diff --git a/.gitignore b/.gitignore index 582c86cb2..8a80e27f8 100644 --- a/.gitignore +++ b/.gitignore @@ -111,3 +111,8 @@ xcode-frameworks vendor/ .bundle/ + +venv/ +Podfile.lock +config_settings.yaml +default_config/ \ No newline at end of file diff --git a/Authorization/Authorization/Presentation/Base/FieldsView.swift b/Authorization/Authorization/Presentation/Base/FieldsView.swift index 1a71a2483..98cfafaaf 100644 --- a/Authorization/Authorization/Presentation/Base/FieldsView.swift +++ b/Authorization/Authorization/Presentation/Base/FieldsView.swift @@ -12,7 +12,7 @@ struct FieldsView: View { let fields: [FieldConfiguration] let router: BaseRouter - let configuration: Config + let config: ConfigProtocol let cssInjector: CSSInjector let proxy: GeometryProxy @Environment(\.colorScheme) var colorScheme @@ -107,7 +107,7 @@ struct FieldsView_Previews: PreviewProvider { FieldsView( fields: fields, router: AuthorizationRouterMock(), - configuration: ConfigMock(), + config: ConfigMock(), cssInjector: CSSInjectorMock(), proxy: proxy ) diff --git a/Authorization/Authorization/Presentation/Login/SignInViewModel.swift b/Authorization/Authorization/Presentation/Login/SignInViewModel.swift index 86b3f32ef..350d09af0 100644 --- a/Authorization/Authorization/Presentation/Login/SignInViewModel.swift +++ b/Authorization/Authorization/Presentation/Login/SignInViewModel.swift @@ -31,7 +31,7 @@ public class SignInViewModel: ObservableObject { } let router: AuthorizationRouter - private let config: Config + private let config: ConfigProtocol private let interactor: AuthInteractorProtocol private let analytics: AuthorizationAnalytics private let validator: Validator @@ -39,7 +39,7 @@ public class SignInViewModel: ObservableObject { public init( interactor: AuthInteractorProtocol, router: AuthorizationRouter, - config: Config, + config: ConfigProtocol, analytics: AuthorizationAnalytics, validator: Validator ) { diff --git a/Authorization/Authorization/Presentation/Registration/SignUpView.swift b/Authorization/Authorization/Presentation/Registration/SignUpView.swift index d4dc67acf..bc4bd7dee 100644 --- a/Authorization/Authorization/Presentation/Registration/SignUpView.swift +++ b/Authorization/Authorization/Presentation/Registration/SignUpView.swift @@ -72,7 +72,7 @@ public struct SignUpView: View { FieldsView(fields: requiredFields, router: viewModel.router, - configuration: viewModel.config, + config: viewModel.config, cssInjector: viewModel.cssInjector, proxy: proxy) @@ -80,7 +80,7 @@ public struct SignUpView: View { DisclosureGroup(isExpanded: $disclosureGroupOpen, content: { FieldsView(fields: nonRequiredFields, router: viewModel.router, - configuration: viewModel.config, + config: viewModel.config, cssInjector: viewModel.cssInjector, proxy: proxy).padding(.horizontal, 1) }, label: { diff --git a/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift b/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift index 3cc1e16fd..ff3691c05 100644 --- a/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift +++ b/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift @@ -25,7 +25,7 @@ public class SignUpViewModel: ObservableObject { @Published var fields: [FieldConfiguration] = [] let router: AuthorizationRouter - let config: Config + let config: ConfigProtocol let cssInjector: CSSInjector private let interactor: AuthInteractorProtocol @@ -36,7 +36,7 @@ public class SignUpViewModel: ObservableObject { interactor: AuthInteractorProtocol, router: AuthorizationRouter, analytics: AuthorizationAnalytics, - config: Config, + config: ConfigProtocol, cssInjector: CSSInjector, validator: Validator ) { diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index a402a3b72..154ad29ae 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -122,6 +122,10 @@ 07DDFCBD29A780BB00572595 /* UINavigationController+Animation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07DDFCBC29A780BB00572595 /* UINavigationController+Animation.swift */; }; C8C446EF233F81B9FABB77D2 /* Pods_App_Core.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 349B90CD6579F7B8D257E515 /* Pods_App_Core.framework */; }; CFC84952299F8B890055E497 /* Debounce.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFC84951299F8B890055E497 /* Debounce.swift */; }; + DBF6F2412B014ADA0098414B /* FirebaseConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF6F2402B014ADA0098414B /* FirebaseConfig.swift */; }; + DBF6F2462B01DAFE0098414B /* AgreementConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF6F2452B01DAFE0098414B /* AgreementConfig.swift */; }; + DBF6F2482B01E20A0098414B /* ConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF6F2472B01E20A0098414B /* ConfigTests.swift */; }; + DBF6F24A2B0380E00098414B /* FeaturesConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF6F2492B0380E00098414B /* FeaturesConfig.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -259,6 +263,10 @@ 9D5B06CAA99EA5CD49CBE2BB /* Pods-App-Core.debugdev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core.debugdev.xcconfig"; path = "Target Support Files/Pods-App-Core/Pods-App-Core.debugdev.xcconfig"; sourceTree = ""; }; C7E5BCE79CE297B20777B27A /* Pods-App-Core.debugprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core.debugprod.xcconfig"; path = "Target Support Files/Pods-App-Core/Pods-App-Core.debugprod.xcconfig"; sourceTree = ""; }; CFC84951299F8B890055E497 /* Debounce.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Debounce.swift; sourceTree = ""; }; + DBF6F2402B014ADA0098414B /* FirebaseConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirebaseConfig.swift; sourceTree = ""; }; + DBF6F2452B01DAFE0098414B /* AgreementConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgreementConfig.swift; sourceTree = ""; }; + DBF6F2472B01E20A0098414B /* ConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigTests.swift; sourceTree = ""; }; + DBF6F2492B0380E00098414B /* FeaturesConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeaturesConfig.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -413,9 +421,9 @@ 0727876E28D233EC002E9142 /* Configuration */ = { isa = PBXGroup; children = ( + DBF6F2422B014AF30098414B /* Config */, CFC84955299FAC4D0055E497 /* Combine */, 0770DE1828D0847D006D8A5D /* BaseRouter.swift */, - 0727876F28D23411002E9142 /* Config.swift */, 0231CDBD2922422D00032416 /* CSSInjector.swift */, 02280F5A294B4E6F0032823A /* Connectivity.swift */, ); @@ -514,6 +522,7 @@ 0770DE5D28D0B209006D8A5D /* Localizable.strings */, 0770DE5128D0ADFF006D8A5D /* Assets.xcassets */, 071009CF28D1E3A600344290 /* Constants.swift */, + DBFB74502B0CA508004370F9 /* Tests */, ); path = Core; sourceTree = ""; @@ -612,6 +621,25 @@ path = Combine; sourceTree = ""; }; + DBF6F2422B014AF30098414B /* Config */ = { + isa = PBXGroup; + children = ( + 0727876F28D23411002E9142 /* Config.swift */, + DBF6F2402B014ADA0098414B /* FirebaseConfig.swift */, + DBF6F2492B0380E00098414B /* FeaturesConfig.swift */, + DBF6F2452B01DAFE0098414B /* AgreementConfig.swift */, + ); + path = Config; + sourceTree = ""; + }; + DBFB74502B0CA508004370F9 /* Tests */ = { + isa = PBXGroup; + children = ( + DBF6F2472B01E20A0098414B /* ConfigTests.swift */, + ); + path = Tests; + sourceTree = ""; + }; F1620A3A2C8B0699EAA61B57 /* Frameworks */ = { isa = PBXGroup; children = ( @@ -686,6 +714,7 @@ TargetAttributes = { 07169468296D996800E3DED6 = { CreatedOnToolsVersion = 14.2; + LastSwiftMigration = 1500; }; 0770DE0728D07831006D8A5D = { CreatedOnToolsVersion = 14.0; @@ -786,6 +815,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + DBF6F2482B01E20A0098414B /* ConfigTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -795,6 +825,7 @@ files = ( 0727878528D31657002E9142 /* Data_User.swift in Sources */, 02F6EF4A28D9F0A700835477 /* DateExtension.swift in Sources */, + DBF6F2462B01DAFE0098414B /* AgreementConfig.swift in Sources */, 027BD3AF2909475000392132 /* DismissKeyboardTapHandler.swift in Sources */, 02E225B0291D29EB0067769A /* UrlExtension.swift in Sources */, 0770DE7928D0C4A9006D8A5D /* RoundedCorners.swift in Sources */, @@ -861,6 +892,7 @@ 0770DE1928D0847D006D8A5D /* BaseRouter.swift in Sources */, 0284DBFE28D48C5300830893 /* CourseItem.swift in Sources */, 0248C92329C075EF00DC8402 /* CourseBlockModel.swift in Sources */, + DBF6F2412B014ADA0098414B /* FirebaseConfig.swift in Sources */, 072787B628D37A0E002E9142 /* Validator.swift in Sources */, 0236961D28F9A2D200EEF206 /* Data_AuthResponse.swift in Sources */, 02AFCC182AEFDB24000360F0 /* ThirdPartyMailClient.swift in Sources */, @@ -886,6 +918,7 @@ 02F6EF3B28D9B8EC00835477 /* CourseCellView.swift in Sources */, 023A1138291432FD00D0D354 /* FieldConfiguration.swift in Sources */, 024D865E28F02C6B0077E0A0 /* WebView.swift in Sources */, + DBF6F24A2B0380E00098414B /* FeaturesConfig.swift in Sources */, 02F164372902A9EB0090DDEF /* StringExtension.swift in Sources */, 0231CDBE2922422D00032416 /* CSSInjector.swift in Sources */, 0236961928F9A26900EEF206 /* AuthRepository.swift in Sources */, @@ -1030,6 +1063,7 @@ 02DD1C9B29E80CE400F35DCE /* DebugStage */ = { isa = XCBuildConfiguration; buildSettings = { + CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; @@ -1042,6 +1076,7 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -1142,6 +1177,7 @@ 02DD1C9E29E80CED00F35DCE /* ReleaseStage */ = { isa = XCBuildConfiguration; buildSettings = { + CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; @@ -1162,6 +1198,7 @@ 07169470296D996900E3DED6 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { + CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; @@ -1174,6 +1211,7 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -1182,6 +1220,7 @@ 07169471296D996900E3DED6 /* DebugProd */ = { isa = XCBuildConfiguration; buildSettings = { + CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; @@ -1194,6 +1233,7 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -1202,6 +1242,7 @@ 07169472296D996900E3DED6 /* DebugDev */ = { isa = XCBuildConfiguration; buildSettings = { + CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; @@ -1214,6 +1255,7 @@ SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -1222,6 +1264,7 @@ 07169473296D996900E3DED6 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { + CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; @@ -1242,6 +1285,7 @@ 07169474296D996900E3DED6 /* ReleaseProd */ = { isa = XCBuildConfiguration; buildSettings = { + CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; @@ -1262,6 +1306,7 @@ 07169475296D996900E3DED6 /* ReleaseDev */ = { isa = XCBuildConfiguration; buildSettings = { + CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; diff --git a/Core/Core/Configuration/CSSInjector.swift b/Core/Core/Configuration/CSSInjector.swift index b90afc26f..57a2c88b0 100644 --- a/Core/Core/Configuration/CSSInjector.swift +++ b/Core/Core/Configuration/CSSInjector.swift @@ -12,11 +12,8 @@ public class CSSInjector { public let baseURL: URL - public init(baseURL: String) { - guard let url = URL(string: baseURL) else { - fatalError("Ivalid baseURL") - } - self.baseURL = url + public init(config: ConfigProtocol) { + self.baseURL = config.baseURL } public enum CssType { @@ -151,7 +148,7 @@ public class CSSInjector { #if DEBUG public class CSSInjectorMock: CSSInjector { public convenience init() { - self.init(baseURL: "https://google.com/") + self.init(config: ConfigMock()) } } #endif diff --git a/Core/Core/Configuration/Config.swift b/Core/Core/Configuration/Config.swift deleted file mode 100644 index 8263f5c16..000000000 --- a/Core/Core/Configuration/Config.swift +++ /dev/null @@ -1,55 +0,0 @@ -// -// Config.swift -// Core -// -// Created by Vladimir Chekyrta on 14.09.2022. -// - -import Foundation - -public class Config { - - public let baseURL: URL - public let oAuthClientId: String - public let tokenType: TokenType = .jwt - - public lazy var termsOfUse: URL? = { - URL(string: "\(baseURL.description)/tos") - }() - - public lazy var privacyPolicy: URL? = { - URL(string: "\(baseURL.description)/privacy") - }() - - public let feedbackEmail = "support@example.com" - - private let appStoreId = "0000000000" - public var appStoreLink: String { - "itms-apps://itunes.apple.com/app/id\(appStoreId)?mt=8" - } - public let whatsNewEnabled: Bool = false - - public init(baseURL: String, oAuthClientId: String) { - guard let url = URL(string: baseURL) else { - fatalError("Ivalid baseURL") - } - self.baseURL = url - self.oAuthClientId = oAuthClientId - } -} - -public extension Config { - enum TokenType: String { - case jwt = "JWT" - case bearer = "BEARER" - } -} - -// Mark - For testing and SwiftUI preview -#if DEBUG -public class ConfigMock: Config { - public convenience init() { - self.init(baseURL: "https://google.com/", oAuthClientId: "client_id") - } -} -#endif diff --git a/Core/Core/Configuration/Config/AgreementConfig.swift b/Core/Core/Configuration/Config/AgreementConfig.swift new file mode 100644 index 000000000..46059500e --- /dev/null +++ b/Core/Core/Configuration/Config/AgreementConfig.swift @@ -0,0 +1,31 @@ +// +// AgreementConfig.swift +// Core +// +// Created by Muhammad Umer on 11/13/23. +// + +import Foundation + +private enum AgreementKeys: String { + case privacyPolicyURL = "PRIVACY_POLICY_URL" + case tosURL = "TOS_URL" +} + +public class AgreementConfig: NSObject { + public var privacyPolicyURL: URL? + public var tosURL: URL? + + init(dictionary: [String: AnyObject]) { + privacyPolicyURL = (dictionary[AgreementKeys.privacyPolicyURL.rawValue] as? String).flatMap(URL.init) + tosURL = (dictionary[AgreementKeys.tosURL.rawValue] as? String).flatMap(URL.init) + super.init() + } +} + +private let key = "AGREEMENT_URLS" +extension Config { + public var agreement: AgreementConfig { + return AgreementConfig(dictionary: self[key] as? [String: AnyObject] ?? [:]) + } +} diff --git a/Core/Core/Configuration/Config/Config.swift b/Core/Core/Configuration/Config/Config.swift new file mode 100644 index 000000000..49f1ffdc5 --- /dev/null +++ b/Core/Core/Configuration/Config/Config.swift @@ -0,0 +1,151 @@ +// +// Config.swift +// Core +// +// Created by Muhammad Umer on 11/11/2023. +// + +import Foundation + +public protocol ConfigProtocol { + var baseURL: URL { get } + var oAuthClientId: String { get } + var tokenType: TokenType { get } + var feedbackEmail: String { get } + var appStoreLink: String { get } + var agreement: AgreementConfig { get } + var firebase: FirebaseConfig { get } + var features: FeaturesConfig { get } +} + +public enum TokenType: String { + case jwt = "JWT" + case bearer = "BEARER" +} + +private enum ConfigKeys: String { + case baseURL = "API_HOST_URL" + case oAuthClientID = "OAUTH_CLIENT_ID" + case tokenType = "TOKEN_TYPE" + case feedbackEmailAddress = "FEEDBACK_EMAIL_ADDRESS" + case environmentDisplayName = "ENVIRONMENT_DISPLAY_NAME" + case platformName = "PLATFORM_NAME" + case organizationCode = "ORGANIZATION_CODE" + case appstoreID = "APP_STORE_ID" +} + +public class Config { + let configFileName = "config" + + internal var properties: [String: Any] = [:] + + internal init(properties: [String: Any]) { + self.properties = properties + } + + public convenience init() { + self.init(properties: [:]) + loadAndParseConfig() + } + + private func loadAndParseConfig() { + guard let path = Bundle.main.path(forResource: configFileName, ofType: "plist"), + let data = try? Data(contentsOf: URL(fileURLWithPath: path)), + let dict = try? PropertyListSerialization.propertyList( + from: data, + options: [], + format: nil) as? [String: Any] + else { return } + + properties = dict + } + + internal subscript(key: String) -> Any? { + return properties[key] + } + + func dict(for key: String) -> [String: Any]? { + return properties[key] as? [String: Any] + } + + func value(for key: String) -> T? { + return properties[key] as? T + } + + func value(for key: String) -> Any? { + return properties[key] + } + + func value(for key: String, dict: [String: Any]) -> String? { + return dict[key] as? String ?? nil + } + + func string(for key: String) -> String? { + return value(for: key) as? String ?? nil + } + + func string(for key: String, dict: [String: Any]) -> String? { + return value(for: key, dict: dict) + } + + func bool(for key: String) -> Bool { + return value(for: key) as? Bool ?? false + } +} + +extension Config: ConfigProtocol { + public var baseURL: URL { + guard let urlString = string(for: ConfigKeys.baseURL.rawValue), + let url = URL(string: urlString) else { + fatalError("Unable to find base url in config.") + } + return url + } + + public var oAuthClientId: String { + guard let clientID = string(for: ConfigKeys.oAuthClientID.rawValue) else { + fatalError("Unable to find OAuth ClientID in config.") + } + return clientID + } + + public var tokenType: TokenType { + guard let tokenTypeValue = string(for: ConfigKeys.tokenType.rawValue), + let tokenType = TokenType(rawValue: tokenTypeValue) + else { return .jwt } + return tokenType + } + + public var feedbackEmail: String { + return string(for: ConfigKeys.feedbackEmailAddress.rawValue) ?? "" + } + + private var appStoreId: String { + return string(for: ConfigKeys.appstoreID.rawValue) ?? "0000000000" + } + + public var appStoreLink: String { + "itms-apps://itunes.apple.com/app/id\(appStoreId)?mt=8" + } +} + +// Mark - For testing and SwiftUI preview +#if DEBUG +public class ConfigMock: Config { + private let config: [String: Any] = [ + "API_HOST_URL": "https://www.example.com", + "OAUTH_CLIENT_ID": "oauth_client_id", + "FEEDBACK_EMAIL_ADDRESS": "example@mail.com", + "TOKEN_TYPE": "JWT", + "WHATS_NEW_ENABLED": false, + "AGREEMENT_URLS": [ + "PRIVACY_POLICY_URL": "https://www.example.com/privacy", + "TOS_URL": "https://www.example.com/tos" + ] + ] + + public init() { + super.init(properties: config) + } +} +#endif diff --git a/Core/Core/Configuration/Config/FeaturesConfig.swift b/Core/Core/Configuration/Config/FeaturesConfig.swift new file mode 100644 index 000000000..eb6c6227f --- /dev/null +++ b/Core/Core/Configuration/Config/FeaturesConfig.swift @@ -0,0 +1,27 @@ +// +// FeaturesConfig.swift +// Core +// +// Created by Muhammad Umer on 11/14/23. +// + +import Foundation + +private enum FeaturesKeys: String { + case whatNewEnabled = "WHATS_NEW_ENABLED" +} + +public class FeaturesConfig: NSObject { + public var whatNewEnabled: Bool + + init(dictionary: [String: Any]) { + whatNewEnabled = dictionary[FeaturesKeys.whatNewEnabled.rawValue] as? Bool ?? false + super.init() + } +} + +extension Config { + public var features: FeaturesConfig { + return FeaturesConfig(dictionary: properties) + } +} diff --git a/Core/Core/Configuration/Config/FirebaseConfig.swift b/Core/Core/Configuration/Config/FirebaseConfig.swift new file mode 100644 index 000000000..d981ff3ce --- /dev/null +++ b/Core/Core/Configuration/Config/FirebaseConfig.swift @@ -0,0 +1,105 @@ +// +// FirebaseConfig.swift +// Core +// +// Created by Muhammad Umer on 11/12/23. +// + +import Foundation +import FirebaseCore + +private enum FirebaseKeys: String { + case enabled = "ENABLED" + case analyticsSource = "ANALYTICS_SOURCE" + case cloudMessagingEnabled = "CLOUD_MESSAGING_ENABLED" + case apiKey = "API_KEY" + case bundleID = "BUNDLE_ID" + case clientID = "CLIENT_ID" + case databaseURL = "DATABASE_URL" + case gcmSenderID = "GCM_SENDER_ID" + case googleAppID = "GOOGLE_APP_ID" + case projectID = "PROJECT_ID" + case reversedClientID = "REVERSED_CLIENT_ID" + case storageBucket = "STORAGE_BUCKET" +} + +enum AnalyticsSource: String { + case firebase + case segment + case none +} + +public class FirebaseConfig: NSObject { + public var enabled: Bool = false + public var cloudMessagingEnabled: Bool = false + public let apiKey: String? + public let bundleID: String? + public let clientID: String? + public let databaseURL: String? + public let gcmSenderID: String? + public let googleAppID: String? + public let projectID: String? + public let reversedClientID: String? + public let storageBucket: String? + + private let analyticsSource: AnalyticsSource + + public var requiredKeysAvailable: Bool { + return apiKey != nil && clientID != nil && googleAppID != nil && gcmSenderID != nil + } + + init(dictionary: [String: AnyObject]) { + apiKey = dictionary[FirebaseKeys.apiKey.rawValue] as? String + clientID = dictionary[FirebaseKeys.clientID.rawValue] as? String + googleAppID = dictionary[FirebaseKeys.googleAppID.rawValue] as? String + gcmSenderID = dictionary[FirebaseKeys.gcmSenderID.rawValue] as? String + bundleID = dictionary[FirebaseKeys.bundleID.rawValue] as? String + databaseURL = dictionary[FirebaseKeys.databaseURL.rawValue] as? String + projectID = dictionary[FirebaseKeys.projectID.rawValue] as? String + reversedClientID = dictionary[FirebaseKeys.reversedClientID.rawValue] as? String + storageBucket = dictionary[FirebaseKeys.storageBucket.rawValue] as? String + + let analyticsSource = dictionary[FirebaseKeys.analyticsSource.rawValue] as? String + self.analyticsSource = AnalyticsSource(rawValue: analyticsSource ?? AnalyticsSource.none.rawValue) ?? .none + + super.init() + + enabled = requiredKeysAvailable && dictionary[FirebaseKeys.enabled.rawValue] as? Bool == true + let cloudMessagingEnabled = dictionary[FirebaseKeys.cloudMessagingEnabled.rawValue] as? Bool ?? false + self.cloudMessagingEnabled = enabled && cloudMessagingEnabled + } + + public var isAnalyticsSourceSegment: Bool { + return analyticsSource == AnalyticsSource.segment + } + + public var isAnalyticsSourceFirebase: Bool { + return analyticsSource == AnalyticsSource.firebase + } + + public var firebaseOptions: FirebaseOptions? { + if enabled, + requiredKeysAvailable, + let bundleID = bundleID, + let googleAppID = googleAppID, + let gcmSenderID = gcmSenderID { + let firebaseOptions = FirebaseOptions(googleAppID: googleAppID, + gcmSenderID: gcmSenderID) + firebaseOptions.apiKey = apiKey + firebaseOptions.projectID = projectID + firebaseOptions.bundleID = bundleID + firebaseOptions.clientID = clientID + firebaseOptions.storageBucket = storageBucket + firebaseOptions.databaseURL = databaseURL + } + + return nil + } +} + +private let key = "FIREBASE" +extension Config { + public var firebase: FirebaseConfig { + return FirebaseConfig(dictionary: self[key] as? [String: AnyObject] ?? [:]) + } +} diff --git a/Core/Core/Data/Repository/AuthRepository.swift b/Core/Core/Data/Repository/AuthRepository.swift index 874159e4c..8c47cb6a1 100644 --- a/Core/Core/Data/Repository/AuthRepository.swift +++ b/Core/Core/Data/Repository/AuthRepository.swift @@ -20,9 +20,9 @@ public class AuthRepository: AuthRepositoryProtocol { private let api: API private var appStorage: CoreStorage - private let config: Config + private let config: ConfigProtocol - public init(api: API, appStorage: CoreStorage, config: Config) { + public init(api: API, appStorage: CoreStorage, config: ConfigProtocol) { self.api = api self.appStorage = appStorage self.config = config diff --git a/Core/Core/Network/API.swift b/Core/Core/Network/API.swift index 1142d85ce..7e3964b62 100644 --- a/Core/Core/Network/API.swift +++ b/Core/Core/Network/API.swift @@ -12,9 +12,9 @@ import WebKit public final class API { private let session: Alamofire.Session - private let config: Config + private let config: ConfigProtocol - public init(session: Session, config: Config) { + public init(session: Session, config: ConfigProtocol) { self.session = session self.config = config } diff --git a/Core/Core/Network/RequestInterceptor.swift b/Core/Core/Network/RequestInterceptor.swift index c2b0b00fd..f27b6f310 100644 --- a/Core/Core/Network/RequestInterceptor.swift +++ b/Core/Core/Network/RequestInterceptor.swift @@ -10,10 +10,10 @@ import Alamofire final public class RequestInterceptor: Alamofire.RequestInterceptor { - private let config: Config + private let config: ConfigProtocol private var storage: CoreStorage - public init(config: Config, storage: CoreStorage) { + public init(config: ConfigProtocol, storage: CoreStorage) { self.config = config self.storage = storage } diff --git a/Core/Core/Tests/ConfigTests.swift b/Core/Core/Tests/ConfigTests.swift new file mode 100644 index 000000000..819970fb4 --- /dev/null +++ b/Core/Core/Tests/ConfigTests.swift @@ -0,0 +1,77 @@ +// +// ConfigTests.swift +// CoreTests +// +// Created by Muhammad Umer on 11/13/23. +// + +import XCTest +@testable import Core + +class ConfigTests: XCTestCase { + + private lazy var properties: [String: Any] = [ + "API_HOST_URL": "https://www.example.com", + "OAUTH_CLIENT_ID": "oauth_client_id", + "FEEDBACK_EMAIL_ADDRESS": "example@mail.com", + "TOKEN_TYPE": "JWT", + "WHATS_NEW_ENABLED": true, + "AGREEMENT_URLS": [ + "PRIVACY_POLICY_URL": "https://www.example.com/privacy", + "TOS_URL": "https://www.example.com/tos" + ], + "FIREBASE": [ + "ENABLED": true, + "API_KEY": "testApiKey", + "BUNDLE_ID": "testBundleID", + "CLIENT_ID": "testClientID", + "DATABASE_URL": "https://test.database.url", + "GCM_SENDER_ID": "testGCMSenderID", + "GOOGLE_APP_ID": "testGoogleAppID", + "PROJECT_ID": "testProjectID", + "REVERSED_CLIENT_ID": "testReversedClientID", + "STORAGE_BUCKET": "testStorageBucket", + "ANALYTICS_SOURCE": "firebase", + "CLOUD_MESSAGING_ENABLED": true] + ] + + func testConfigInitialization() { + let config = Config(properties: properties) + + XCTAssertEqual(config.baseURL.absoluteString, "https://www.example.com") + XCTAssertEqual(config.oAuthClientId, "oauth_client_id") + XCTAssertEqual(config.feedbackEmail, "example@mail.com") + XCTAssertEqual(config.tokenType.rawValue, "JWT") + XCTAssertTrue(config.features.whatNewEnabled) + } + + func testFeaturesConfigInitialization() { + let config = Config(properties: properties) + + XCTAssertTrue(config.features.whatNewEnabled) + } + + func testAgreementConfigInitialization() { + let config = Config(properties: properties) + + XCTAssertEqual(config.agreement.privacyPolicyURL, URL(string: "https://www.example.com/privacy")) + XCTAssertEqual(config.agreement.tosURL, URL(string: "https://www.example.com/tos")) + } + + func testFirebaseConfigInitialization() { + let config = Config(properties: properties) + + XCTAssertTrue(config.firebase.enabled) + XCTAssertEqual(config.firebase.apiKey, "testApiKey") + XCTAssertEqual(config.firebase.bundleID, "testBundleID") + XCTAssertEqual(config.firebase.clientID, "testClientID") + XCTAssertEqual(config.firebase.databaseURL, "https://test.database.url") + XCTAssertEqual(config.firebase.gcmSenderID, "testGCMSenderID") + XCTAssertEqual(config.firebase.googleAppID, "testGoogleAppID") + XCTAssertEqual(config.firebase.projectID, "testProjectID") + XCTAssertEqual(config.firebase.reversedClientID, "testReversedClientID") + XCTAssertEqual(config.firebase.storageBucket, "testStorageBucket") + XCTAssertEqual(config.firebase.isAnalyticsSourceFirebase, true) + XCTAssertEqual(config.firebase.cloudMessagingEnabled, true) + } +} diff --git a/Core/Core/View/Base/WebUnitViewModel.swift b/Core/Core/View/Base/WebUnitViewModel.swift index caa010a93..298abb1d0 100644 --- a/Core/Core/View/Base/WebUnitViewModel.swift +++ b/Core/Core/View/Base/WebUnitViewModel.swift @@ -11,7 +11,7 @@ import SwiftUI public class WebUnitViewModel: ObservableObject { let authInteractor: AuthInteractorProtocol - let config: Config + let config: ConfigProtocol @Published var updatingCookies: Bool = false @Published var cookiesReady: Bool = false @@ -26,7 +26,7 @@ public class WebUnitViewModel: ObservableObject { } } - public init(authInteractor: AuthInteractorProtocol, config: Config) { + public init(authInteractor: AuthInteractorProtocol, config: ConfigProtocol) { self.authInteractor = authInteractor self.config = config } diff --git a/Course/Course/Data/CourseRepository.swift b/Course/Course/Data/CourseRepository.swift index e07f75bb8..72abf3394 100644 --- a/Course/Course/Data/CourseRepository.swift +++ b/Course/Course/Data/CourseRepository.swift @@ -27,12 +27,12 @@ public class CourseRepository: CourseRepositoryProtocol { private let api: API private let appStorage: CoreStorage - private let config: Config + private let config: ConfigProtocol private let persistence: CoursePersistenceProtocol public init(api: API, appStorage: CoreStorage, - config: Config, + config: ConfigProtocol, persistence: CoursePersistenceProtocol) { self.api = api self.appStorage = appStorage diff --git a/Course/Course/Presentation/Container/CourseContainerViewModel.swift b/Course/Course/Presentation/Container/CourseContainerViewModel.swift index a3f90b8e9..fd86eb4c3 100644 --- a/Course/Course/Presentation/Container/CourseContainerViewModel.swift +++ b/Course/Course/Presentation/Container/CourseContainerViewModel.swift @@ -28,7 +28,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { } let router: CourseRouter - let config: Config + let config: ConfigProtocol let connectivity: ConnectivityProtocol let isActive: Bool? @@ -46,7 +46,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { authInteractor: AuthInteractorProtocol, router: CourseRouter, analytics: CourseAnalytics, - config: Config, + config: ConfigProtocol, connectivity: ConnectivityProtocol, manager: DownloadManagerProtocol, isActive: Bool?, diff --git a/Course/Course/Presentation/Details/CourseDetailsViewModel.swift b/Course/Course/Presentation/Details/CourseDetailsViewModel.swift index 6b1e6d747..ac2283d17 100644 --- a/Course/Course/Presentation/Details/CourseDetailsViewModel.swift +++ b/Course/Course/Presentation/Details/CourseDetailsViewModel.swift @@ -32,7 +32,7 @@ public class CourseDetailsViewModel: ObservableObject { private let interactor: CourseInteractorProtocol private let analytics: CourseAnalytics let router: CourseRouter - let config: Config + let config: ConfigProtocol let cssInjector: CSSInjector let connectivity: ConnectivityProtocol @@ -40,7 +40,7 @@ public class CourseDetailsViewModel: ObservableObject { interactor: CourseInteractorProtocol, router: CourseRouter, analytics: CourseAnalytics, - config: Config, + config: ConfigProtocol, cssInjector: CSSInjector, connectivity: ConnectivityProtocol ) { diff --git a/Dashboard/Dashboard/Data/DashboardRepository.swift b/Dashboard/Dashboard/Data/DashboardRepository.swift index ce5721784..cff780083 100644 --- a/Dashboard/Dashboard/Data/DashboardRepository.swift +++ b/Dashboard/Dashboard/Data/DashboardRepository.swift @@ -17,10 +17,10 @@ public class DashboardRepository: DashboardRepositoryProtocol { private let api: API private let storage: CoreStorage - private let config: Config + private let config: ConfigProtocol private let persistence: DashboardPersistenceProtocol - public init(api: API, storage: CoreStorage, config: Config, persistence: DashboardPersistenceProtocol) { + public init(api: API, storage: CoreStorage, config: ConfigProtocol, persistence: DashboardPersistenceProtocol) { self.api = api self.storage = storage self.config = config diff --git a/Discovery/Discovery/Data/DiscoveryRepository.swift b/Discovery/Discovery/Data/DiscoveryRepository.swift index 4ba3fa74b..bf898af35 100644 --- a/Discovery/Discovery/Data/DiscoveryRepository.swift +++ b/Discovery/Discovery/Data/DiscoveryRepository.swift @@ -20,12 +20,12 @@ public class DiscoveryRepository: DiscoveryRepositoryProtocol { private let api: API private let appStorage: CoreStorage - private let config: Config + private let config: ConfigProtocol private let persistence: DiscoveryPersistenceProtocol public init(api: API, appStorage: CoreStorage, - config: Config, + config: ConfigProtocol, persistence: DiscoveryPersistenceProtocol) { self.api = api self.appStorage = appStorage diff --git a/Discovery/Discovery/Presentation/DiscoveryViewModel.swift b/Discovery/Discovery/Presentation/DiscoveryViewModel.swift index 18391bcc5..ec8268ae1 100644 --- a/Discovery/Discovery/Presentation/DiscoveryViewModel.swift +++ b/Discovery/Discovery/Presentation/DiscoveryViewModel.swift @@ -29,14 +29,14 @@ public class DiscoveryViewModel: ObservableObject { } let router: DiscoveryRouter - let config: Config + let config: ConfigProtocol let connectivity: ConnectivityProtocol private let interactor: DiscoveryInteractorProtocol private let analytics: DiscoveryAnalytics public init( router: DiscoveryRouter, - config: Config, + config: ConfigProtocol, interactor: DiscoveryInteractorProtocol, connectivity: ConnectivityProtocol, analytics: DiscoveryAnalytics diff --git a/Discovery/Discovery/Presentation/UpdateViews/UpdateNotificationView.swift b/Discovery/Discovery/Presentation/UpdateViews/UpdateNotificationView.swift index 83098abf7..5f8a3dfab 100644 --- a/Discovery/Discovery/Presentation/UpdateViews/UpdateNotificationView.swift +++ b/Discovery/Discovery/Presentation/UpdateViews/UpdateNotificationView.swift @@ -10,9 +10,9 @@ import Core public struct UpdateNotificationView: View { - private let config: Config + private let config: ConfigProtocol - public init(config: Config) { + public init(config: ConfigProtocol) { self.config = config } diff --git a/Discovery/Discovery/Presentation/UpdateViews/UpdateRecommendedView.swift b/Discovery/Discovery/Presentation/UpdateViews/UpdateRecommendedView.swift index 5059707d7..066119a62 100644 --- a/Discovery/Discovery/Presentation/UpdateViews/UpdateRecommendedView.swift +++ b/Discovery/Discovery/Presentation/UpdateViews/UpdateRecommendedView.swift @@ -12,9 +12,9 @@ public struct UpdateRecommendedView: View { @Environment (\.isHorizontal) private var isHorizontal private let router: DiscoveryRouter - private let config: Config + private let config: ConfigProtocol - public init(router: DiscoveryRouter, config: Config) { + public init(router: DiscoveryRouter, config: ConfigProtocol) { self.router = router self.config = config } diff --git a/Discovery/Discovery/Presentation/UpdateViews/UpdateRequiredView.swift b/Discovery/Discovery/Presentation/UpdateViews/UpdateRequiredView.swift index 9f121b944..0d69d456b 100644 --- a/Discovery/Discovery/Presentation/UpdateViews/UpdateRequiredView.swift +++ b/Discovery/Discovery/Presentation/UpdateViews/UpdateRequiredView.swift @@ -12,10 +12,10 @@ public struct UpdateRequiredView: View { @Environment (\.isHorizontal) private var isHorizontal private let router: DiscoveryRouter - private let config: Config + private let config: ConfigProtocol private let showAccountLink: Bool - public init(router: DiscoveryRouter, config: Config, showAccountLink: Bool = true) { + public init(router: DiscoveryRouter, config: ConfigProtocol, showAccountLink: Bool = true) { self.router = router self.config = config self.showAccountLink = showAccountLink diff --git a/Discussion/Discussion/Data/Network/DiscussionRepository.swift b/Discussion/Discussion/Data/Network/DiscussionRepository.swift index 7e395da51..ab8686ad7 100644 --- a/Discussion/Discussion/Data/Network/DiscussionRepository.swift +++ b/Discussion/Discussion/Data/Network/DiscussionRepository.swift @@ -37,10 +37,10 @@ public class DiscussionRepository: DiscussionRepositoryProtocol { private let api: API private let appStorage: CoreStorage - private let config: Config + private let config: ConfigProtocol private let router: DiscussionRouter - public init(api: API, appStorage: CoreStorage, config: Config, router: DiscussionRouter) { + public init(api: API, appStorage: CoreStorage, config: ConfigProtocol, router: DiscussionRouter) { self.api = api self.appStorage = appStorage self.config = config diff --git a/Discussion/Discussion/Presentation/Comments/Base/BaseResponsesViewModel.swift b/Discussion/Discussion/Presentation/Comments/Base/BaseResponsesViewModel.swift index bc2572e8d..4a955aa2e 100644 --- a/Discussion/Discussion/Presentation/Comments/Base/BaseResponsesViewModel.swift +++ b/Discussion/Discussion/Presentation/Comments/Base/BaseResponsesViewModel.swift @@ -43,13 +43,13 @@ public class BaseResponsesViewModel { internal let interactor: DiscussionInteractorProtocol internal let router: DiscussionRouter - internal let config: Config + internal let config: ConfigProtocol internal let addPostSubject = CurrentValueSubject(nil) init( interactor: DiscussionInteractorProtocol, router: DiscussionRouter, - config: Config + config: ConfigProtocol ) { self.interactor = interactor self.router = router diff --git a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesViewModel.swift b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesViewModel.swift index 92555f692..cc7ec0820 100644 --- a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesViewModel.swift +++ b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesViewModel.swift @@ -19,7 +19,7 @@ public class ResponsesViewModel: BaseResponsesViewModel, ObservableObject { public init( interactor: DiscussionInteractorProtocol, router: DiscussionRouter, - config: Config, + config: ConfigProtocol, threadStateSubject: CurrentValueSubject ) { self.threadStateSubject = threadStateSubject diff --git a/Discussion/Discussion/Presentation/Comments/Thread/ThreadViewModel.swift b/Discussion/Discussion/Presentation/Comments/Thread/ThreadViewModel.swift index db10d8039..bc9be9fac 100644 --- a/Discussion/Discussion/Presentation/Comments/Thread/ThreadViewModel.swift +++ b/Discussion/Discussion/Presentation/Comments/Thread/ThreadViewModel.swift @@ -21,7 +21,7 @@ public class ThreadViewModel: BaseResponsesViewModel, ObservableObject { public init( interactor: DiscussionInteractorProtocol, router: DiscussionRouter, - config: Config, + config: ConfigProtocol, postStateSubject: CurrentValueSubject ) { self.postStateSubject = postStateSubject diff --git a/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadViewModel.swift b/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadViewModel.swift index 95c907300..46d12d614 100644 --- a/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadViewModel.swift +++ b/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadViewModel.swift @@ -26,12 +26,12 @@ public class CreateNewThreadViewModel: ObservableObject { public let interactor: DiscussionInteractorProtocol public let router: DiscussionRouter - public let config: Config + public let config: ConfigProtocol public init( interactor: DiscussionInteractorProtocol, router: DiscussionRouter, - config: Config + config: ConfigProtocol ) { self.interactor = interactor self.router = router diff --git a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsViewModel.swift b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsViewModel.swift index 9364f4d6b..85ec229e8 100644 --- a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsViewModel.swift +++ b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsViewModel.swift @@ -30,13 +30,13 @@ public class DiscussionTopicsViewModel: ObservableObject { let interactor: DiscussionInteractorProtocol let router: DiscussionRouter let analytics: DiscussionAnalytics - let config: Config + let config: ConfigProtocol public init(title: String, interactor: DiscussionInteractorProtocol, router: DiscussionRouter, analytics: DiscussionAnalytics, - config: Config) { + config: ConfigProtocol) { self.title = title self.interactor = interactor self.router = router diff --git a/Discussion/Discussion/Presentation/Posts/PostsViewModel.swift b/Discussion/Discussion/Presentation/Posts/PostsViewModel.swift index baad91ffc..04b02c788 100644 --- a/Discussion/Discussion/Presentation/Posts/PostsViewModel.swift +++ b/Discussion/Discussion/Presentation/Posts/PostsViewModel.swift @@ -76,14 +76,14 @@ public class PostsViewModel: ObservableObject { private var threads: ThreadLists = ThreadLists(threads: []) private let interactor: DiscussionInteractorProtocol private let router: DiscussionRouter - private let config: Config + private let config: ConfigProtocol internal let postStateSubject = CurrentValueSubject(nil) private var cancellable: AnyCancellable? public init( interactor: DiscussionInteractorProtocol, router: DiscussionRouter, - config: Config + config: ConfigProtocol ) { self.interactor = interactor self.router = router diff --git a/Documentation/CONFIGURATION_MANAGEMENT.md b/Documentation/CONFIGURATION_MANAGEMENT.md new file mode 100644 index 000000000..b7db2e6d9 --- /dev/null +++ b/Documentation/CONFIGURATION_MANAGEMENT.md @@ -0,0 +1,108 @@ +# Configuration Management + +This documentation provides a comprehensive solution for integrating and managing configuration files in OpenEdx iOS project. + +## Features + +- **Build Phase Script Integration:** Adds a script to the Build Phase of Xcode. It calls the Xcode build phase run script, which takes care of the virtual environment and installing dependencies and executes a Python script `process_config.py` with `$CONFIGURATION` and `scheme_mappings` argument. +- **Python Script for Configuration:** Utilizes `process_config.py` for: + - Adding essential keys to `Info.plist` (e.g., Facebook, Microsoft keys). + - Creating `GoogleServices.plist` with Firebase keys. + - Generating `config.plist` from `ios.yaml` and `shared.yaml`. + +Inside `Config.swift`, parsing and populating relevant keys and classes are done, e.g. `AgreementConfig.swift` and `FirebaseConfig.swift`. + +## Getting Started + +### Configuration Setup + +Edit a `config_settings.yaml` in the `default_config` folder. It should contain data as follows: + +```yaml +config_directory: '{path_to_config_folder}' +config_mapping: + prod: 'prod' + stage: 'stage' + dev: 'dev' +# These mappings are configurable, e.g. dev: 'prod_test' +``` + +- `config_directory` provides the path of the config directory. +- `config_mappings` provides mappings that can be utilized to map the Xcode build scheme to a defined folder within the config directory, and it will be referenced. + +### Configuration Files + +Two main configuration files are used: `ios.yaml` and `shared.yaml`, placed under the folder defined in `config_mappings`. Additionally, a `mappings.yaml` file is required in the same directory, specifying the YAML files to be processed. Its structure is as follows: + +```yaml +ios: + files: + - {file_one.yaml} + - {file_two.yaml} +``` + +- `ios.yaml` will contain config data specific to iOS, e.g., Firebase keys, Facebook keys, etc. +- `shared.yaml` will contain config data that is shared, e.g., `API_HOST_URL`, `OAUTH_CLIENT_ID`, `TOKEN_TYPE`, etc. + +## Future Support + +- To add config related to some other service, create a class, e.g. `ServiceNameConfig.swift`, to be able to populate related fields. +- Create an `extension` to `Config.swift` to be able to add the newly created service as a variable to the main Config. +- If needed, make a protocol to be referenced inside the scope of `ConfigProtocol` so that the config is available using `ConfigProtocol` service. + +Example: + +```swift +private let key = "KEY" +extension Config { + public var serviceNameConfig: ServiceNameConfig { + return ServiceNameConfig(dictionary: self[key] as? [String: AnyObject] ?? [:]) + } +} +``` + +## Note + +If Firebase Configuration is provided the updated `FirebaseCrashlytics` build phase script extracts `googleAppID` from the newly generated `GoogleService-Info.plist` and runs the Crashlytics script with the provifing id. + +## Examples of Config Files + +`ios.yaml`: + +```yaml +OAUTH_CLIENT_ID: '' + +FIREBASE: + ENABLED: true + API_KEY: "testApiKey" + BUNDLE_ID: "testBundleID" + CLIENT_ID: "testClientID" + DATABASE_URL: "https://test.database.url" + GCM_SENDER_ID: "testGCMSenderID" + GOOGLE_APP_ID: "testGoogleAppID" + PROJECT_ID: "testProjectID" + REVERSED_CLIENT_ID: "testReversedClientID" + STORAGE_BUCKET: "testStorageBucket" + ANALYTICS_SOURCE: "firebase" + +MICROSOFT: + ENABLED: true + APP_ID: "microsoftAppID" +``` + +`shared.yaml`: + +```yaml +API_HOST_URL: "https://www.example.com" +FEEDBACK_EMAIL_ADDRESS: "example@mail.com" +TOKEN_TYPE: "JWT" + +AGREEMENT_URLS: + PRIVACY_POLICY_URL: "https://www.example.com/privacy" + TOS_URL: "https://www.example.com/tos" + +# Features +WHATS_NEW_ENABLED: false +``` + +The `default_config` directory is added to the project to provide an idea of how to write config YAML files. diff --git a/OpenEdX.xcodeproj/project.pbxproj b/OpenEdX.xcodeproj/project.pbxproj index d3b1013cc..e1f36a25e 100644 --- a/OpenEdX.xcodeproj/project.pbxproj +++ b/OpenEdX.xcodeproj/project.pbxproj @@ -29,7 +29,6 @@ 02ED50D829A66007008341CD /* languages.json in Resources */ = {isa = PBXBuildFile; fileRef = 02ED50DA29A66007008341CD /* languages.json */; }; 02F175312A4DA95B0019CD70 /* MainScreenAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F175302A4DA95B0019CD70 /* MainScreenAnalytics.swift */; }; 071009C928D1DB3F00344290 /* ScreenAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 071009C828D1DB3F00344290 /* ScreenAssembly.swift */; }; - 0727876D28D23312002E9142 /* Environment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0727876C28D23312002E9142 /* Environment.swift */; }; 0727878E28D347C7002E9142 /* MainScreenView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0727878D28D347C7002E9142 /* MainScreenView.swift */; }; 072787B128D34D83002E9142 /* Discovery.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 072787B028D34D83002E9142 /* Discovery.framework */; }; 072787B228D34D83002E9142 /* Discovery.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 072787B028D34D83002E9142 /* Discovery.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -94,7 +93,6 @@ 02ED50DB29A6600B008341CD /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.json; name = uk; path = uk.lproj/languages.json; sourceTree = ""; }; 02F175302A4DA95B0019CD70 /* MainScreenAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainScreenAnalytics.swift; sourceTree = ""; }; 071009C828D1DB3F00344290 /* ScreenAssembly.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenAssembly.swift; sourceTree = ""; }; - 0727876C28D23312002E9142 /* Environment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Environment.swift; sourceTree = ""; }; 0727878D28D347C7002E9142 /* MainScreenView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainScreenView.swift; sourceTree = ""; }; 072787B028D34D83002E9142 /* Discovery.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Discovery.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 0770DE1228D07845006D8A5D /* Core.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Core.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -198,7 +196,6 @@ 0298DF2F2A4EF7230023A257 /* AnalyticsManager.swift */, 02F175302A4DA95B0019CD70 /* MainScreenAnalytics.swift */, 0293A2012A6FC9E30090A336 /* Data */, - 0727876C28D23312002E9142 /* Environment.swift */, 0727878C28D347B2002E9142 /* View */, 0770DE1A28D084BC006D8A5D /* DI */, 07D5DA3D28D075AB00752FD9 /* Assets.xcassets */, @@ -256,6 +253,7 @@ 07D5DA2E28D075AA00752FD9 /* Frameworks */, 07D5DA2F28D075AA00752FD9 /* Resources */, 0770DE1528D07845006D8A5D /* Embed Frameworks */, + DB97C0542B002EF00035C36F /* Process Config */, 02F175442A4E3B320019CD70 /* FirebaseCrashlytics */, ); buildRules = ( @@ -333,7 +331,7 @@ ); runOnlyForDeploymentPostprocessing = 1; shellPath = /bin/sh; - shellScript = "case $CONFIGURATION in\n \"DebugDev\" | \"ReleaseDev\" )\n googleAppID=$(grep -A 4 'case .debugDev, .releaseDev:' ${PROJECT_DIR}/${TARGET_NAME}/Environment.swift | grep 'googleAppID:' | awk -F'\"' '{print $2}')\n ;;\n \"DebugStage\" | \"ReleaseStage\" )\n googleAppID=$(grep -A 4 'case .debugStage, .releaseStage:' ${PROJECT_DIR}/${TARGET_NAME}/Environment.swift | grep 'googleAppID:' | awk -F'\"' '{print $2}')\n ;;\n \"DebugProd\" | \"RelesaseProd\" )\n googleAppID=$(grep -A 4 'case .debugProd, .releaseProd:' ${PROJECT_DIR}/${TARGET_NAME}/Environment.swift | grep 'googleAppID:' | awk -F'\"' '{print $2}')\n ;;\n *)\n echo \"Unknown configuration\"\n ;;\nesac\n\nif [ -z \"$googleAppID\" ]\nthen\n echo \"GoogleAppID is empty. The FirebaseCrashlytics script will be skipped.\"\nelse\n \"${PODS_ROOT}/FirebaseCrashlytics/run\" --app-id \"$googleAppID\" -p ios ${DWARF_DSYM_FOLDER_PATH}\nfi\n"; + shellScript = "plistPath=\"${BUILT_PRODUCTS_DIR}/${WRAPPER_NAME}/GoogleService-Info.plist\"\n\ngoogleAppID=$(/usr/libexec/PlistBuddy -c \"Print :GOOGLE_APP_ID\" \"$plistPath\")\n\nif [ -z \"$googleAppID\" ]\nthen\n echo \"GoogleAppID is empty. The FirebaseCrashlytics script will be skipped.\"\nelse\n \"${PODS_ROOT}/FirebaseCrashlytics/run\" --app-id \"$googleAppID\" -p ios \"${DWARF_DSYM_FOLDER_PATH}\"\nfi\n"; }; 0770DE2328D08647006D8A5D /* SwiftLint */ = { isa = PBXShellScriptBuildPhase; @@ -375,6 +373,25 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; + DB97C0542B002EF00035C36F /* Process Config */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Process Config"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "#!/bin/bash\n\nVENV_PATH=\"${SRCROOT}/venv\"\n\n/usr/bin/python3 -m venv \"$VENV_PATH\"\n\nsource \"$VENV_PATH/bin/activate\"\n\npip install --upgrade pip\npip install PyYAML\n\nscheme_mapping='{\n \"prod\": [\"ReleaseProd\", \"DebugProd\"],\n \"stage\": [\"ReleaseStage\", \"DebugStage\"],\n \"dev\": [\"ReleaseDev\", \"DebugDev\"]\n}'\n\npython config_script/process_config.py \"$CONFIGURATION\" \"$scheme_mapping\"\n\ndeactivate\n"; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -395,7 +412,6 @@ 025AD4AC2A6FB95C00AB8FA7 /* DatabaseManager.swift in Sources */, 024E69202AEFC3FB00FA0B59 /* MainScreenViewModel.swift in Sources */, 0770DE2028D0858A006D8A5D /* Router.swift in Sources */, - 0727876D28D23312002E9142 /* Environment.swift in Sources */, 0293A2092A6FCDE50090A336 /* DashboardPersistence.swift in Sources */, 0770DE1728D080A1006D8A5D /* RouteController.swift in Sources */, 071009C928D1DB3F00344290 /* ScreenAssembly.swift in Sources */, diff --git a/OpenEdX/AppDelegate.swift b/OpenEdX/AppDelegate.swift index bb7c92a3b..2c6d8b71b 100644 --- a/OpenEdX/AppDelegate.swift +++ b/OpenEdX/AppDelegate.swift @@ -31,13 +31,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate { didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - if BuildConfiguration.shared.firebaseOptions.apiKey != "" { - FirebaseApp.configure(options: BuildConfiguration.shared.firebaseOptions) + initDI() + + if let config = Container.shared.resolve(ConfigProtocol.self), + let configuration = config.firebase.firebaseOptions { + FirebaseApp.configure(options: configuration) Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(true) } - initDI() - Theme.Fonts.registerFonts() window = UIWindow(frame: UIScreen.main.bounds) window?.rootViewController = RouteController() diff --git a/OpenEdX/DI/AppAssembly.swift b/OpenEdX/DI/AppAssembly.swift index 9c2bff47e..e5a491fc7 100644 --- a/OpenEdX/DI/AppAssembly.swift +++ b/OpenEdX/DI/AppAssembly.swift @@ -117,12 +117,14 @@ class AppAssembly: Assembly { r.resolve(Router.self)! }.inObjectScope(.container) - container.register(Config.self) { _ in - Config(baseURL: BuildConfiguration.shared.baseURL, oAuthClientId: BuildConfiguration.shared.clientId) + container.register(ConfigProtocol.self) { _ in + Config() }.inObjectScope(.container) - container.register(CSSInjector.self) { _ in - CSSInjector(baseURL: BuildConfiguration.shared.baseURL) + container.register(CSSInjector.self) { r in + CSSInjector( + config: r.resolve(ConfigProtocol.self)! + ) }.inObjectScope(.container) container.register(KeychainSwift.self) { _ in diff --git a/OpenEdX/DI/NetworkAssembly.swift b/OpenEdX/DI/NetworkAssembly.swift index 1f036a860..83537fb29 100644 --- a/OpenEdX/DI/NetworkAssembly.swift +++ b/OpenEdX/DI/NetworkAssembly.swift @@ -13,7 +13,7 @@ import Swinject class NetworkAssembly: Assembly { func assemble(container: Container) { container.register(RequestInterceptor.self) { r in - RequestInterceptor(config: r.resolve(Config.self)!, storage: r.resolve(CoreStorage.self)!) + RequestInterceptor(config: r.resolve(ConfigProtocol.self)!, storage: r.resolve(CoreStorage.self)!) }.inObjectScope(.container) container.register(Alamofire.Session.self) { r in @@ -38,7 +38,7 @@ class NetworkAssembly: Assembly { }.inObjectScope(.container) container.register(API.self) {r in - API(session: r.resolve(Alamofire.Session.self)!, config: r.resolve(Config.self)!) + API(session: r.resolve(Alamofire.Session.self)!, config: r.resolve(ConfigProtocol.self)!) }.inObjectScope(.container) } } diff --git a/OpenEdX/DI/ScreenAssembly.swift b/OpenEdX/DI/ScreenAssembly.swift index f826a23b6..c50f7e0f0 100644 --- a/OpenEdX/DI/ScreenAssembly.swift +++ b/OpenEdX/DI/ScreenAssembly.swift @@ -24,7 +24,7 @@ class ScreenAssembly: Assembly { AuthRepository( api: r.resolve(API.self)!, appStorage: r.resolve(CoreStorage.self)!, - config: r.resolve(Config.self)! + config: r.resolve(ConfigProtocol.self)! ) } container.register(AuthInteractorProtocol.self) { r in @@ -37,7 +37,7 @@ class ScreenAssembly: Assembly { container.register(MainScreenViewModel.self) { r in MainScreenViewModel( analytics: r.resolve(MainScreenAnalytics.self)!, - config: r.resolve(Config.self)!, + config: r.resolve(ConfigProtocol.self)!, profileInteractor: r.resolve(ProfileInteractorProtocol.self)! ) } @@ -47,7 +47,7 @@ class ScreenAssembly: Assembly { SignInViewModel( interactor: r.resolve(AuthInteractorProtocol.self)!, router: r.resolve(AuthorizationRouter.self)!, - config: r.resolve(Config.self)!, + config: r.resolve(ConfigProtocol.self)!, analytics: r.resolve(AuthorizationAnalytics.self)!, validator: r.resolve(Validator.self)! ) @@ -57,7 +57,7 @@ class ScreenAssembly: Assembly { interactor: r.resolve(AuthInteractorProtocol.self)!, router: r.resolve(AuthorizationRouter.self)!, analytics: r.resolve(AuthorizationAnalytics.self)!, - config: r.resolve(Config.self)!, + config: r.resolve(ConfigProtocol.self)!, cssInjector: r.resolve(CSSInjector.self)!, validator: r.resolve(Validator.self)! ) @@ -80,7 +80,7 @@ class ScreenAssembly: Assembly { DiscoveryRepository( api: r.resolve(API.self)!, appStorage: r.resolve(CoreStorage.self)!, - config: r.resolve(Config.self)!, + config: r.resolve(ConfigProtocol.self)!, persistence: r.resolve(DiscoveryPersistenceProtocol.self)! ) } @@ -92,7 +92,7 @@ class ScreenAssembly: Assembly { container.register(DiscoveryViewModel.self) { r in DiscoveryViewModel( router: r.resolve(DiscoveryRouter.self)!, - config: r.resolve(Config.self)!, + config: r.resolve(ConfigProtocol.self)!, interactor: r.resolve(DiscoveryInteractorProtocol.self)!, connectivity: r.resolve(ConnectivityProtocol.self)!, analytics: r.resolve(DiscoveryAnalytics.self)! @@ -118,7 +118,7 @@ class ScreenAssembly: Assembly { DashboardRepository( api: r.resolve(API.self)!, storage: r.resolve(CoreStorage.self)!, - config: r.resolve(Config.self)!, + config: r.resolve(ConfigProtocol.self)!, persistence: r.resolve(DashboardPersistenceProtocol.self)! ) } @@ -143,7 +143,7 @@ class ScreenAssembly: Assembly { storage: r.resolve(AppStorage.self)!, coreDataHandler: r.resolve(CoreDataHandlerProtocol.self)!, downloadManager: r.resolve(DownloadManagerProtocol.self)!, - config: r.resolve(Config.self)! + config: r.resolve(ConfigProtocol.self)! ) } container.register(ProfileInteractorProtocol.self) { r in @@ -156,7 +156,7 @@ class ScreenAssembly: Assembly { interactor: r.resolve(ProfileInteractorProtocol.self)!, router: r.resolve(ProfileRouter.self)!, analytics: r.resolve(ProfileAnalytics.self)!, - config: r.resolve(Config.self)!, + config: r.resolve(ConfigProtocol.self)!, connectivity: r.resolve(ConnectivityProtocol.self)! ) } @@ -194,7 +194,7 @@ class ScreenAssembly: Assembly { CourseRepository( api: r.resolve(API.self)!, appStorage: r.resolve(CoreStorage.self)!, - config: r.resolve(Config.self)!, + config: r.resolve(ConfigProtocol.self)!, persistence: r.resolve(CoursePersistenceProtocol.self)! ) } @@ -208,7 +208,7 @@ class ScreenAssembly: Assembly { interactor: r.resolve(CourseInteractorProtocol.self)!, router: r.resolve(CourseRouter.self)!, analytics: r.resolve(CourseAnalytics.self)!, - config: r.resolve(Config.self)!, + config: r.resolve(ConfigProtocol.self)!, cssInjector: r.resolve(CSSInjector.self)!, connectivity: r.resolve(ConnectivityProtocol.self)! ) @@ -223,7 +223,7 @@ class ScreenAssembly: Assembly { authInteractor: r.resolve(AuthInteractorProtocol.self)!, router: r.resolve(CourseRouter.self)!, analytics: r.resolve(CourseAnalytics.self)!, - config: r.resolve(Config.self)!, + config: r.resolve(ConfigProtocol.self)!, connectivity: r.resolve(ConnectivityProtocol.self)!, manager: r.resolve(DownloadManagerProtocol.self)!, isActive: isActive, @@ -267,7 +267,7 @@ class ScreenAssembly: Assembly { container.register(WebUnitViewModel.self) { r in WebUnitViewModel(authInteractor: r.resolve(AuthInteractorProtocol.self)!, - config: r.resolve(Config.self)!) + config: r.resolve(ConfigProtocol.self)!) } container.register( @@ -326,7 +326,7 @@ class ScreenAssembly: Assembly { DiscussionRepository( api: r.resolve(API.self)!, appStorage: r.resolve(CoreStorage.self)!, - config: r.resolve(Config.self)!, + config: r.resolve(ConfigProtocol.self)!, router: r.resolve(DiscussionRouter.self)! ) } @@ -343,7 +343,7 @@ class ScreenAssembly: Assembly { interactor: r.resolve(DiscussionInteractorProtocol.self)!, router: r.resolve(DiscussionRouter.self)!, analytics: r.resolve(DiscussionAnalytics.self)!, - config: r.resolve(Config.self)! + config: r.resolve(ConfigProtocol.self)! ) } @@ -360,7 +360,7 @@ class ScreenAssembly: Assembly { PostsViewModel( interactor: r.resolve(DiscussionInteractorProtocol.self)!, router: r.resolve(DiscussionRouter.self)!, - config: r.resolve(Config.self)! + config: r.resolve(ConfigProtocol.self)! ) } @@ -368,7 +368,7 @@ class ScreenAssembly: Assembly { ThreadViewModel( interactor: r.resolve(DiscussionInteractorProtocol.self)!, router: r.resolve(DiscussionRouter.self)!, - config: r.resolve(Config.self)!, + config: r.resolve(ConfigProtocol.self)!, postStateSubject: subject ) } @@ -377,7 +377,7 @@ class ScreenAssembly: Assembly { ResponsesViewModel( interactor: r.resolve(DiscussionInteractorProtocol.self)!, router: r.resolve(DiscussionRouter.self)!, - config: r.resolve(Config.self)!, + config: r.resolve(ConfigProtocol.self)!, threadStateSubject: subject ) } @@ -386,7 +386,7 @@ class ScreenAssembly: Assembly { CreateNewThreadViewModel( interactor: r.resolve(DiscussionInteractorProtocol.self)!, router: r.resolve(DiscussionRouter.self)!, - config: r.resolve(Config.self)! + config: r.resolve(ConfigProtocol.self)! ) } } diff --git a/OpenEdX/Environment.swift b/OpenEdX/Environment.swift deleted file mode 100644 index e89c0bb88..000000000 --- a/OpenEdX/Environment.swift +++ /dev/null @@ -1,89 +0,0 @@ -// -// Environment.swift -// OpenEdX -// -// Created by Vladimir Chekyrta on 14.09.2022. -// - -import Foundation -import Core -import FirebaseCore - -enum `Environment`: String { - case debugDev = "DebugDev" - case releaseDev = "ReleaseDev" - - case debugStage = "DebugStage" - case releaseStage = "ReleaseStage" - - case debugProd = "DebugProd" - case releaseProd = "ReleaseProd" -} - -class BuildConfiguration { - static let shared = BuildConfiguration() - - var environment: Environment - - var baseURL: String { - switch environment { - case .debugDev, .releaseDev: - return "https://example-dev.com" - case .debugStage, .releaseStage: - return "https://example-stage.com" - case .debugProd, .releaseProd: - return "https://example.com" - } - } - - var clientId: String { - switch environment { - case .debugDev, .releaseDev: - return "DEV_CLIENT_ID" - case .debugStage, .releaseStage: - return "STAGE_CLIENT_ID" - case .debugProd, .releaseProd: - return "PROD_CLIENT_ID" - } - } - - var firebaseOptions: FirebaseOptions { - switch environment { - case .debugDev, .releaseDev: - let firebaseOptions = FirebaseOptions(googleAppID: "", - gcmSenderID: "") - firebaseOptions.apiKey = "" - firebaseOptions.projectID = "" - firebaseOptions.bundleID = "" - firebaseOptions.clientID = "" - firebaseOptions.storageBucket = "" - - return firebaseOptions - case .debugStage, .releaseStage: - let firebaseOptions = FirebaseOptions(googleAppID: "", - gcmSenderID: "") - firebaseOptions.apiKey = "" - firebaseOptions.projectID = "" - firebaseOptions.bundleID = "" - firebaseOptions.clientID = "" - firebaseOptions.storageBucket = "" - - return firebaseOptions - case .debugProd, .releaseProd: - let firebaseOptions = FirebaseOptions(googleAppID: "", - gcmSenderID: "") - firebaseOptions.apiKey = "" - firebaseOptions.projectID = "" - firebaseOptions.bundleID = "" - firebaseOptions.clientID = "" - firebaseOptions.storageBucket = "" - - return firebaseOptions - } - } - - init() { - let currentConfiguration = Bundle.main.object(forInfoDictionaryKey: "Configuration") as! String - environment = Environment(rawValue: currentConfiguration)! - } -} diff --git a/OpenEdX/RouteController.swift b/OpenEdX/RouteController.swift index 6d7d0297b..7478fac65 100644 --- a/OpenEdX/RouteController.swift +++ b/OpenEdX/RouteController.swift @@ -51,12 +51,12 @@ class RouteController: UIViewController { private func showMainOrWhatsNewScreen() { var storage = Container.shared.resolve(WhatsNewStorage.self)! - let config = Container.shared.resolve(Config.self)! + let config = Container.shared.resolve(ConfigProtocol.self)! let viewModel = WhatsNewViewModel(storage: storage) let shouldShowWhatsNew = viewModel.shouldShowWhatsNew() - if shouldShowWhatsNew && config.whatsNewEnabled { + if shouldShowWhatsNew && config.features.whatNewEnabled { if let jsonVersion = viewModel.getVersion() { storage.whatsNewVersion = jsonVersion } diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index 2fb8f7983..389531979 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -61,13 +61,13 @@ public class Router: AuthorizationRouter, public func showMainOrWhatsNewScreen() { showToolBar() var storage = Container.shared.resolve(WhatsNewStorage.self)! - let config = Container.shared.resolve(Config.self)! + let config = Container.shared.resolve(ConfigProtocol.self)! let viewModel = WhatsNewViewModel(storage: storage) let whatsNew = WhatsNewView(router: Container.shared.resolve(WhatsNewRouter.self)!, viewModel: viewModel) let shouldShowWhatsNew = viewModel.shouldShowWhatsNew() - if shouldShowWhatsNew && config.whatsNewEnabled { + if shouldShowWhatsNew && config.features.whatNewEnabled { if let jsonVersion = viewModel.getVersion() { storage.whatsNewVersion = jsonVersion } @@ -441,7 +441,7 @@ public class Router: AuthorizationRouter, public func showUpdateRequiredView(showAccountLink: Bool = true) { let view = UpdateRequiredView( router: self, - config: Container.shared.resolve(Config.self)!, + config: Container.shared.resolve(ConfigProtocol.self)!, showAccountLink: showAccountLink ) let controller = UIHostingController(rootView: view) @@ -449,7 +449,7 @@ public class Router: AuthorizationRouter, } public func showUpdateRecomendedView() { - let view = UpdateRecommendedView(router: self, config: Container.shared.resolve(Config.self)!) + let view = UpdateRecommendedView(router: self, config: Container.shared.resolve(ConfigProtocol.self)!) self.presentView(transitionStyle: .crossDissolve, view: view) } diff --git a/OpenEdX/View/MainScreenViewModel.swift b/OpenEdX/View/MainScreenViewModel.swift index 0296a6509..d45b3503c 100644 --- a/OpenEdX/View/MainScreenViewModel.swift +++ b/OpenEdX/View/MainScreenViewModel.swift @@ -12,10 +12,10 @@ import Profile class MainScreenViewModel: ObservableObject { private let analytics: MainScreenAnalytics - let config: Config + let config: ConfigProtocol let profileInteractor: ProfileInteractorProtocol - init(analytics: MainScreenAnalytics, config: Config, profileInteractor: ProfileInteractorProtocol) { + init(analytics: MainScreenAnalytics, config: ConfigProtocol, profileInteractor: ProfileInteractorProtocol) { self.analytics = analytics self.config = config self.profileInteractor = profileInteractor diff --git a/Podfile.lock b/Podfile.lock index cf6c60e94..4afedd8c7 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -182,4 +182,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: a44d8de5a5803eb3e3c995134c79c3dad959dbf7 -COCOAPODS: 1.12.1 +COCOAPODS: 1.13.0 diff --git a/Profile/Profile/Data/ProfileRepository.swift b/Profile/Profile/Data/ProfileRepository.swift index e445f5225..788c7c513 100644 --- a/Profile/Profile/Data/ProfileRepository.swift +++ b/Profile/Profile/Data/ProfileRepository.swift @@ -30,14 +30,14 @@ public class ProfileRepository: ProfileRepositoryProtocol { private var storage: CoreStorage & ProfileStorage private let downloadManager: DownloadManagerProtocol private let coreDataHandler: CoreDataHandlerProtocol - private let config: Config + private let config: ConfigProtocol public init( api: API, storage: CoreStorage & ProfileStorage, coreDataHandler: CoreDataHandlerProtocol, downloadManager: DownloadManagerProtocol, - config: Config + config: ConfigProtocol ) { self.api = api self.storage = storage diff --git a/Profile/Profile/Presentation/Profile/ProfileView.swift b/Profile/Profile/Presentation/Profile/ProfileView.swift index 3456a1444..5d8dd18d5 100644 --- a/Profile/Profile/Presentation/Profile/ProfileView.swift +++ b/Profile/Profile/Presentation/Profile/ProfileView.swift @@ -118,7 +118,7 @@ public struct ProfileView: View { .foregroundColor(Theme.Colors.textSecondary) } - if let tos = viewModel.config.termsOfUse { + if let tos = viewModel.config.agreement.tosURL { Button(action: { viewModel.trackCookiePolicyClicked() UIApplication.shared.open(tos) @@ -136,7 +136,7 @@ public struct ProfileView: View { .foregroundColor(Theme.Colors.textSecondary) } - if let privacy = viewModel.config.privacyPolicy { + if let privacy = viewModel.config.agreement.privacyPolicyURL { Button(action: { viewModel.trackPrivacyPolicyClicked() UIApplication.shared.open(privacy) diff --git a/Profile/Profile/Presentation/Profile/ProfileViewModel.swift b/Profile/Profile/Presentation/Profile/ProfileViewModel.swift index e31d837be..a7647e7df 100644 --- a/Profile/Profile/Presentation/Profile/ProfileViewModel.swift +++ b/Profile/Profile/Presentation/Profile/ProfileViewModel.swift @@ -35,7 +35,7 @@ public class ProfileViewModel: ObservableObject { @Published var latestVersion: String = "" let router: ProfileRouter - let config: Config + let config: ConfigProtocol let connectivity: ConnectivityProtocol private let interactor: ProfileInteractorProtocol @@ -45,7 +45,7 @@ public class ProfileViewModel: ObservableObject { interactor: ProfileInteractorProtocol, router: ProfileRouter, analytics: ProfileAnalytics, - config: Config, + config: ConfigProtocol, connectivity: ConnectivityProtocol ) { self.interactor = interactor diff --git a/README.md b/README.md index 668a7a554..7586b4758 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Modern vision of the mobile application for the Open EdX platform from Raccoon G 4. Ensure that the ``OpenEdXDev`` or ``OpenEdXProd`` scheme is selected. -5. Configure the [``Environment.swift`` file](https://github.com/raccoongang/new-edx-app-ios/blob/main/OpenEdX/Environment.swift) with URLs and OAuth credentials for your Open edX instance. +5. Configure `config_settings.yaml` inside `default_config` and `config.yaml` inside sub direcroties to point to your OpenEdx configuration [Configuration Docuementation](./Documentation/CONFIGURATION_MANAGEMENT.md) 6. Click the **Run** button. diff --git a/config_script/process_config.py b/config_script/process_config.py new file mode 100644 index 000000000..6941ce096 --- /dev/null +++ b/config_script/process_config.py @@ -0,0 +1,306 @@ +import plistlib +import os +import yaml +from pathlib import Path +import sys +import json + +class PlistManager: + def __init__(self, config_dir, config_files): + self.config_dir = config_dir + self.config_files = config_files + + def get_config_paths(self): + return [Path(self.config_dir) / config_name for config_name in self.config_files] + + def get_product_name(self): + return os.getenv('PRODUCT_NAME') + + def get_bundle_identifier(self): + return os.getenv('PRODUCT_BUNDLE_IDENTIFIER') + + def get_info_plist_path(self): + return os.getenv('INFOPLIST_PATH') + + def get_wrapper_name(self): + return os.getenv('WRAPPER_NAME') + + def get_built_products_path(self): + return os.getenv('BUILT_PRODUCTS_DIR') + + def get_bundle_config_path(self): + return os.path.join(self.get_built_products_path(), self.get_wrapper_name(), 'config.plist') + + def get_app_info_plist_path(self): + built_products_path = self.get_built_products_path() + info_plist_path = self.get_info_plist_path() + + if built_products_path and info_plist_path: + return os.path.join(built_products_path, info_plist_path) + else: + return None + + def get_firebase_info_plist_path(self): + built_products_path = self.get_built_products_path() + wrapper_name = self.get_wrapper_name() + + if built_products_path and wrapper_name: + return os.path.join(built_products_path, wrapper_name, 'GoogleService-Info.plist') + else: + print("The BUILT_PRODUCTS_DIR or WRAPPER_NAME environment variable is not set.") + return None + + def get_firebase_config_path(self): + built_products_path = self.get_built_products_path() + wrapper_name = self.get_wrapper_name() + + if built_products_path and wrapper_name: + return os.path.join(built_products_path, wrapper_name, 'firebase.plist') + else: + print("The BUILT_PRODUCTS_DIR or WRAPPER_NAME environment variable is not set.") + return None + + def load_config(self): + properties = {} + + for path in self.get_config_paths(): + try: + with open(path, 'r') as file: + dict = yaml.safe_load(file) + if dict is not None: + properties.update(dict) + except FileNotFoundError: + print(f"{path} not found. Skipping.") + + return properties + + def yaml_to_plist(self): + plist_data = {} + + for path in self.get_config_paths(): + try: + with open(path, 'r') as file: + yaml_data = yaml.safe_load(file) + if yaml_data is not None: + plist_data.update(yaml_data) + except FileNotFoundError: + print(f"{path} not found. Skipping.") + except yaml.YAMLError as e: + print(f"Error parsing YAML file {path}: {e}") + + return plist_data + + def write_to_plist_file(self, plist, file_path): + file_name = os.path.basename(file_path) + with open(file_path, 'wb') as plist_file: + plistlib.dump(plist, plist_file) + print(f"File {file_name} has been written to {file_path}") + + def print_info_plist_contents(self, plist_path): + if not plist_path: + print(f"Path is not set. {plist_path}") + try: + with open(plist_path, 'rb') as plist_file: + plist_contents = plistlib.load(plist_file) + print(plist_contents) + except Exception as e: + print(f"Error reading plist file: {e}") + + def get_info_plist_contents(self, plist_path): + if not plist_path: + print(f"Path is not set. {plist_path}") + try: + with open(plist_path, 'rb') as plist_file: + plist_contents = plistlib.load(plist_file) + return plist_contents + except Exception as e: + print(f"Error reading plist file: {e}") + return None + + +class ConfigurationManager: + def __init__(self, plist_manager): + self.plist_manager = plist_manager + + def get_environment_variable(self, variable): + return os.getenv(variable) + + def add_url_scheme(self, scheme, plist): + body = { + 'CFBundleTypeRole': 'Editor', + 'CFBundleURLSchemes': scheme + } + existing = plist.get('CFBundleURLTypes', []) + found = any(scheme in entry.get('CFBundleURLSchemes', []) for entry in existing) + + if not found: + existing.append(body) + plist['CFBundleURLTypes'] = existing + + return plist + + def add_firebase_config(self, config, firebase_info_plist_path): + plist = {} + firebase = config.get('FIREBASE', {}) + + if firebase_info_plist_path and firebase: + plist['BUNDLE_ID'] = self.plist_manager.get_bundle_identifier() + plist['API_KEY'] = firebase.get('API_KEY', '') + plist['CLIENT_ID'] = firebase.get('CLIENT_ID', '') + plist['GOOGLE_APP_ID'] = firebase.get('GOOGLE_APP_ID', '') + plist['GCM_SENDER_ID'] = firebase.get('GCM_SENDER_ID', '') + + project_id = firebase.get('PROJECT_ID', '') + if project_id: + plist['PROJECT_ID'] = project_id + plist['STORAGE_BUCKET'] = project_id + '.appspot.com' + plist['DATABASE_URL'] = 'https://' + project_id + '.firebaseio.com' + + reversed_client_id = firebase.get('REVERSED_CLIENT_ID', '') + if reversed_client_id: + plist['REVERSED_CLIENT_ID'] = reversed_client_id + + self.plist_manager.write_to_plist_file(plist, self.plist_manager.get_firebase_info_plist_path()) + else: + print("Firebase config is empty. Skipping") + + def add_facebook_config(self, config, plist): + facebook = config.get('FACEBOOK', {}) + key = facebook.get('FACEBOOK_APP_ID') + client_token = facebook.get('CLIENT_TOKEN') + + if key and client_token: + plist["FacebookAppID"] = key + plist["FacebookClientToken"] = client_token + plist["FacebookDisplayName"] = self.plist_manager.get_product_name() + scheme = ["fb" + key] + self.add_url_scheme(scheme, plist) + + def add_google_config(self, config, plist): + google = config.get('GOOGLE', {}) + key = google.get('GOOGLE_PLUS_KEY') + + if key: + scheme = ['.'.join(reversed(key.split('.')))] + self.add_url_scheme(scheme, plist) + + def add_microsoft_config(self, config, plist): + microsoft = config.get('MICROSOFT', {}) + key = microsoft.get('APP_ID') + + if key: + bundle_identifier = self.plist_manager.get_bundle_identifier() + scheme = ["msauth." + bundle_identifier] + self.add_url_scheme(scheme, plist) + + def update_info_plist(self, plist_data, plist_path): + if not plist_path: + print("Path is not set.") + sys.exit(1) + + try: + with open(plist_path, 'rb') as plist_file: + plist_contents = plistlib.load(plist_file) + + plist_contents.update(plist_data) + + try: + plistlib.dumps(plist_contents) + except Exception as e: + print(f"Error validating plist contents: {e}") + sys.exit(1) + + self.plist_manager.write_to_plist_file(plist_contents, plist_path) + except FileNotFoundError: + print(f"Plist file not found: {plist_path}") + sys.exit(1) + except Exception as e: + print(f"Error reading or writing plist file: {e}") + sys.exit(1) + +def parse_yaml(file_path): + try: + with open(file_path, 'r') as file: + return yaml.safe_load(file) + except Exception as e: + print(f"Unable to open or read the file '{file_path}': {e}") + return None + +CONFIG_SETTINGS_YAML_FILENAME = 'config_settings.yaml' +DEFAULT_CONFIG_PATH = './default_config/' + CONFIG_SETTINGS_YAML_FILENAME +CONFIG_DIRECTORY_NAME = 'config_directory' +CONFIG_MAPPINGS = 'config_mapping' +MAPPINGS_FILENAME = 'file_mappings.yaml' + +def get_current_config(configuration, scheme_mappings): + for key, values in scheme_mappings.items(): + if configuration in values: + return key + return None + +def process_plist_files(configuration_manager, plist_manager, config): + firebase_info_plist_path = plist_manager.get_firebase_config_path() + info_plist_path = plist_manager.get_app_info_plist_path() + info_plist_content = plist_manager.get_info_plist_contents(info_plist_path) + + configuration_manager.add_firebase_config(config, firebase_info_plist_path) + configuration_manager.add_facebook_config(config, info_plist_content) + configuration_manager.add_google_config(config, info_plist_content) + configuration_manager.add_microsoft_config(config, info_plist_content) + + configuration_manager.update_info_plist(info_plist_content, info_plist_path) + + bundle_config_path = plist_manager.get_bundle_config_path() + config_plist = plist_manager.yaml_to_plist() + plist_manager.write_to_plist_file(config_plist, bundle_config_path) + +def main(configuration, scheme_mappings): + current_config = get_current_config(configuration, scheme_mappings) + + if current_config is None: + print("Config not found in mappings. Exiting.") + sys.exit(1) + + config_settings = parse_yaml(CONFIG_SETTINGS_YAML_FILENAME) + + if not config_settings: + print("Parsing default config.") + config_settings = parse_yaml(DEFAULT_CONFIG_PATH) + + config_directory = config_settings.get(CONFIG_DIRECTORY_NAME) + config_name = config_settings.get(CONFIG_MAPPINGS, {}).get(current_config) + + if config_directory and config_name: + path = os.path.join(config_directory, config_name) + mappings_path = os.path.join(path, MAPPINGS_FILENAME) + data = parse_yaml(mappings_path) + + if data: + ios_files = data.get('ios', {}).get('files', []) + plist_manager = PlistManager(path, ios_files) + config = plist_manager.load_config() + + if config: + configuration_manager = ConfigurationManager(plist_manager) + process_plist_files(configuration_manager, plist_manager, config) + print(f"Config {configuration} parsed and written successfully.") + else: + print("Unable to parse config files") + sys.exit(1) + + else: + print("Files mappings not found") + sys.exit(1) + + else: + print("Config directory or config name is not provided") + sys.exit(1) + +if __name__ == "__main__": + if len(sys.argv) < 3: + print("Usage: script.py ") + sys.exit(1) + + configuration = sys.argv[1] + scheme_mappings = json.loads(sys.argv[2]) + main(configuration, scheme_mappings) diff --git a/default_config/config_settings.yaml b/default_config/config_settings.yaml new file mode 100644 index 000000000..249e93fc3 --- /dev/null +++ b/default_config/config_settings.yaml @@ -0,0 +1,5 @@ +config_directory: './default_config' +config_mapping: + prod: 'prod' + stage: 'stage' + dev: 'dev' diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml new file mode 100644 index 000000000..d7e76817e --- /dev/null +++ b/default_config/dev/config.yaml @@ -0,0 +1,4 @@ +API_HOST_URL: 'http://localhost:8000' +ENVIRONMENT_DISPLAY_NAME: 'Localhost' +FEEDBACK_EMAIL_ADDRESS: 'support@example.com' +OAUTH_CLIENT_ID: '' diff --git a/default_config/dev/file_mappings.yaml b/default_config/dev/file_mappings.yaml new file mode 100644 index 000000000..86d84fa91 --- /dev/null +++ b/default_config/dev/file_mappings.yaml @@ -0,0 +1,3 @@ +ios: + files: + - config.yaml diff --git a/default_config/prod/config.yaml b/default_config/prod/config.yaml new file mode 100644 index 000000000..d7e76817e --- /dev/null +++ b/default_config/prod/config.yaml @@ -0,0 +1,4 @@ +API_HOST_URL: 'http://localhost:8000' +ENVIRONMENT_DISPLAY_NAME: 'Localhost' +FEEDBACK_EMAIL_ADDRESS: 'support@example.com' +OAUTH_CLIENT_ID: '' diff --git a/default_config/prod/file_mappings.yaml b/default_config/prod/file_mappings.yaml new file mode 100644 index 000000000..86d84fa91 --- /dev/null +++ b/default_config/prod/file_mappings.yaml @@ -0,0 +1,3 @@ +ios: + files: + - config.yaml diff --git a/default_config/stage/config.yaml b/default_config/stage/config.yaml new file mode 100644 index 000000000..d7e76817e --- /dev/null +++ b/default_config/stage/config.yaml @@ -0,0 +1,4 @@ +API_HOST_URL: 'http://localhost:8000' +ENVIRONMENT_DISPLAY_NAME: 'Localhost' +FEEDBACK_EMAIL_ADDRESS: 'support@example.com' +OAUTH_CLIENT_ID: '' diff --git a/default_config/stage/file_mappings.yaml b/default_config/stage/file_mappings.yaml new file mode 100644 index 000000000..86d84fa91 --- /dev/null +++ b/default_config/stage/file_mappings.yaml @@ -0,0 +1,3 @@ +ios: + files: + - config.yaml From 0b2e649fddbc734ff4518da19c8b14c394356a6a Mon Sep 17 00:00:00 2001 From: Volodymyr Chekyrta <127732735+volodymyr-chekyrta@users.noreply.github.com> Date: Tue, 21 Nov 2023 12:53:28 +0200 Subject: [PATCH 018/158] Move ConfigTests to CoreTests (#164) --- Core/{Core/Tests => CoreTests/Configuration}/ConfigTests.swift | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename Core/{Core/Tests => CoreTests/Configuration}/ConfigTests.swift (100%) diff --git a/Core/Core/Tests/ConfigTests.swift b/Core/CoreTests/Configuration/ConfigTests.swift similarity index 100% rename from Core/Core/Tests/ConfigTests.swift rename to Core/CoreTests/Configuration/ConfigTests.swift From d61e3286cdbaf2f763c5cbef86a35020e981eaf9 Mon Sep 17 00:00:00 2001 From: Volodymyr Chekyrta Date: Tue, 21 Nov 2023 16:09:00 +0200 Subject: [PATCH 019/158] Chore. Add CoreTests to targets --- Core/Core.xcodeproj/project.pbxproj | 26 ++++++++++++------- .../xcschemes/OpenEdXDev.xcscheme | 10 +++++++ .../xcschemes/OpenEdXProd.xcscheme | 10 +++++++ .../xcschemes/OpenEdXStage.xcscheme | 10 +++++++ 4 files changed, 47 insertions(+), 9 deletions(-) diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index 154ad29ae..0ee74bea0 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -491,6 +491,7 @@ children = ( 0770DE5328D0B00C006D8A5D /* swiftgen.yml */, 0770DE0A28D07831006D8A5D /* Core */, + 078525AC2B0CBFF4007B4521 /* CoreTests */, 0770DE0928D07831006D8A5D /* Products */, C9DFE47E699CFFA85A77AF2C /* Pods */, F1620A3A2C8B0699EAA61B57 /* Frameworks */, @@ -522,7 +523,6 @@ 0770DE5D28D0B209006D8A5D /* Localizable.strings */, 0770DE5128D0ADFF006D8A5D /* Assets.xcassets */, 071009CF28D1E3A600344290 /* Constants.swift */, - DBFB74502B0CA508004370F9 /* Tests */, ); path = Core; sourceTree = ""; @@ -597,6 +597,22 @@ path = Base; sourceTree = ""; }; + 078525AC2B0CBFF4007B4521 /* CoreTests */ = { + isa = PBXGroup; + children = ( + 078525AD2B0CC004007B4521 /* Configuration */, + ); + path = CoreTests; + sourceTree = ""; + }; + 078525AD2B0CC004007B4521 /* Configuration */ = { + isa = PBXGroup; + children = ( + DBF6F2472B01E20A0098414B /* ConfigTests.swift */, + ); + path = Configuration; + sourceTree = ""; + }; C9DFE47E699CFFA85A77AF2C /* Pods */ = { isa = PBXGroup; children = ( @@ -632,14 +648,6 @@ path = Config; sourceTree = ""; }; - DBFB74502B0CA508004370F9 /* Tests */ = { - isa = PBXGroup; - children = ( - DBF6F2472B01E20A0098414B /* ConfigTests.swift */, - ); - path = Tests; - sourceTree = ""; - }; F1620A3A2C8B0699EAA61B57 /* Frameworks */ = { isa = PBXGroup; children = ( diff --git a/OpenEdX.xcodeproj/xcshareddata/xcschemes/OpenEdXDev.xcscheme b/OpenEdX.xcodeproj/xcshareddata/xcschemes/OpenEdXDev.xcscheme index 55135d8fa..cd0feecf1 100644 --- a/OpenEdX.xcodeproj/xcshareddata/xcschemes/OpenEdXDev.xcscheme +++ b/OpenEdX.xcodeproj/xcshareddata/xcschemes/OpenEdXDev.xcscheme @@ -107,6 +107,16 @@ ReferencedContainer = "container:WhatsNew/WhatsNew.xcodeproj"> + + + + + + + + + + + + Date: Tue, 21 Nov 2023 16:52:32 +0200 Subject: [PATCH 020/158] Add accessibility (#133) * add accessibility support * ios 17 fixes * add voiceover to download states on CourseVertical and CourseOutline views * Update CourseCellView.swift --------- Co-authored-by: stepanokdev <100592747+Stepanokdev@users.noreply.github.com> --- .../Extensions/UIApplicationExtension.swift | 2 +- .../UINavigationController+Animation.swift | 2 +- Core/Core/Extensions/ViewExtension.swift | 2 +- Core/Core/View/Base/CourseCellView.swift | 4 + Core/Core/View/Base/NavigationBar.swift | 2 + Core/Core/View/Base/StyledButton.swift | 2 + .../Container/CourseContainerView.swift | 4 +- .../Outline/CourseOutlineView.swift | 158 ++++++++++-------- .../Outline/CourseVerticalView.swift | 15 +- Course/Course/SwiftGen/Strings.swift | 8 + Course/Course/en.lproj/Localizable.strings | 4 + Course/Course/uk.lproj/Localizable.strings | 4 + .../Presentation/DashboardView.swift | 5 +- .../Presentation/DiscoveryView.swift | 12 +- .../Discovery/Presentation/SearchView.swift | 16 +- .../DiscussionSearchTopicsView.swift | 13 +- .../DiscussionTopicsView.swift | 2 + .../Presentation/Posts/PostsView.swift | 3 +- .../Presentation/Profile/ProfileView.swift | 106 +++++++----- 19 files changed, 221 insertions(+), 143 deletions(-) diff --git a/Core/Core/Extensions/UIApplicationExtension.swift b/Core/Core/Extensions/UIApplicationExtension.swift index 9c6f88c6f..68e63665d 100644 --- a/Core/Core/Extensions/UIApplicationExtension.swift +++ b/Core/Core/Extensions/UIApplicationExtension.swift @@ -45,7 +45,7 @@ extension UINavigationController { let image = CoreAssets.arrowLeft.image navigationBar.backIndicatorImage = image.withTintColor(CoreAssets.accentColor.color) - navigationBar.tintColor = .clear + navigationBar.backItem?.backButtonTitle = " " navigationBar.backIndicatorTransitionMaskImage = image.withTintColor(CoreAssets.accentColor.color) navigationBar.titleTextAttributes = [.foregroundColor: CoreAssets.textPrimary.color] } diff --git a/Core/Core/Extensions/UINavigationController+Animation.swift b/Core/Core/Extensions/UINavigationController+Animation.swift index 21671ea44..4f720f78c 100644 --- a/Core/Core/Extensions/UINavigationController+Animation.swift +++ b/Core/Core/Extensions/UINavigationController+Animation.swift @@ -24,7 +24,7 @@ public extension UINavigationController { duration: CFTimeInterval = 0.3 ) { addTransition(transitionType: type, duration: duration) - pushViewController(vc, animated: false) + pushViewController(vc, animated: UIAccessibility.isVoiceOverRunning) } private func addTransition( diff --git a/Core/Core/Extensions/ViewExtension.swift b/Core/Core/Extensions/ViewExtension.swift index 91a66ed92..8ccc2ae6b 100644 --- a/Core/Core/Extensions/ViewExtension.swift +++ b/Core/Core/Extensions/ViewExtension.swift @@ -263,7 +263,7 @@ public extension Image { .scaledToFit() .frame(height: 24) .padding(.horizontal, 8) - .padding(.top, topPadding) + .offset(y: topPadding) .foregroundColor(color) } } diff --git a/Core/Core/View/Base/CourseCellView.swift b/Core/Core/View/Base/CourseCellView.swift index ae21b2e7f..7c6fd419a 100644 --- a/Core/Core/View/Base/CourseCellView.swift +++ b/Core/Core/View/Base/CourseCellView.swift @@ -48,6 +48,7 @@ public struct CourseCellView: View { .cornerRadius(8) .clipShape(RoundedRectangle(cornerRadius: Theme.Shapes.cardImageRadius)) .padding(.leading, 3) + .accessibilityElement(children: .ignore) VStack(alignment: .leading) { Text(courseOrg) @@ -90,6 +91,8 @@ public struct CourseCellView: View { .background(Theme.Colors.background) .opacity(showView ? 1 : 0) .offset(y: showView ? 0 : 20) + .accessibilityElement(children: .ignore) + .accessibilityLabel(courseName + " " + (type == .dashboard ? (courseEnd == "" ? courseStart : courseEnd) : "")) .onAppear { DispatchQueue.main.asyncAfter(deadline: .now()) { withAnimation(.easeInOut(duration: (index <= 5 ? 0.3 : 0.1)) @@ -98,6 +101,7 @@ public struct CourseCellView: View { } } } + VStack { if Int(index) != cellsCount { Divider() diff --git a/Core/Core/View/Base/NavigationBar.swift b/Core/Core/View/Base/NavigationBar.swift index 2af6581f9..bcfd9837b 100644 --- a/Core/Core/View/Base/NavigationBar.swift +++ b/Core/Core/View/Base/NavigationBar.swift @@ -55,12 +55,14 @@ public struct NavigationBar: View { }, label: { CoreAssets.arrowLeft.swiftUIImage .backButtonStyle(color: leftButtonColor) + .padding(8) }) .foregroundColor(Theme.Colors.styledButtonText) }.frame(minWidth: 0, maxWidth: .infinity, alignment: .topLeading) + } if rightButtonType != nil { VStack { diff --git a/Core/Core/View/Base/StyledButton.swift b/Core/Core/View/Base/StyledButton.swift index b16f61a1a..fdad6a1d1 100644 --- a/Core/Core/View/Base/StyledButton.swift +++ b/Core/Core/View/Base/StyledButton.swift @@ -55,6 +55,8 @@ public struct StyledButton: View { .stroke(style: .init(lineWidth: 1, lineCap: .round, lineJoin: .round, miterLimit: 1)) .foregroundColor(isTransparent ? .white : .clear) ) + .accessibilityElement(children: .ignore) + .accessibilityLabel(title) } } diff --git a/Course/Course/Presentation/Container/CourseContainerView.swift b/Course/Course/Presentation/Container/CourseContainerView.swift index 726cdaeb3..4cfe2f2ce 100644 --- a/Course/Course/Presentation/Container/CourseContainerView.swift +++ b/Course/Course/Presentation/Container/CourseContainerView.swift @@ -56,7 +56,7 @@ public struct CourseContainerView: View { title: title, courseID: courseID, isVideo: false - ) + ).accessibilityAction {} .tabItem { CoreAssets.bookCircle.swiftUIImage.renderingMode(.template) Text(CourseLocalization.CourseContainer.course) @@ -68,7 +68,7 @@ public struct CourseContainerView: View { title: title, courseID: courseID, isVideo: true - ) + ).accessibilityAction {} .tabItem { CoreAssets.videoCircle.swiftUIImage.renderingMode(.template) Text(CourseLocalization.CourseContainer.videos) diff --git a/Course/Course/Presentation/Outline/CourseOutlineView.swift b/Course/Course/Presentation/Outline/CourseOutlineView.swift index 19a6fa413..03a5f623f 100644 --- a/Course/Course/Presentation/Outline/CourseOutlineView.swift +++ b/Course/Course/Presentation/Outline/CourseOutlineView.swift @@ -137,11 +137,13 @@ public struct CourseOutlineView: View { } Spacer(minLength: 84) } - }.frameLimit() + } + .frameLimit() .onRightSwipeGesture { viewModel.router.back() } }.padding(.top, 8) + .accessibilityAction {} // MARK: - Offline mode SnackBar OfflineSnackBarView( @@ -208,81 +210,91 @@ struct CourseStructureView: View { ForEach(chapter.childs, id: \.id) { child in let sequentialIndex = chapter.childs.firstIndex(where: { $0.id == child.id }) VStack(alignment: .leading) { - Button( - action: { - if let chapterIndex, let sequentialIndex { - viewModel.trackSequentialClicked(child) - viewModel.router.showCourseVerticalView( - courseID: viewModel.courseStructure?.id ?? "", - courseName: viewModel.courseStructure?.displayName ?? "", - title: child.displayName, - chapters: chapters, - chapterIndex: chapterIndex, - sequentialIndex: sequentialIndex - ) - } - }, - label: { - Group { - if child.completion == 1 { - CoreAssets.finished.swiftUIImage - .renderingMode(.template) - .foregroundColor(.accentColor) - } else { - child.type.image - } - Text(child.displayName) - .font(Theme.Fonts.titleMedium) - .multilineTextAlignment(.leading) - .lineLimit(1) - .frame( - maxWidth: idiom == .pad - ? proxy.size.width * 0.5 - : proxy.size.width * 0.6, - alignment: .leading + HStack { + Button( + action: { + if let chapterIndex, let sequentialIndex { + viewModel.trackSequentialClicked(child) + viewModel.router.showCourseVerticalView( + courseID: viewModel.courseStructure?.id ?? "", + courseName: viewModel.courseStructure?.displayName ?? "", + title: child.displayName, + chapters: chapters, + chapterIndex: chapterIndex, + sequentialIndex: sequentialIndex ) - }.foregroundColor(Theme.Colors.textPrimary) - Spacer() - if let state = viewModel.downloadState[child.id] { - switch state { - case .available: - DownloadAvailableView() - .onTapGesture { - viewModel.onDownloadViewTap( - chapter: chapter, - blockId: child.id, - state: state - ) - } - .onForeground { - viewModel.onForeground() - } - case .downloading: - DownloadProgressView() - .onTapGesture { - viewModel.onDownloadViewTap( - chapter: chapter, - blockId: child.id, - state: state - ) - } - .onBackground { - viewModel.onBackground() - } - case .finished: - DownloadFinishedView() - .onTapGesture { - viewModel.onDownloadViewTap( - chapter: chapter, - blockId: child.id, - state: state - ) - } } + }, + label: { + Group { + if child.completion == 1 { + CoreAssets.finished.swiftUIImage + .renderingMode(.template) + .foregroundColor(.accentColor) + } else { + child.type.image + } + Text(child.displayName) + .font(Theme.Fonts.titleMedium) + .multilineTextAlignment(.leading) + .lineLimit(1) + .frame( + maxWidth: idiom == .pad + ? proxy.size.width * 0.5 + : proxy.size.width * 0.6, + alignment: .leading + ) + }.foregroundColor(Theme.Colors.textPrimary) + }) .accessibilityElement(children: .ignore) + .accessibilityLabel(child.displayName) + Spacer() + if let state = viewModel.downloadState[child.id] { + switch state { + case .available: + DownloadAvailableView() + .accessibilityElement(children: .ignore) + .accessibilityLabel(CourseLocalization.Accessibility.download) + .onTapGesture { + viewModel.onDownloadViewTap( + chapter: chapter, + blockId: child.id, + state: state + ) + } + .onForeground { + viewModel.onForeground() + } + case .downloading: + DownloadProgressView() + .accessibilityElement(children: .ignore) + .accessibilityLabel(CourseLocalization.Accessibility.cancelDownload) + .onTapGesture { + viewModel.onDownloadViewTap( + chapter: chapter, + blockId: child.id, + state: state + ) + } + .onBackground { + viewModel.onBackground() + } + case .finished: + DownloadFinishedView() + .accessibilityElement(children: .ignore) + .accessibilityLabel(CourseLocalization.Accessibility.deleteDownload) + .onTapGesture { + viewModel.onDownloadViewTap( + chapter: chapter, + blockId: child.id, + state: state + ) + } } - Image(systemName: "chevron.right") - .foregroundColor(Theme.Colors.accentColor) - }).padding(.horizontal, 36) + } + Image(systemName: "chevron.right") + .foregroundColor(Theme.Colors.accentColor) + } + .padding(.horizontal, 36) .padding(.vertical, 20) if chapterIndex != chapters.count - 1 { Divider() diff --git a/Course/Course/Presentation/Outline/CourseVerticalView.swift b/Course/Course/Presentation/Outline/CourseVerticalView.swift index 20b1bc56f..b85677818 100644 --- a/Course/Course/Presentation/Outline/CourseVerticalView.swift +++ b/Course/Course/Presentation/Outline/CourseVerticalView.swift @@ -54,6 +54,7 @@ public struct CourseVerticalView: View { // MARK: - Lessons list ForEach(viewModel.verticals, id: \.id) { vertical in if let index = viewModel.verticals.firstIndex(where: {$0.id == vertical.id}) { + HStack { Button(action: { let vertical = viewModel.verticals[index] if let block = vertical.childs.first { @@ -74,7 +75,6 @@ public struct CourseVerticalView: View { ) } }, label: { - HStack { Group { if vertical.completion == 1 { CoreAssets.finished.swiftUIImage @@ -93,11 +93,15 @@ public struct CourseVerticalView: View { .multilineTextAlignment(.leading) .frame(maxWidth: .infinity, alignment: .leading) }.foregroundColor(Theme.Colors.textPrimary) + }).accessibilityElement(children: .ignore) + .accessibilityLabel(vertical.displayName) Spacer() if let state = viewModel.downloadState[vertical.id] { switch state { case .available: DownloadAvailableView() + .accessibilityElement(children: .ignore) + .accessibilityLabel(CourseLocalization.Accessibility.download) .onTapGesture { viewModel.onDownloadViewTap( blockId: vertical.id, @@ -109,6 +113,8 @@ public struct CourseVerticalView: View { } case .downloading: DownloadProgressView() + .accessibilityElement(children: .ignore) + .accessibilityLabel(CourseLocalization.Accessibility.cancelDownload) .onTapGesture { viewModel.onDownloadViewTap( blockId: vertical.id, @@ -120,6 +126,8 @@ public struct CourseVerticalView: View { } case .finished: DownloadFinishedView() + .accessibilityElement(children: .ignore) + .accessibilityLabel(CourseLocalization.Accessibility.deleteDownload) .onTapGesture { viewModel.onDownloadViewTap( blockId: vertical.id, @@ -131,7 +139,7 @@ public struct CourseVerticalView: View { Image(systemName: "chevron.right") .padding(.vertical, 8) } - }).padding(.horizontal, 36) + .padding(.horizontal, 36) .padding(.vertical, 14) if index != viewModel.verticals.count - 1 { Divider() @@ -143,7 +151,8 @@ public struct CourseVerticalView: View { } } Spacer(minLength: 84) - }.frameLimit() + }.accessibilityAction {} + .frameLimit() .onRightSwipeGesture { viewModel.router.back() } diff --git a/Course/Course/SwiftGen/Strings.swift b/Course/Course/SwiftGen/Strings.swift index 9ec7b8b36..2eaafb3bf 100644 --- a/Course/Course/SwiftGen/Strings.swift +++ b/Course/Course/SwiftGen/Strings.swift @@ -10,6 +10,14 @@ import Foundation // swiftlint:disable explicit_type_interface function_parameter_count identifier_name line_length // swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces public enum CourseLocalization { + public enum Accessibility { + /// Cancel download + public static let cancelDownload = CourseLocalization.tr("Localizable", "ACCESSIBILITY.CANCEL_DOWNLOAD", fallback: "Cancel download") + /// Delete download + public static let deleteDownload = CourseLocalization.tr("Localizable", "ACCESSIBILITY.DELETE_DOWNLOAD", fallback: "Delete download") + /// Download + public static let download = CourseLocalization.tr("Localizable", "ACCESSIBILITY.DOWNLOAD", fallback: "Download") + } public enum Alert { /// Rotate your device to view this video in full screen. public static let rotateDevice = CourseLocalization.tr("Localizable", "ALERT.ROTATE_DEVICE", fallback: "Rotate your device to view this video in full screen.") diff --git a/Course/Course/en.lproj/Localizable.strings b/Course/Course/en.lproj/Localizable.strings index a37d426c0..3152f86c7 100644 --- a/Course/Course/en.lproj/Localizable.strings +++ b/Course/Course/en.lproj/Localizable.strings @@ -52,3 +52,7 @@ "NOT_AVALIABLE.BUTTON" = "Open in browser"; "SUBTITLES.TITLE" = "Subtitles"; + +"ACCESSIBILITY.DOWNLOAD" = "Download"; +"ACCESSIBILITY.CANCEL_DOWNLOAD" = "Cancel download"; +"ACCESSIBILITY.DELETE_DOWNLOAD" = "Delete download"; diff --git a/Course/Course/uk.lproj/Localizable.strings b/Course/Course/uk.lproj/Localizable.strings index 4f7ff5f87..302297084 100644 --- a/Course/Course/uk.lproj/Localizable.strings +++ b/Course/Course/uk.lproj/Localizable.strings @@ -51,3 +51,7 @@ "NOT_AVALIABLE.BUTTON" = "Відкрити в браузері"; "SUBTITLES.TITLE" = "Субтитри"; + +"ACCESSIBILITY.DOWNLOAD" = "Скачати"; +"ACCESSIBILITY.CANCEL_DOWNLOAD" = "Скасувати завантаження"; +"ACCESSIBILITY.DELETE_DOWNLOAD" = "Видалити файл"; diff --git a/Dashboard/Dashboard/Presentation/DashboardView.swift b/Dashboard/Dashboard/Presentation/DashboardView.swift index 4be6e62e7..80edb3ee5 100644 --- a/Dashboard/Dashboard/Presentation/DashboardView.swift +++ b/Dashboard/Dashboard/Presentation/DashboardView.swift @@ -18,6 +18,8 @@ public struct DashboardView: View { .foregroundColor(Theme.Colors.textPrimary) }.listRowBackground(Color.clear) .padding(.top, 24) + .accessibilityElement(children: .ignore) + .accessibilityLabel(DashboardLocalization.Header.courses + DashboardLocalization.Header.welcomeBack) @StateObject private var viewModel: DashboardViewModel @@ -91,7 +93,8 @@ public struct DashboardView: View { } } } - }.frameLimit() + }.accessibilityAction {} + .frameLimit() }.padding(.top, 8) // MARK: - Offline mode SnackBar diff --git a/Discovery/Discovery/Presentation/DiscoveryView.swift b/Discovery/Discovery/Presentation/DiscoveryView.swift index 2abf4dd78..a90b41a9b 100644 --- a/Discovery/Discovery/Presentation/DiscoveryView.swift +++ b/Discovery/Discovery/Presentation/DiscoveryView.swift @@ -22,6 +22,8 @@ public struct DiscoveryView: View { .font(Theme.Fonts.titleSmall) .foregroundColor(Theme.Colors.textPrimary) }.listRowBackground(Color.clear) + .accessibilityElement(children: .ignore) + .accessibilityLabel(DiscoveryLocalization.Header.title1 + DiscoveryLocalization.Header.title2) public init(viewModel: DiscoveryViewModel) { self._viewModel = StateObject(wrappedValue: { viewModel }()) @@ -56,12 +58,15 @@ public struct DiscoveryView: View { Theme.Shapes.textInputShape .stroke(lineWidth: 1) .fill(Theme.Colors.textInputUnfocusedStroke) - ).onTapGesture { + ) + .onTapGesture { viewModel.router.showDiscoverySearch() viewModel.discoverySearchBarClicked() } .padding(.horizontal, 24) .padding(.bottom, 20) + .accessibilityElement(children: .ignore) + .accessibilityLabel(DiscoveryLocalization.search) ZStack { RefreshableScrollViewCompat(action: { @@ -112,8 +117,9 @@ public struct DiscoveryView: View { } VStack {}.frame(height: 40) } - }.frameLimit() - } + } + .frameLimit() + }.accessibilityAction {} }.padding(.top, 8) // MARK: - Offline mode SnackBar diff --git a/Discovery/Discovery/Presentation/SearchView.swift b/Discovery/Discovery/Presentation/SearchView.swift index 09e4619cf..77dd693c8 100644 --- a/Discovery/Discovery/Presentation/SearchView.swift +++ b/Discovery/Discovery/Presentation/SearchView.swift @@ -10,10 +10,12 @@ import Core public struct SearchView: View { + @FocusState + private var focused: Bool + @ObservedObject private var viewModel: SearchViewModel @State private var animated: Bool = false - @State private var becomeFirstResponderRunOnce = false public init(viewModel: SearchViewModel) { self.viewModel = viewModel @@ -38,6 +40,7 @@ public struct SearchView: View { ? Theme.Colors.accentColor : Theme.Colors.textPrimary ) + .accessibilityHidden(true) TextField( !viewModel.isSearchActive @@ -47,13 +50,10 @@ public struct SearchView: View { onEditingChanged: { editing in viewModel.isSearchActive = editing } - ) - .introspect(.textField, on: .iOS(.v14, .v15, .v16, .v17), customize: { textField in - if !becomeFirstResponderRunOnce { - textField.becomeFirstResponder() - self.becomeFirstResponderRunOnce = true + ).focused($focused) + .onAppear { + self.focused = true } - }) .foregroundColor(Theme.Colors.textPrimary) Spacer() if !viewModel.searchText.trimmingCharacters(in: .whitespaces).isEmpty { @@ -169,6 +169,8 @@ public struct SearchView: View { .font(Theme.Fonts.titleSmall) .foregroundColor(Theme.Colors.textPrimary) }.listRowBackground(Color.clear) + .accessibilityElement(children: .ignore) + .accessibilityLabel(DiscoveryLocalization.Search.title + searchDescription(viewModel: viewModel)) } private func searchDescription(viewModel: SearchViewModel) -> String { diff --git a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsView.swift b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsView.swift index 24d4335da..c1526b020 100644 --- a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsView.swift +++ b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsView.swift @@ -10,9 +10,11 @@ import Core public struct DiscussionSearchTopicsView: View { + @FocusState + private var focused: Bool + @ObservedObject private var viewModel: DiscussionSearchTopicsViewModel @State private var animated: Bool = false - @State private var becomeFirstResponderRunOnce = false public init(viewModel: DiscussionSearchTopicsViewModel) { self.viewModel = viewModel @@ -44,13 +46,10 @@ public struct DiscussionSearchTopicsView: View { onEditingChanged: { editing in viewModel.isSearchActive = editing } - ) - .introspect(.textField, on: .iOS(.v14, .v15, .v16, .v17), customize: { textField in - if !becomeFirstResponderRunOnce { - textField.becomeFirstResponder() - self.becomeFirstResponderRunOnce = true + ).focused($focused) + .onAppear { + self.focused = true } - }) .foregroundColor(Theme.Colors.textPrimary) Spacer() if !viewModel.searchText.trimmingCharacters(in: .whitespaces).isEmpty { diff --git a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift index 722e1b9fd..4e690d15a 100644 --- a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift +++ b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift @@ -49,6 +49,8 @@ public struct DiscussionTopicsView: View { } .padding(.horizontal, 24) .padding(.bottom, 20) + .accessibilityElement(children: .ignore) + .accessibilityLabel(DiscussionLocalization.Topics.search) // MARK: - Page Body VStack { diff --git a/Discussion/Discussion/Presentation/Posts/PostsView.swift b/Discussion/Discussion/Presentation/Posts/PostsView.swift index 906a47ce2..4cd46f5cb 100644 --- a/Discussion/Discussion/Presentation/Posts/PostsView.swift +++ b/Discussion/Discussion/Presentation/Posts/PostsView.swift @@ -186,7 +186,8 @@ public struct PostsView: View { } } } - }.frameLimit() + }.accessibilityAction {} + .frameLimit() .animation(nil) .onRightSwipeGesture { router.back() diff --git a/Profile/Profile/Presentation/Profile/ProfileView.swift b/Profile/Profile/Presentation/Profile/ProfileView.swift index 5d8dd18d5..f398d735c 100644 --- a/Profile/Profile/Presentation/Profile/ProfileView.swift +++ b/Profile/Profile/Presentation/Profile/ProfileView.swift @@ -66,6 +66,15 @@ public struct ProfileView: View { } } } + .accessibilityElement(children: .ignore) + .accessibilityLabel( + (viewModel.userModel?.yearOfBirth != 0 ? + ProfileLocalization.Edit.Fields.yearOfBirth + String(viewModel.userModel?.yearOfBirth ?? 0) : + "") + + (viewModel.userModel?.shortBiography != nil ? + ProfileLocalization.bio + (viewModel.userModel?.shortBiography ?? "") : + "") + ) .cardStyle( bgColor: Theme.Colors.textInputUnfocusedBackground, strokeColor: .clear @@ -90,7 +99,10 @@ public struct ProfileView: View { } }) - }.cardStyle( + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(ProfileLocalization.settingsVideo) + .cardStyle( bgColor: Theme.Colors.textInputUnfocusedBackground, strokeColor: .clear ) @@ -113,6 +125,8 @@ public struct ProfileView: View { }) .buttonStyle(PlainButtonStyle()) .foregroundColor(.primary) + .accessibilityElement(children: .ignore) + .accessibilityLabel(ProfileLocalization.supportInfo) Rectangle() .frame(height: 1) .foregroundColor(Theme.Colors.textSecondary) @@ -131,6 +145,8 @@ public struct ProfileView: View { }) .buttonStyle(PlainButtonStyle()) .foregroundColor(.primary) + .accessibilityElement(children: .ignore) + .accessibilityLabel(ProfileLocalization.terms) Rectangle() .frame(height: 1) .foregroundColor(Theme.Colors.textSecondary) @@ -149,55 +165,57 @@ public struct ProfileView: View { }) .buttonStyle(PlainButtonStyle()) .foregroundColor(.primary) + .accessibilityElement(children: .ignore) + .accessibilityLabel(ProfileLocalization.privacy) } // MARK: Version - Rectangle() - .frame(height: 1) - .foregroundColor(Theme.Colors.textSecondary) - Button(action: { - viewModel.openAppStore() - }, label: { - HStack { - VStack(alignment: .leading, spacing: 0) { - HStack { - if viewModel.versionState == .updateRequired { - CoreAssets.warningFilled.swiftUIImage - .resizable() - .frame(width: 24, height: 24) - } - Text("\(ProfileLocalization.Settings.version) \(viewModel.currentVersion)") + Rectangle() + .frame(height: 1) + .foregroundColor(Theme.Colors.textSecondary) + Button(action: { + viewModel.openAppStore() + }, label: { + HStack { + VStack(alignment: .leading, spacing: 0) { + HStack { + if viewModel.versionState == .updateRequired { + CoreAssets.warningFilled.swiftUIImage + .resizable() + .frame(width: 24, height: 24) } - switch viewModel.versionState { - case .actual: - HStack { - CoreAssets.checkmark.swiftUIImage - .renderingMode(.template) - .foregroundColor(.green) - Text(ProfileLocalization.Settings.upToDate) - .font(Theme.Fonts.labelMedium) - .foregroundStyle(Theme.Colors.textSecondary) - } - case .updateNeeded: - Text("\(ProfileLocalization.Settings.tapToUpdate) \(viewModel.latestVersion)") - .font(Theme.Fonts.labelMedium) - .foregroundStyle(Theme.Colors.accentColor) - case .updateRequired: - Text(ProfileLocalization.Settings.tapToInstall) + Text("\(ProfileLocalization.Settings.version) \(viewModel.currentVersion)") + } + switch viewModel.versionState { + case .actual: + HStack { + CoreAssets.checkmark.swiftUIImage + .renderingMode(.template) + .foregroundColor(.green) + Text(ProfileLocalization.Settings.upToDate) .font(Theme.Fonts.labelMedium) - .foregroundStyle(Theme.Colors.accentColor) + .foregroundStyle(Theme.Colors.textSecondary) } - } - Spacer() - if viewModel.versionState != .actual { - Image(systemName: "arrow.up.circle") - .resizable() - .frame(width: 24, height: 24) + case .updateNeeded: + Text("\(ProfileLocalization.Settings.tapToUpdate) \(viewModel.latestVersion)") + .font(Theme.Fonts.labelMedium) + .foregroundStyle(Theme.Colors.accentColor) + case .updateRequired: + Text(ProfileLocalization.Settings.tapToInstall) + .font(Theme.Fonts.labelMedium) .foregroundStyle(Theme.Colors.accentColor) } - } - }).disabled(viewModel.versionState == .actual) + Spacer() + if viewModel.versionState != .actual { + Image(systemName: "arrow.up.circle") + .resizable() + .frame(width: 24, height: 24) + .foregroundStyle(Theme.Colors.accentColor) + } + + } + }).disabled(viewModel.versionState == .actual) }.cardStyle( bgColor: Theme.Colors.textInputUnfocusedBackground, @@ -230,7 +248,8 @@ public struct ProfileView: View { Image(systemName: "rectangle.portrait.and.arrow.right") } }) - + .accessibilityElement(children: .ignore) + .accessibilityLabel(ProfileLocalization.logout) } .foregroundColor(Theme.Colors.alert) .cardStyle(bgColor: Theme.Colors.textInputUnfocusedBackground, @@ -241,7 +260,8 @@ public struct ProfileView: View { Spacer() } } - }.frameLimit(sizePortrait: 420) + }.accessibilityAction {} + .frameLimit(sizePortrait: 420) .padding(.top, 8) .onChange(of: settingsTapped, perform: { _ in let userModel = viewModel.userModel ?? UserProfile() From 1296cb9dd7a4b2ee758a4c7acd756c589b66cc5d Mon Sep 17 00:00:00 2001 From: Volodymyr Chekyrta <127732735+volodymyr-chekyrta@users.noreply.github.com> Date: Wed, 22 Nov 2023 11:31:57 +0200 Subject: [PATCH 021/158] Fix CI (#171) * Update ci_prepare_env.sh --- ci_scripts/ci_prepare_env.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ci_scripts/ci_prepare_env.sh b/ci_scripts/ci_prepare_env.sh index 206dcde33..477bd65a3 100644 --- a/ci_scripts/ci_prepare_env.sh +++ b/ci_scripts/ci_prepare_env.sh @@ -34,8 +34,10 @@ install_xcode_cloud_brew_dependencies () { } setup_github_actions_environment() { - brew update && brew install xcodegen git-lfs imagemagick + # brew update && brew install xcodegen git-lfs imagemagick + brew update && brew install xcodegen git-lfs + bundle config path vendor/bundle bundle install --jobs 4 --retry 3 From 7fc5a6477f5aeb9528af302f158892327572b6f3 Mon Sep 17 00:00:00 2001 From: Muhammad Umer Date: Fri, 24 Nov 2023 14:09:07 +0500 Subject: [PATCH 022/158] chore: add google and microsoft scheme (#172) * chore: add microsoft and google scheme to info.plist * fix: fix config tests file reference --- Core/Core.xcodeproj/project.pbxproj | 24 ++++++++++++++++++++---- config_script/process_config.py | 16 +++++++++++++--- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index 0ee74bea0..036526d61 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -122,9 +122,9 @@ 07DDFCBD29A780BB00572595 /* UINavigationController+Animation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07DDFCBC29A780BB00572595 /* UINavigationController+Animation.swift */; }; C8C446EF233F81B9FABB77D2 /* Pods_App_Core.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 349B90CD6579F7B8D257E515 /* Pods_App_Core.framework */; }; CFC84952299F8B890055E497 /* Debounce.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFC84951299F8B890055E497 /* Debounce.swift */; }; + DB4EBE9E2B1075E100CB4DC4 /* ConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4EBE9D2B1075E100CB4DC4 /* ConfigTests.swift */; }; DBF6F2412B014ADA0098414B /* FirebaseConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF6F2402B014ADA0098414B /* FirebaseConfig.swift */; }; DBF6F2462B01DAFE0098414B /* AgreementConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF6F2452B01DAFE0098414B /* AgreementConfig.swift */; }; - DBF6F2482B01E20A0098414B /* ConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF6F2472B01E20A0098414B /* ConfigTests.swift */; }; DBF6F24A2B0380E00098414B /* FeaturesConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF6F2492B0380E00098414B /* FeaturesConfig.swift */; }; /* End PBXBuildFile section */ @@ -263,9 +263,9 @@ 9D5B06CAA99EA5CD49CBE2BB /* Pods-App-Core.debugdev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core.debugdev.xcconfig"; path = "Target Support Files/Pods-App-Core/Pods-App-Core.debugdev.xcconfig"; sourceTree = ""; }; C7E5BCE79CE297B20777B27A /* Pods-App-Core.debugprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core.debugprod.xcconfig"; path = "Target Support Files/Pods-App-Core/Pods-App-Core.debugprod.xcconfig"; sourceTree = ""; }; CFC84951299F8B890055E497 /* Debounce.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Debounce.swift; sourceTree = ""; }; + DB4EBE9D2B1075E100CB4DC4 /* ConfigTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConfigTests.swift; sourceTree = ""; }; DBF6F2402B014ADA0098414B /* FirebaseConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirebaseConfig.swift; sourceTree = ""; }; DBF6F2452B01DAFE0098414B /* AgreementConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgreementConfig.swift; sourceTree = ""; }; - DBF6F2472B01E20A0098414B /* ConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigTests.swift; sourceTree = ""; }; DBF6F2492B0380E00098414B /* FeaturesConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeaturesConfig.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -491,7 +491,7 @@ children = ( 0770DE5328D0B00C006D8A5D /* swiftgen.yml */, 0770DE0A28D07831006D8A5D /* Core */, - 078525AC2B0CBFF4007B4521 /* CoreTests */, + DB4EBE9B2B1075E100CB4DC4 /* CoreTests */, 0770DE0928D07831006D8A5D /* Products */, C9DFE47E699CFFA85A77AF2C /* Pods */, F1620A3A2C8B0699EAA61B57 /* Frameworks */, @@ -637,6 +637,22 @@ path = Combine; sourceTree = ""; }; + DB4EBE9B2B1075E100CB4DC4 /* CoreTests */ = { + isa = PBXGroup; + children = ( + DB4EBE9C2B1075E100CB4DC4 /* Configuration */, + ); + path = CoreTests; + sourceTree = ""; + }; + DB4EBE9C2B1075E100CB4DC4 /* Configuration */ = { + isa = PBXGroup; + children = ( + DB4EBE9D2B1075E100CB4DC4 /* ConfigTests.swift */, + ); + path = Configuration; + sourceTree = ""; + }; DBF6F2422B014AF30098414B /* Config */ = { isa = PBXGroup; children = ( @@ -823,7 +839,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - DBF6F2482B01E20A0098414B /* ConfigTests.swift in Sources */, + DB4EBE9E2B1075E100CB4DC4 /* ConfigTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/config_script/process_config.py b/config_script/process_config.py index 6941ce096..dd24319b0 100644 --- a/config_script/process_config.py +++ b/config_script/process_config.py @@ -94,7 +94,8 @@ def write_to_plist_file(self, plist, file_path): file_name = os.path.basename(file_path) with open(file_path, 'wb') as plist_file: plistlib.dump(plist, plist_file) - print(f"File {file_name} has been written to {file_path}") + print(f"File {file_name} has been written to:") + print(f"{file_path}") def print_info_plist_contents(self, plist_path): if not plist_path: @@ -136,7 +137,13 @@ def add_url_scheme(self, scheme, plist): if not found: existing.append(body) plist['CFBundleURLTypes'] = existing - + + def add_application_query_schemes(self, schemes, plist): + existing = plist.get('LSApplicationQueriesSchemes', []) + for scheme in schemes: + if scheme not in existing: + existing.append(scheme) + plist['LSApplicationQueriesSchemes'] = existing return plist def add_firebase_config(self, config, firebase_info_plist_path): @@ -179,8 +186,10 @@ def add_facebook_config(self, config, plist): def add_google_config(self, config, plist): google = config.get('GOOGLE', {}) key = google.get('GOOGLE_PLUS_KEY') + client_id = google.get('CLIENT_ID') - if key: + if key and client_id: + plist["GIDClientID"] = client_id scheme = ['.'.join(reversed(key.split('.')))] self.add_url_scheme(scheme, plist) @@ -192,6 +201,7 @@ def add_microsoft_config(self, config, plist): bundle_identifier = self.plist_manager.get_bundle_identifier() scheme = ["msauth." + bundle_identifier] self.add_url_scheme(scheme, plist) + self.add_application_query_schemes(["msauthv2", "msauthv3"], plist) def update_info_plist(self, plist_data, plist_path): if not plist_path: From 375f99b5c7286491a3a2f4487a9e9510b4e3a607 Mon Sep 17 00:00:00 2001 From: Muhammad Umer Date: Mon, 27 Nov 2023 18:57:14 +0500 Subject: [PATCH 023/158] fix: add support for login via username (#141) * fix: add support for login via username * chore: fix typo in test case * chore: add check to validate if username is not empty * chore: address feedback * chore: add check for white space on email * chore: handle white spaces in username validation * chore: address feedback * fix: remove unused translation * Revert "fix: remove unused translation" This reverts commit 4c5120c79d28c4bd2f4baf875ae05be19f36c216. --- .../Presentation/Login/SignInView.swift | 4 ++-- .../Presentation/Login/SignInViewModel.swift | 8 ++++---- Authorization/Authorization/SwiftGen/Strings.swift | 8 ++++++-- .../Authorization/en.lproj/Localizable.strings | 5 +++-- .../Presentation/Login/SignInViewModelTests.swift | 6 +++--- Core/Core/View/Validator.swift | 14 +++++++++----- 6 files changed, 27 insertions(+), 18 deletions(-) diff --git a/Authorization/Authorization/Presentation/Login/SignInView.swift b/Authorization/Authorization/Presentation/Login/SignInView.swift index 8ad4a9949..893712c9b 100644 --- a/Authorization/Authorization/Presentation/Login/SignInView.swift +++ b/Authorization/Authorization/Presentation/Login/SignInView.swift @@ -49,10 +49,10 @@ public struct SignInView: View { .foregroundColor(Theme.Colors.textPrimary) .padding(.bottom, 20) - Text(AuthLocalization.SignIn.email) + Text(AuthLocalization.SignIn.emailOrUsername) .font(Theme.Fonts.labelLarge) .foregroundColor(Theme.Colors.textPrimary) - TextField(AuthLocalization.SignIn.email, text: $email) + TextField(AuthLocalization.SignIn.emailOrUsername, text: $email) .keyboardType(.emailAddress) .textContentType(.emailAddress) .autocapitalization(.none) diff --git a/Authorization/Authorization/Presentation/Login/SignInViewModel.swift b/Authorization/Authorization/Presentation/Login/SignInViewModel.swift index 350d09af0..d1376e5a5 100644 --- a/Authorization/Authorization/Presentation/Login/SignInViewModel.swift +++ b/Authorization/Authorization/Presentation/Login/SignInViewModel.swift @@ -52,12 +52,12 @@ public class SignInViewModel: ObservableObject { @MainActor func login(username: String, password: String) async { - guard validator.isValidEmail(username) else { - errorMessage = AuthLocalization.Error.invalidEmailAddress + guard validator.isValidUsername(username) else { + errorMessage = AuthLocalization.Error.invalidEmailAddressOrUsername return } - guard validator.isValidPassword(password) else { - errorMessage = AuthLocalization.Error.invalidPasswordLenght + guard !password.isEmpty else { + errorMessage = AuthLocalization.Error.invalidPasswordLength return } diff --git a/Authorization/Authorization/SwiftGen/Strings.swift b/Authorization/Authorization/SwiftGen/Strings.swift index 9d7d92b4e..aa8fe2f35 100644 --- a/Authorization/Authorization/SwiftGen/Strings.swift +++ b/Authorization/Authorization/SwiftGen/Strings.swift @@ -13,8 +13,10 @@ public enum AuthLocalization { public enum Error { /// Invalid email address public static let invalidEmailAddress = AuthLocalization.tr("Localizable", "ERROR.INVALID_EMAIL_ADDRESS", fallback: "Invalid email address") - /// Invalid password lenght - public static let invalidPasswordLenght = AuthLocalization.tr("Localizable", "ERROR.INVALID_PASSWORD_LENGHT", fallback: "Invalid password lenght") + /// Invalid email or username + public static let invalidEmailAddressOrUsername = AuthLocalization.tr("Localizable", "ERROR.INVALID_EMAIL_ADDRESS_OR_USERNAME", fallback: "Invalid email or username") + /// Invalid password length + public static let invalidPasswordLength = AuthLocalization.tr("Localizable", "ERROR.INVALID_PASSWORD_LENGTH", fallback: "Invalid password length") } public enum Forgot { /// We have sent a password recover instructions to your email @@ -31,6 +33,8 @@ public enum AuthLocalization { public enum SignIn { /// Email public static let email = AuthLocalization.tr("Localizable", "SIGN_IN.EMAIL", fallback: "Email") + /// Email or username + public static let emailOrUsername = AuthLocalization.tr("Localizable", "SIGN_IN.EMAIL_OR_USERNAME", fallback: "Email or username") /// Forgot password? public static let forgotPassBtn = AuthLocalization.tr("Localizable", "SIGN_IN.FORGOT_PASS_BTN", fallback: "Forgot password?") /// Sign in diff --git a/Authorization/Authorization/en.lproj/Localizable.strings b/Authorization/Authorization/en.lproj/Localizable.strings index 88365042c..8c58b2406 100644 --- a/Authorization/Authorization/en.lproj/Localizable.strings +++ b/Authorization/Authorization/en.lproj/Localizable.strings @@ -9,14 +9,15 @@ "SIGN_IN.LOG_IN_TITLE" = "Sign in"; "SIGN_IN.WELCOME_BACK" = "Welcome back! Please authorize to continue."; "SIGN_IN.EMAIL" = "Email"; +"SIGN_IN.EMAIL_OR_USERNAME" = "Email or username"; "SIGN_IN.PASSWORD" = "Password"; "SIGN_IN.REGISTER_BTN" = "Register"; "SIGN_IN.FORGOT_PASS_BTN" = "Forgot password?"; "SIGN_IN.LOG_IN_BTN" = "Sign in"; - "ERROR.INVALID_EMAIL_ADDRESS" = "Invalid email address"; -"ERROR.INVALID_PASSWORD_LENGHT" = "Invalid password lenght"; +"ERROR.INVALID_EMAIL_ADDRESS_OR_USERNAME" = "Invalid email or username"; +"ERROR.INVALID_PASSWORD_LENGTH" = "Invalid password length"; "SIGN_UP.TITLE" = "Sign up"; "SIGN_UP.SUBTITLE" = "Create new account."; diff --git a/Authorization/AuthorizationTests/Presentation/Login/SignInViewModelTests.swift b/Authorization/AuthorizationTests/Presentation/Login/SignInViewModelTests.swift index 036da9d10..872219ba8 100644 --- a/Authorization/AuthorizationTests/Presentation/Login/SignInViewModelTests.swift +++ b/Authorization/AuthorizationTests/Presentation/Login/SignInViewModelTests.swift @@ -35,12 +35,12 @@ final class SignInViewModelTests: XCTestCase { validator: validator ) - await viewModel.login(username: "email", password: "") + await viewModel.login(username: "", password: "") Verify(interactor, 0, .login(username: .any, password: .any)) Verify(router, 0, .showMainOrWhatsNewScreen()) - XCTAssertEqual(viewModel.errorMessage, AuthLocalization.Error.invalidEmailAddress) + XCTAssertEqual(viewModel.errorMessage, AuthLocalization.Error.invalidEmailAddressOrUsername) XCTAssertEqual(viewModel.isShowProgress, false) } @@ -61,7 +61,7 @@ final class SignInViewModelTests: XCTestCase { Verify(interactor, 0, .login(username: .any, password: .any)) Verify(router, 0, .showMainOrWhatsNewScreen()) - XCTAssertEqual(viewModel.errorMessage, AuthLocalization.Error.invalidPasswordLenght) + XCTAssertEqual(viewModel.errorMessage, AuthLocalization.Error.invalidPasswordLength) XCTAssertEqual(viewModel.isShowProgress, false) } diff --git a/Core/Core/View/Validator.swift b/Core/Core/View/Validator.swift index f44913db2..7ffb57401 100644 --- a/Core/Core/View/Validator.swift +++ b/Core/Core/View/Validator.swift @@ -17,16 +17,20 @@ public class Validator { public init() { } - public func isValidEmail(_ email: String) -> Bool { - return emailPredicate.evaluate(with: email) + public func isValidEmail(_ string: String) -> Bool { + return emailPredicate.evaluate(with: string) } public func isValidPassword(_ password: String) -> Bool { return password.count >= 2 } - public func isValidUsername(_ username: String) -> Bool { - return username.count >= 2 && username.count <= 30 + public func isValidUsername(_ string: String) -> Bool { + let trimmedString = string.trimmingCharacters(in: .whitespaces) + if trimmedString.contains("@") { + return emailPredicate.evaluate(with: trimmedString) + } else { + return !trimmedString.isEmpty + } } - } From ec53c4c2e68f0dde1e62eef59050a6fe214afb75 Mon Sep 17 00:00:00 2001 From: Saeed Bashir Date: Sat, 2 Dec 2023 18:44:24 +0500 Subject: [PATCH 024/158] feat: pre-login mobile app exploration (#139) * feat: pre-login mobile app exploration * fix: generated mock files again to fix the broken tests * refactor: file formatting * fix: fix broken code after GitHub resolve conflicts * chore: making feature configureable via config * refactor: remove empty method that was wrote for implementing analytics in future * fix: fixup after rebasing with develop * refactor: address feedback * refactor: address review feedback * fix: getting the ci_script changes * refactor: address review feedback * refactor: delete old config file --- .../Authorization.xcodeproj/project.pbxproj | 20 +++ .../Presentation/Login/SignInView.swift | 36 +++-- .../Presentation/Login/SignInViewModel.swift | 2 +- .../Startup/LogistrationBottomView.swift | 63 +++++++++ .../Presentation/Startup/StartupView.swift | 124 ++++++++++++++++++ .../Startup/StartupViewModel.swift | 30 +++++ .../Authorization/SwiftGen/Strings.swift | 10 ++ .../en.lproj/Localizable.strings | 5 + .../uk.lproj/Localizable.strings | 5 + .../AuthorizationMock.generated.swift | 68 ++++++++++ Core/Core/Configuration/BaseRouter.swift | 14 +- .../Configuration/Config/FeaturesConfig.swift | 3 + Core/Core/View/Base/StyledButton.swift | 16 ++- Course/CourseTests/CourseMock.generated.swift | 34 +++++ .../DashboardMock.generated.swift | 34 +++++ .../Presentation/DiscoveryRouter.swift | 6 +- .../Presentation/DiscoveryView.swift | 36 +++-- .../Discovery/Presentation/SearchView.swift | 12 +- .../DiscoveryMock.generated.swift | 34 +++++ .../DiscussionMock.generated.swift | 68 ++++++++++ OpenEdX/DI/ScreenAssembly.swift | 8 ++ OpenEdX/RouteController.swift | 21 ++- OpenEdX/Router.swift | 29 +++- OpenEdX/View/MainScreenView.swift | 3 +- .../Profile/ProfileViewModel.swift | 2 +- .../Profile/ProfileViewModelTests.swift | 2 +- .../ProfileTests/ProfileMock.generated.swift | 68 ++++++++++ ci_scripts/ci_prepare_env.sh | 1 - 28 files changed, 706 insertions(+), 48 deletions(-) create mode 100644 Authorization/Authorization/Presentation/Startup/LogistrationBottomView.swift create mode 100644 Authorization/Authorization/Presentation/Startup/StartupView.swift create mode 100644 Authorization/Authorization/Presentation/Startup/StartupViewModel.swift diff --git a/Authorization/Authorization.xcodeproj/project.pbxproj b/Authorization/Authorization.xcodeproj/project.pbxproj index fba945920..8aa67bc84 100644 --- a/Authorization/Authorization.xcodeproj/project.pbxproj +++ b/Authorization/Authorization.xcodeproj/project.pbxproj @@ -27,6 +27,9 @@ 0770DE7128D0C0E7006D8A5D /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0770DE7028D0C0E7006D8A5D /* Strings.swift */; }; 5FB79D2802949372CDAF08D6 /* Pods_App_Authorization_AuthorizationTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4FAE9B7FD61FF88C9C4FE1E8 /* Pods_App_Authorization_AuthorizationTests.framework */; }; DE843D6BB1B9DDA398494890 /* Pods_App_Authorization.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 47BCFB7C19382EECF15131B6 /* Pods_App_Authorization.framework */; }; + E03261642AE64676002CA7EB /* StartupViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E03261632AE64676002CA7EB /* StartupViewModel.swift */; }; + E03261662AE64AF4002CA7EB /* StartupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E03261652AE64AF4002CA7EB /* StartupView.swift */; }; + E03261682AE9F156002CA7EB /* LogistrationBottomView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E03261672AE9F156002CA7EB /* LogistrationBottomView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -75,6 +78,9 @@ 96C85172770225EB81A6D2DA /* Pods-App-Authorization.releasedev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Authorization.releasedev.xcconfig"; path = "Target Support Files/Pods-App-Authorization/Pods-App-Authorization.releasedev.xcconfig"; sourceTree = ""; }; 9BF6A1004A955E24527FCF0F /* Pods-App-Authorization-AuthorizationTests.releaseprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Authorization-AuthorizationTests.releaseprod.xcconfig"; path = "Target Support Files/Pods-App-Authorization-AuthorizationTests/Pods-App-Authorization-AuthorizationTests.releaseprod.xcconfig"; sourceTree = ""; }; A99D45203C981893C104053A /* Pods-App-Authorization-AuthorizationTests.releasestage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Authorization-AuthorizationTests.releasestage.xcconfig"; path = "Target Support Files/Pods-App-Authorization-AuthorizationTests/Pods-App-Authorization-AuthorizationTests.releasestage.xcconfig"; sourceTree = ""; }; + E03261632AE64676002CA7EB /* StartupViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupViewModel.swift; sourceTree = ""; }; + E03261652AE64AF4002CA7EB /* StartupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupView.swift; sourceTree = ""; }; + E03261672AE9F156002CA7EB /* LogistrationBottomView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogistrationBottomView.swift; sourceTree = ""; }; E78971D8E6ED2116BBF9FD66 /* Pods-App-Authorization.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Authorization.release.xcconfig"; path = "Target Support Files/Pods-App-Authorization/Pods-App-Authorization.release.xcconfig"; sourceTree = ""; }; F52826C68AEA1CF4769389EA /* Pods-App-Authorization.releasestage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Authorization.releasestage.xcconfig"; path = "Target Support Files/Pods-App-Authorization/Pods-App-Authorization.releasestage.xcconfig"; sourceTree = ""; }; F5802BBA113276950ABCD9B3 /* Pods-App-Authorization.releaseprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Authorization.releaseprod.xcconfig"; path = "Target Support Files/Pods-App-Authorization/Pods-App-Authorization.releaseprod.xcconfig"; sourceTree = ""; }; @@ -139,6 +145,7 @@ 071009CC28D1E24000344290 /* Presentation */ = { isa = PBXGroup; children = ( + E03261622AE6464A002CA7EB /* Startup */, 020C31BD290AADA700D6DEA2 /* Base */, 071009C528D1D9FA00344290 /* Login */, 07169462296D93E000E3DED6 /* Registration */, @@ -258,6 +265,16 @@ path = ../Pods; sourceTree = ""; }; + E03261622AE6464A002CA7EB /* Startup */ = { + isa = PBXGroup; + children = ( + E03261632AE64676002CA7EB /* StartupViewModel.swift */, + E03261652AE64AF4002CA7EB /* StartupView.swift */, + E03261672AE9F156002CA7EB /* LogistrationBottomView.swift */, + ); + path = Startup; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -471,11 +488,14 @@ 0770DE7128D0C0E7006D8A5D /* Strings.swift in Sources */, 025F40E229D360E20064C183 /* ResetPasswordViewModel.swift in Sources */, 02066B462906D72F00F4307E /* SignUpViewModel.swift in Sources */, + E03261642AE64676002CA7EB /* StartupViewModel.swift in Sources */, 02A2ACDB2A4B016100FBBBBB /* AuthorizationAnalytics.swift in Sources */, + E03261682AE9F156002CA7EB /* LogistrationBottomView.swift in Sources */, 025F40E029D1E2FC0064C183 /* ResetPasswordView.swift in Sources */, 020C31CB290BF49900D6DEA2 /* FieldsView.swift in Sources */, 0770DE4E28D0A677006D8A5D /* SignInView.swift in Sources */, 02F3BFE5292533720051930C /* AuthorizationRouter.swift in Sources */, + E03261662AE64AF4002CA7EB /* StartupView.swift in Sources */, 071009C728D1DA4F00344290 /* SignInViewModel.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Authorization/Authorization/Presentation/Login/SignInView.swift b/Authorization/Authorization/Presentation/Login/SignInView.swift index 893712c9b..17be98802 100644 --- a/Authorization/Authorization/Presentation/Login/SignInView.swift +++ b/Authorization/Authorization/Presentation/Login/SignInView.swift @@ -7,6 +7,7 @@ import SwiftUI import Core +import Swinject public struct SignInView: View { @@ -29,6 +30,19 @@ public struct SignInView: View { .resizable() .edgesIgnoringSafeArea(.top) }.frame(maxWidth: .infinity, maxHeight: 200) + if viewModel.config.features.startupScreenEnabled { + VStack { + Button(action: { viewModel.router.back() }, label: { + CoreAssets.arrowLeft.swiftUIImage.renderingMode(.template) + .backButtonStyle(color: .white) + }) + .foregroundColor(Theme.Colors.styledButtonText) + .padding(.leading, isHorizontal ? 48 : 0) + .padding(.top, 11) + + }.frame(maxWidth: .infinity, alignment: .topLeading) + .padding(.top, isHorizontal ? 20 : 0) + } VStack(alignment: .center) { CoreAssets.appLogo.swiftUIImage @@ -83,21 +97,23 @@ public struct SignInView: View { .stroke(lineWidth: 1) .fill(Theme.Colors.textInputStroke) ) - HStack { - Button(AuthLocalization.SignIn.registerBtn) { - viewModel.trackSignUpClicked() - viewModel.router.showRegisterScreen() - }.foregroundColor(Theme.Colors.accentColor) - - Spacer() - + if !viewModel.config.features.startupScreenEnabled { + Button(AuthLocalization.SignIn.registerBtn) { + viewModel.trackSignUpClicked() + viewModel.router.showRegisterScreen() + }.foregroundColor(Theme.Colors.accentColor) + + Spacer() + } + Button(AuthLocalization.SignIn.forgotPassBtn) { viewModel.trackForgotPasswordClicked() viewModel.router.showForgotPasswordScreen() }.foregroundColor(Theme.Colors.accentColor) + .padding(.top, 0) } - .padding(.top, 10) + if viewModel.isShowProgress { HStack(alignment: .center) { ProgressBar(size: 40, lineWidth: 8) @@ -153,8 +169,6 @@ public struct SignInView: View { } } .hideNavigationBar() - .navigationBarBackButtonHidden(true) - .navigationBarHidden(true) .ignoresSafeArea(.all, edges: .horizontal) .background(Theme.Colors.background.ignoresSafeArea(.all)) } diff --git a/Authorization/Authorization/Presentation/Login/SignInViewModel.swift b/Authorization/Authorization/Presentation/Login/SignInViewModel.swift index d1376e5a5..f2fa8a8f5 100644 --- a/Authorization/Authorization/Presentation/Login/SignInViewModel.swift +++ b/Authorization/Authorization/Presentation/Login/SignInViewModel.swift @@ -31,7 +31,7 @@ public class SignInViewModel: ObservableObject { } let router: AuthorizationRouter - private let config: ConfigProtocol + let config: ConfigProtocol private let interactor: AuthInteractorProtocol private let analytics: AuthorizationAnalytics private let validator: Validator diff --git a/Authorization/Authorization/Presentation/Startup/LogistrationBottomView.swift b/Authorization/Authorization/Presentation/Startup/LogistrationBottomView.swift new file mode 100644 index 000000000..2a4b2924c --- /dev/null +++ b/Authorization/Authorization/Presentation/Startup/LogistrationBottomView.swift @@ -0,0 +1,63 @@ +// +// LogistrationBottomView.swift +// Authorization +// +// Created by SaeedBashir on 10/26/23. +// + +import Foundation +import SwiftUI +import Core + +public struct LogistrationBottomView: View { + @ObservedObject + private var viewModel: StartupViewModel + + @Environment(\.isHorizontal) private var isHorizontal + + public init(viewModel: StartupViewModel) { + self.viewModel = viewModel + } + + public var body: some View { + VStack(alignment: .leading) { + HStack(spacing: 24) { + StyledButton(AuthLocalization.SignIn.registerBtn) { + viewModel.router.showRegisterScreen() + viewModel.tracksignUpClicked() + } + .frame(maxWidth: .infinity) + + StyledButton( + AuthLocalization.SignIn.logInTitle, + action: { viewModel.router.showLoginScreen() }, + color: .white, + textColor: Theme.Colors.accentColor, + borderColor: Theme.Colors.textInputStroke + ) + .frame(width: 100) + } + .padding(.horizontal, isHorizontal ? 0 : 0) + } + .padding(.horizontal, isHorizontal ? 10 : 24) + } +} + +struct LogistrationBottomView_Previews: PreviewProvider { + static var previews: some View { + let vm = StartupViewModel( + interactor: AuthInteractor.mock, + router: AuthorizationRouterMock(), + analytics: AuthorizationAnalyticsMock() + ) + LogistrationBottomView(viewModel: vm) + .preferredColorScheme(.light) + .previewDisplayName("StartupView Light") + .loadFonts() + + LogistrationBottomView(viewModel: vm) + .preferredColorScheme(.dark) + .previewDisplayName("StartupView Dark") + .loadFonts() + } +} diff --git a/Authorization/Authorization/Presentation/Startup/StartupView.swift b/Authorization/Authorization/Presentation/Startup/StartupView.swift new file mode 100644 index 000000000..b2c358ca2 --- /dev/null +++ b/Authorization/Authorization/Presentation/Startup/StartupView.swift @@ -0,0 +1,124 @@ +// +// StartupView.swift +// Authorization +// +// Created by SaeedBashir on 10/23/23. +// + +import Foundation +import SwiftUI +import Core + +public struct StartupView: View { + + @State private var searchQuery: String = "" + + @Environment(\.isHorizontal) private var isHorizontal + + @ObservedObject + private var viewModel: StartupViewModel + + public init(viewModel: StartupViewModel) { + self.viewModel = viewModel + } + + public var body: some View { + ZStack(alignment: .top) { + VStack(alignment: .leading) { + CoreAssets.appLogo.swiftUIImage + .resizable() + .frame(maxWidth: 189, maxHeight: 54) + .padding(.top, isHorizontal ? 20 : 40) + .padding(.bottom, isHorizontal ? 0 : 20) + .padding(.horizontal, isHorizontal ? 10 : 24) + .colorMultiply(Theme.Colors.accentColor) + + VStack { + VStack(alignment: .leading) { + Text(AuthLocalization.Startup.infoMessage) + .font(Theme.Fonts.titleLarge) + .foregroundColor(Theme.Colors.textPrimary) + .padding(.bottom, isHorizontal ? 10 : 20 ) + + Text(AuthLocalization.Startup.searchTitle) + .font(Theme.Fonts.bodyLarge) + .bold() + .foregroundColor(Theme.Colors.textPrimary) + .padding(.top, isHorizontal ? 0 : 24) + + HStack(spacing: 11) { + Image(systemName: "magnifyingglass") + .padding(.leading, 16) + .padding(.top, 1) + TextField(AuthLocalization.Startup.searchPlaceholder, text: $searchQuery, onCommit: { + if searchQuery.isEmpty { return } + viewModel.router.showDiscoveryScreen(searchQuery: searchQuery, fromStartupScreen: true) + }) + .autocapitalization(.none) + .autocorrectionDisabled() + .frame(minHeight: 50) + .submitLabel(.search) + + }.overlay( + Theme.Shapes.textInputShape + .stroke(lineWidth: 1) + .fill(Theme.Colors.textInputStroke) + ) + .background( + Theme.Shapes.textInputShape + .fill(Theme.Colors.textInputBackground) + ) + + Button { + viewModel.router.showDiscoveryScreen(searchQuery: searchQuery, fromStartupScreen: true) + } label: { + Text(AuthLocalization.Startup.exploreAllCourses) + .underline() + .foregroundColor(Theme.Colors.accentColor) + .font(Theme.Fonts.bodyLarge) + } + .padding(.top, isHorizontal ? 0 : 5) + Spacer() + } + .padding(.horizontal, isHorizontal ? 10 : 24) + + LogistrationBottomView(viewModel: viewModel) + } + .padding(.top, 10) + .padding(.bottom, 2) + } + .onDisappear { + searchQuery = "" + } + } + .hideNavigationBar() + .padding(.all, isHorizontal ? 1 : 0) + .background(Theme.Colors.background.ignoresSafeArea(.all)) + .ignoresSafeArea(.keyboard, edges: .bottom) + .onTapGesture { + UIApplication.shared.endEditing() + } + } +} + +#if DEBUG +struct StartupView_Previews: PreviewProvider { + static var previews: some View { + let vm = StartupViewModel( + interactor: AuthInteractor.mock, + router: AuthorizationRouterMock(), + analytics: AuthorizationAnalyticsMock() + ) + + StartupView(viewModel: vm) + .preferredColorScheme(.light) + .previewDisplayName("StartupView Light") + .loadFonts() + + StartupView(viewModel: vm) + .preferredColorScheme(.dark) + .previewDisplayName("StartupView Dark") + .loadFonts() + } +} +#endif diff --git a/Authorization/Authorization/Presentation/Startup/StartupViewModel.swift b/Authorization/Authorization/Presentation/Startup/StartupViewModel.swift new file mode 100644 index 000000000..1a13aaea9 --- /dev/null +++ b/Authorization/Authorization/Presentation/Startup/StartupViewModel.swift @@ -0,0 +1,30 @@ +// +// StartupViewModel.swift +// Authorization +// +// Created by SaeedBashir on 10/23/23. +// + +import Foundation +import Core + +public class StartupViewModel: ObservableObject { + let router: AuthorizationRouter + private let interactor: AuthInteractorProtocol + private let analytics: AuthorizationAnalytics + @Published var searchQuery: String? + + public init( + interactor: AuthInteractorProtocol, + router: AuthorizationRouter, + analytics: AuthorizationAnalytics + ) { + self.interactor = interactor + self.router = router + self.analytics = analytics + } + + func tracksignUpClicked() { + analytics.signUpClicked() + } +} diff --git a/Authorization/Authorization/SwiftGen/Strings.swift b/Authorization/Authorization/SwiftGen/Strings.swift index aa8fe2f35..512465414 100644 --- a/Authorization/Authorization/SwiftGen/Strings.swift +++ b/Authorization/Authorization/SwiftGen/Strings.swift @@ -63,6 +63,16 @@ public enum AuthLocalization { /// Sign up public static let title = AuthLocalization.tr("Localizable", "SIGN_UP.TITLE", fallback: "Sign up") } + public enum Startup { + /// Explore all courses + public static let exploreAllCourses = AuthLocalization.tr("Localizable", "STARTUP.EXPLORE_ALL_COURSES", fallback: "Explore all courses") + /// Courses and programs from the world's best universities in your pocket. + public static let infoMessage = AuthLocalization.tr("Localizable", "STARTUP.INFO_MESSAGE", fallback: "Courses and programs from the world's best universities in your pocket.") + /// Search our 3000+ courses + public static let searchPlaceholder = AuthLocalization.tr("Localizable", "STARTUP.SEARCH_PLACEHOLDER", fallback: "Search our 3000+ courses") + /// What do you want to learn? + public static let searchTitle = AuthLocalization.tr("Localizable", "STARTUP.SEARCH_TITLE", fallback: "What do you want to learn?") + } } // swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length // swiftlint:enable nesting type_body_length type_name vertical_whitespace_opening_braces diff --git a/Authorization/Authorization/en.lproj/Localizable.strings b/Authorization/Authorization/en.lproj/Localizable.strings index 8c58b2406..d6c2890b5 100644 --- a/Authorization/Authorization/en.lproj/Localizable.strings +++ b/Authorization/Authorization/en.lproj/Localizable.strings @@ -30,3 +30,8 @@ "FORGOT.REQUEST" = "Reset password"; "FORGOT.CHECK_TITLE" = "Check your email"; "FORGOT.CHECK_Description" = "We have sent a password recover instructions to your email "; + +"STARTUP.INFO_MESSAGE" = "Courses and programs from the world's best universities in your pocket."; +"STARTUP.SEARCH_TITLE" = "What do you want to learn?"; +"STARTUP.SEARCH_PLACEHOLDER" = "Search our 3000+ courses"; +"STARTUP.EXPLORE_ALL_COURSES" = "Explore all courses"; diff --git a/Authorization/Authorization/uk.lproj/Localizable.strings b/Authorization/Authorization/uk.lproj/Localizable.strings index cc06c89a0..3a334d6d4 100644 --- a/Authorization/Authorization/uk.lproj/Localizable.strings +++ b/Authorization/Authorization/uk.lproj/Localizable.strings @@ -28,3 +28,8 @@ "FORGOT.REQUEST" = "Відновити пароль"; "FORGOT.CHECK_TITLE" = "Перевірте свою електронну пошту"; "FORGOT.CHECK_Description" = "Ми надіслали інструкції щодо відновлення пароля на вашу електронну пошту "; + +"STARTUP.INFO_MESSAGE" = "Courses and programs from the world's best universities in your pocket."; +"STARTUP.SEARCH_TITLE" = "What do you want to learn?"; +"STARTUP.SEARCH_PLACEHOLDER" = "Search our 3000+ courses"; +"STARTUP.EXPLORE_ALL_COURSES" = "Explore all courses"; diff --git a/Authorization/AuthorizationTests/AuthorizationMock.generated.swift b/Authorization/AuthorizationTests/AuthorizationMock.generated.swift index 2afc95ada..98aae1963 100644 --- a/Authorization/AuthorizationTests/AuthorizationMock.generated.swift +++ b/Authorization/AuthorizationTests/AuthorizationMock.generated.swift @@ -773,6 +773,12 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { perform?() } + open func showStartupScreen() { + addInvocation(.m_showStartupScreen) + let perform = methodPerformValue(.m_showStartupScreen) as? () -> Void + perform?() + } + open func showLoginScreen() { addInvocation(.m_showLoginScreen) let perform = methodPerformValue(.m_showLoginScreen) as? () -> Void @@ -791,6 +797,12 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { perform?() } + open func showDiscoveryScreen(searchQuery: String?, fromStartupScreen: Bool) { + addInvocation(.m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(Parameter.value(`searchQuery`), Parameter.value(`fromStartupScreen`))) + let perform = methodPerformValue(.m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(Parameter.value(`searchQuery`), Parameter.value(`fromStartupScreen`))) as? (String?, Bool) -> Void + perform?(`searchQuery`, `fromStartupScreen`) + } + open func presentAlert(alertTitle: String, alertMessage: String, positiveAction: String, onCloseTapped: @escaping () -> Void, okTapped: @escaping () -> Void, type: AlertViewType) { addInvocation(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`positiveAction`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter.value(`type`))) let perform = methodPerformValue(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`positiveAction`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter.value(`type`))) as? (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void @@ -824,9 +836,11 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { case m_dismiss__animated_animated(Parameter) case m_removeLastView__controllers_controllers(Parameter) case m_showMainOrWhatsNewScreen + case m_showStartupScreen case m_showLoginScreen case m_showRegisterScreen case m_showForgotPasswordScreen + case m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(Parameter, Parameter) case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>, Parameter) case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(Parameter, Parameter, Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>, Parameter<() -> Void>) case m_presentView__transitionStyle_transitionStyleview_view(Parameter, Parameter) @@ -863,12 +877,20 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { case (.m_showMainOrWhatsNewScreen, .m_showMainOrWhatsNewScreen): return .match + case (.m_showStartupScreen, .m_showStartupScreen): return .match + case (.m_showLoginScreen, .m_showLoginScreen): return .match case (.m_showRegisterScreen, .m_showRegisterScreen): return .match case (.m_showForgotPasswordScreen, .m_showForgotPasswordScreen): return .match + case (.m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(let lhsSearchquery, let lhsFromstartupscreen), .m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(let rhsSearchquery, let rhsFromstartupscreen)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSearchquery, rhs: rhsSearchquery, with: matcher), lhsSearchquery, rhsSearchquery, "searchQuery")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsFromstartupscreen, rhs: rhsFromstartupscreen, with: matcher), lhsFromstartupscreen, rhsFromstartupscreen, "fromStartupScreen")) + return Matcher.ComparisonResult(results) + case (.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(let lhsAlerttitle, let lhsAlertmessage, let lhsPositiveaction, let lhsOnclosetapped, let lhsOktapped, let lhsType), .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(let rhsAlerttitle, let rhsAlertmessage, let rhsPositiveaction, let rhsOnclosetapped, let rhsOktapped, let rhsType)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAlerttitle, rhs: rhsAlerttitle, with: matcher), lhsAlerttitle, rhsAlerttitle, "alertTitle")) @@ -915,9 +937,11 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { case let .m_dismiss__animated_animated(p0): return p0.intValue case let .m_removeLastView__controllers_controllers(p0): return p0.intValue case .m_showMainOrWhatsNewScreen: return 0 + case .m_showStartupScreen: return 0 case .m_showLoginScreen: return 0 case .m_showRegisterScreen: return 0 case .m_showForgotPasswordScreen: return 0 + case let .m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(p0, p1): return p0.intValue + p1.intValue case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(p0, p1, p2, p3, p4, p5): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(p0, p1, p2, p3, p4, p5, p6, p7): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue + p6.intValue + p7.intValue case let .m_presentView__transitionStyle_transitionStyleview_view(p0, p1): return p0.intValue + p1.intValue @@ -933,9 +957,11 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { case .m_dismiss__animated_animated: return ".dismiss(animated:)" case .m_removeLastView__controllers_controllers: return ".removeLastView(controllers:)" case .m_showMainOrWhatsNewScreen: return ".showMainOrWhatsNewScreen()" + case .m_showStartupScreen: return ".showStartupScreen()" case .m_showLoginScreen: return ".showLoginScreen()" case .m_showRegisterScreen: return ".showRegisterScreen()" case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" + case .m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen: return ".showDiscoveryScreen(searchQuery:fromStartupScreen:)" case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type: return ".presentAlert(alertTitle:alertMessage:positiveAction:onCloseTapped:okTapped:type:)" case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped: return ".presentAlert(alertTitle:alertMessage:nextSectionName:action:image:onCloseTapped:okTapped:nextSectionTapped:)" case .m_presentView__transitionStyle_transitionStyleview_view: return ".presentView(transitionStyle:view:)" @@ -965,9 +991,11 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { public static func dismiss(animated: Parameter) -> Verify { return Verify(method: .m_dismiss__animated_animated(`animated`))} public static func removeLastView(controllers: Parameter) -> Verify { return Verify(method: .m_removeLastView__controllers_controllers(`controllers`))} public static func showMainOrWhatsNewScreen() -> Verify { return Verify(method: .m_showMainOrWhatsNewScreen)} + public static func showStartupScreen() -> Verify { return Verify(method: .m_showStartupScreen)} public static func showLoginScreen() -> Verify { return Verify(method: .m_showLoginScreen)} public static func showRegisterScreen() -> Verify { return Verify(method: .m_showRegisterScreen)} public static func showForgotPasswordScreen() -> Verify { return Verify(method: .m_showForgotPasswordScreen)} + public static func showDiscoveryScreen(searchQuery: Parameter, fromStartupScreen: Parameter) -> Verify { return Verify(method: .m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(`searchQuery`, `fromStartupScreen`))} public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`))} public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, nextSectionName: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, nextSectionTapped: Parameter<() -> Void>) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`))} public static func presentView(transitionStyle: Parameter, view: Parameter) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStyleview_view(`transitionStyle`, `view`))} @@ -999,6 +1027,9 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { public static func showMainOrWhatsNewScreen(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_showMainOrWhatsNewScreen, performs: perform) } + public static func showStartupScreen(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_showStartupScreen, performs: perform) + } public static func showLoginScreen(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_showLoginScreen, performs: perform) } @@ -1008,6 +1039,9 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { public static func showForgotPasswordScreen(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_showForgotPasswordScreen, performs: perform) } + public static func showDiscoveryScreen(searchQuery: Parameter, fromStartupScreen: Parameter, perform: @escaping (String?, Bool) -> Void) -> Perform { + return Perform(method: .m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(`searchQuery`, `fromStartupScreen`), performs: perform) + } public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter, perform: @escaping (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void) -> Perform { return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`), performs: perform) } @@ -1175,6 +1209,12 @@ open class BaseRouterMock: BaseRouter, Mock { perform?() } + open func showStartupScreen() { + addInvocation(.m_showStartupScreen) + let perform = methodPerformValue(.m_showStartupScreen) as? () -> Void + perform?() + } + open func showLoginScreen() { addInvocation(.m_showLoginScreen) let perform = methodPerformValue(.m_showLoginScreen) as? () -> Void @@ -1193,6 +1233,12 @@ open class BaseRouterMock: BaseRouter, Mock { perform?() } + open func showDiscoveryScreen(searchQuery: String?, fromStartupScreen: Bool) { + addInvocation(.m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(Parameter.value(`searchQuery`), Parameter.value(`fromStartupScreen`))) + let perform = methodPerformValue(.m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(Parameter.value(`searchQuery`), Parameter.value(`fromStartupScreen`))) as? (String?, Bool) -> Void + perform?(`searchQuery`, `fromStartupScreen`) + } + open func presentAlert(alertTitle: String, alertMessage: String, positiveAction: String, onCloseTapped: @escaping () -> Void, okTapped: @escaping () -> Void, type: AlertViewType) { addInvocation(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`positiveAction`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter.value(`type`))) let perform = methodPerformValue(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`positiveAction`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter.value(`type`))) as? (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void @@ -1225,9 +1271,11 @@ open class BaseRouterMock: BaseRouter, Mock { case m_dismiss__animated_animated(Parameter) case m_removeLastView__controllers_controllers(Parameter) case m_showMainOrWhatsNewScreen + case m_showStartupScreen case m_showLoginScreen case m_showRegisterScreen case m_showForgotPasswordScreen + case m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(Parameter, Parameter) case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>, Parameter) case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(Parameter, Parameter, Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>, Parameter<() -> Void>) case m_presentView__transitionStyle_transitionStyleview_view(Parameter, Parameter) @@ -1259,12 +1307,20 @@ open class BaseRouterMock: BaseRouter, Mock { case (.m_showMainOrWhatsNewScreen, .m_showMainOrWhatsNewScreen): return .match + case (.m_showStartupScreen, .m_showStartupScreen): return .match + case (.m_showLoginScreen, .m_showLoginScreen): return .match case (.m_showRegisterScreen, .m_showRegisterScreen): return .match case (.m_showForgotPasswordScreen, .m_showForgotPasswordScreen): return .match + case (.m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(let lhsSearchquery, let lhsFromstartupscreen), .m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(let rhsSearchquery, let rhsFromstartupscreen)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSearchquery, rhs: rhsSearchquery, with: matcher), lhsSearchquery, rhsSearchquery, "searchQuery")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsFromstartupscreen, rhs: rhsFromstartupscreen, with: matcher), lhsFromstartupscreen, rhsFromstartupscreen, "fromStartupScreen")) + return Matcher.ComparisonResult(results) + case (.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(let lhsAlerttitle, let lhsAlertmessage, let lhsPositiveaction, let lhsOnclosetapped, let lhsOktapped, let lhsType), .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(let rhsAlerttitle, let rhsAlertmessage, let rhsPositiveaction, let rhsOnclosetapped, let rhsOktapped, let rhsType)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAlerttitle, rhs: rhsAlerttitle, with: matcher), lhsAlerttitle, rhsAlerttitle, "alertTitle")) @@ -1310,9 +1366,11 @@ open class BaseRouterMock: BaseRouter, Mock { case let .m_dismiss__animated_animated(p0): return p0.intValue case let .m_removeLastView__controllers_controllers(p0): return p0.intValue case .m_showMainOrWhatsNewScreen: return 0 + case .m_showStartupScreen: return 0 case .m_showLoginScreen: return 0 case .m_showRegisterScreen: return 0 case .m_showForgotPasswordScreen: return 0 + case let .m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(p0, p1): return p0.intValue + p1.intValue case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(p0, p1, p2, p3, p4, p5): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(p0, p1, p2, p3, p4, p5, p6, p7): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue + p6.intValue + p7.intValue case let .m_presentView__transitionStyle_transitionStyleview_view(p0, p1): return p0.intValue + p1.intValue @@ -1327,9 +1385,11 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_dismiss__animated_animated: return ".dismiss(animated:)" case .m_removeLastView__controllers_controllers: return ".removeLastView(controllers:)" case .m_showMainOrWhatsNewScreen: return ".showMainOrWhatsNewScreen()" + case .m_showStartupScreen: return ".showStartupScreen()" case .m_showLoginScreen: return ".showLoginScreen()" case .m_showRegisterScreen: return ".showRegisterScreen()" case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" + case .m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen: return ".showDiscoveryScreen(searchQuery:fromStartupScreen:)" case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type: return ".presentAlert(alertTitle:alertMessage:positiveAction:onCloseTapped:okTapped:type:)" case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped: return ".presentAlert(alertTitle:alertMessage:nextSectionName:action:image:onCloseTapped:okTapped:nextSectionTapped:)" case .m_presentView__transitionStyle_transitionStyleview_view: return ".presentView(transitionStyle:view:)" @@ -1358,9 +1418,11 @@ open class BaseRouterMock: BaseRouter, Mock { public static func dismiss(animated: Parameter) -> Verify { return Verify(method: .m_dismiss__animated_animated(`animated`))} public static func removeLastView(controllers: Parameter) -> Verify { return Verify(method: .m_removeLastView__controllers_controllers(`controllers`))} public static func showMainOrWhatsNewScreen() -> Verify { return Verify(method: .m_showMainOrWhatsNewScreen)} + public static func showStartupScreen() -> Verify { return Verify(method: .m_showStartupScreen)} public static func showLoginScreen() -> Verify { return Verify(method: .m_showLoginScreen)} public static func showRegisterScreen() -> Verify { return Verify(method: .m_showRegisterScreen)} public static func showForgotPasswordScreen() -> Verify { return Verify(method: .m_showForgotPasswordScreen)} + public static func showDiscoveryScreen(searchQuery: Parameter, fromStartupScreen: Parameter) -> Verify { return Verify(method: .m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(`searchQuery`, `fromStartupScreen`))} public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`))} public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, nextSectionName: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, nextSectionTapped: Parameter<() -> Void>) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`))} public static func presentView(transitionStyle: Parameter, view: Parameter) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStyleview_view(`transitionStyle`, `view`))} @@ -1389,6 +1451,9 @@ open class BaseRouterMock: BaseRouter, Mock { public static func showMainOrWhatsNewScreen(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_showMainOrWhatsNewScreen, performs: perform) } + public static func showStartupScreen(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_showStartupScreen, performs: perform) + } public static func showLoginScreen(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_showLoginScreen, performs: perform) } @@ -1398,6 +1463,9 @@ open class BaseRouterMock: BaseRouter, Mock { public static func showForgotPasswordScreen(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_showForgotPasswordScreen, performs: perform) } + public static func showDiscoveryScreen(searchQuery: Parameter, fromStartupScreen: Parameter, perform: @escaping (String?, Bool) -> Void) -> Perform { + return Perform(method: .m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(`searchQuery`, `fromStartupScreen`), performs: perform) + } public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter, perform: @escaping (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void) -> Perform { return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`), performs: perform) } diff --git a/Core/Core/Configuration/BaseRouter.swift b/Core/Core/Configuration/BaseRouter.swift index 034855d87..a153aa067 100644 --- a/Core/Core/Configuration/BaseRouter.swift +++ b/Core/Core/Configuration/BaseRouter.swift @@ -22,12 +22,16 @@ public protocol BaseRouter { func removeLastView(controllers: Int) func showMainOrWhatsNewScreen() - + + func showStartupScreen() + func showLoginScreen() - + func showRegisterScreen() func showForgotPasswordScreen() + + func showDiscoveryScreen(searchQuery: String?, fromStartupScreen: Bool) func presentAlert( alertTitle: String, @@ -74,13 +78,17 @@ open class BaseRouterMock: BaseRouter { public func dismiss(animated: Bool) {} public func showMainOrWhatsNewScreen() {} + + public func showStartupScreen() {} public func showLoginScreen() {} - + public func showRegisterScreen() {} public func showForgotPasswordScreen() {} + public func showDiscoveryScreen(searchQuery: String?, fromStartupScreen: Bool) {} + public func backToRoot(animated: Bool) {} public func back(animated: Bool) {} diff --git a/Core/Core/Configuration/Config/FeaturesConfig.swift b/Core/Core/Configuration/Config/FeaturesConfig.swift index eb6c6227f..eec9e9853 100644 --- a/Core/Core/Configuration/Config/FeaturesConfig.swift +++ b/Core/Core/Configuration/Config/FeaturesConfig.swift @@ -9,13 +9,16 @@ import Foundation private enum FeaturesKeys: String { case whatNewEnabled = "WHATS_NEW_ENABLED" + case startupScreenEnabled = "PRE_LOGIN_EXPERIENCE_ENABLED" } public class FeaturesConfig: NSObject { public var whatNewEnabled: Bool + public var startupScreenEnabled: Bool init(dictionary: [String: Any]) { whatNewEnabled = dictionary[FeaturesKeys.whatNewEnabled.rawValue] as? Bool ?? false + startupScreenEnabled = dictionary[FeaturesKeys.startupScreenEnabled.rawValue] as? Bool ?? false super.init() } } diff --git a/Core/Core/View/Base/StyledButton.swift b/Core/Core/View/Base/StyledButton.swift index fdad6a1d1..92ef56e84 100644 --- a/Core/Core/View/Base/StyledButton.swift +++ b/Core/Core/View/Base/StyledButton.swift @@ -8,29 +8,35 @@ import SwiftUI public struct StyledButton: View { - private let title: String private let action: () -> Void private let isTransparent: Bool private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } private let buttonColor: Color private let textColor: Color + private let disabledTextColor: Color private let isActive: Bool + private let borderColor: Color public init(_ title: String, action: @escaping () -> Void, isTransparent: Bool = false, color: Color = Theme.Colors.accentColor, + textColor: Color = Theme.Colors.styledButtonText, + disabledTextColor: Color = Theme.Colors.textPrimary, + borderColor: Color = .clear, isActive: Bool = true) { self.title = title self.action = action self.isTransparent = isTransparent + self.textColor = textColor + self.disabledTextColor = disabledTextColor + self.borderColor = borderColor + if isActive { self.buttonColor = color - self.textColor = Theme.Colors.styledButtonText } else { self.buttonColor = Theme.Colors.cardViewStroke - self.textColor = Theme.Colors.textPrimary } self.isActive = isActive } @@ -39,7 +45,7 @@ public struct StyledButton: View { Button(action: action) { Text(title) .tracking(isTransparent ? 0 : 1.3) - .foregroundColor(textColor) + .foregroundColor(isActive ? textColor : disabledTextColor) .font(Theme.Fonts.labelLarge) .frame(maxWidth: .infinity) .padding(.horizontal, 16) @@ -53,7 +59,7 @@ public struct StyledButton: View { .overlay( RoundedRectangle(cornerRadius: 8) .stroke(style: .init(lineWidth: 1, lineCap: .round, lineJoin: .round, miterLimit: 1)) - .foregroundColor(isTransparent ? .white : .clear) + .foregroundColor(isTransparent ? .white : borderColor) ) .accessibilityElement(children: .ignore) .accessibilityLabel(title) diff --git a/Course/CourseTests/CourseMock.generated.swift b/Course/CourseTests/CourseMock.generated.swift index 8c909304b..0c093e89c 100644 --- a/Course/CourseTests/CourseMock.generated.swift +++ b/Course/CourseTests/CourseMock.generated.swift @@ -496,6 +496,12 @@ open class BaseRouterMock: BaseRouter, Mock { perform?() } + open func showStartupScreen() { + addInvocation(.m_showStartupScreen) + let perform = methodPerformValue(.m_showStartupScreen) as? () -> Void + perform?() + } + open func showLoginScreen() { addInvocation(.m_showLoginScreen) let perform = methodPerformValue(.m_showLoginScreen) as? () -> Void @@ -514,6 +520,12 @@ open class BaseRouterMock: BaseRouter, Mock { perform?() } + open func showDiscoveryScreen(searchQuery: String?, fromStartupScreen: Bool) { + addInvocation(.m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(Parameter.value(`searchQuery`), Parameter.value(`fromStartupScreen`))) + let perform = methodPerformValue(.m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(Parameter.value(`searchQuery`), Parameter.value(`fromStartupScreen`))) as? (String?, Bool) -> Void + perform?(`searchQuery`, `fromStartupScreen`) + } + open func presentAlert(alertTitle: String, alertMessage: String, positiveAction: String, onCloseTapped: @escaping () -> Void, okTapped: @escaping () -> Void, type: AlertViewType) { addInvocation(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`positiveAction`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter.value(`type`))) let perform = methodPerformValue(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`positiveAction`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter.value(`type`))) as? (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void @@ -546,9 +558,11 @@ open class BaseRouterMock: BaseRouter, Mock { case m_dismiss__animated_animated(Parameter) case m_removeLastView__controllers_controllers(Parameter) case m_showMainOrWhatsNewScreen + case m_showStartupScreen case m_showLoginScreen case m_showRegisterScreen case m_showForgotPasswordScreen + case m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(Parameter, Parameter) case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>, Parameter) case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(Parameter, Parameter, Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>, Parameter<() -> Void>) case m_presentView__transitionStyle_transitionStyleview_view(Parameter, Parameter) @@ -580,12 +594,20 @@ open class BaseRouterMock: BaseRouter, Mock { case (.m_showMainOrWhatsNewScreen, .m_showMainOrWhatsNewScreen): return .match + case (.m_showStartupScreen, .m_showStartupScreen): return .match + case (.m_showLoginScreen, .m_showLoginScreen): return .match case (.m_showRegisterScreen, .m_showRegisterScreen): return .match case (.m_showForgotPasswordScreen, .m_showForgotPasswordScreen): return .match + case (.m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(let lhsSearchquery, let lhsFromstartupscreen), .m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(let rhsSearchquery, let rhsFromstartupscreen)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSearchquery, rhs: rhsSearchquery, with: matcher), lhsSearchquery, rhsSearchquery, "searchQuery")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsFromstartupscreen, rhs: rhsFromstartupscreen, with: matcher), lhsFromstartupscreen, rhsFromstartupscreen, "fromStartupScreen")) + return Matcher.ComparisonResult(results) + case (.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(let lhsAlerttitle, let lhsAlertmessage, let lhsPositiveaction, let lhsOnclosetapped, let lhsOktapped, let lhsType), .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(let rhsAlerttitle, let rhsAlertmessage, let rhsPositiveaction, let rhsOnclosetapped, let rhsOktapped, let rhsType)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAlerttitle, rhs: rhsAlerttitle, with: matcher), lhsAlerttitle, rhsAlerttitle, "alertTitle")) @@ -631,9 +653,11 @@ open class BaseRouterMock: BaseRouter, Mock { case let .m_dismiss__animated_animated(p0): return p0.intValue case let .m_removeLastView__controllers_controllers(p0): return p0.intValue case .m_showMainOrWhatsNewScreen: return 0 + case .m_showStartupScreen: return 0 case .m_showLoginScreen: return 0 case .m_showRegisterScreen: return 0 case .m_showForgotPasswordScreen: return 0 + case let .m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(p0, p1): return p0.intValue + p1.intValue case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(p0, p1, p2, p3, p4, p5): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(p0, p1, p2, p3, p4, p5, p6, p7): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue + p6.intValue + p7.intValue case let .m_presentView__transitionStyle_transitionStyleview_view(p0, p1): return p0.intValue + p1.intValue @@ -648,9 +672,11 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_dismiss__animated_animated: return ".dismiss(animated:)" case .m_removeLastView__controllers_controllers: return ".removeLastView(controllers:)" case .m_showMainOrWhatsNewScreen: return ".showMainOrWhatsNewScreen()" + case .m_showStartupScreen: return ".showStartupScreen()" case .m_showLoginScreen: return ".showLoginScreen()" case .m_showRegisterScreen: return ".showRegisterScreen()" case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" + case .m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen: return ".showDiscoveryScreen(searchQuery:fromStartupScreen:)" case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type: return ".presentAlert(alertTitle:alertMessage:positiveAction:onCloseTapped:okTapped:type:)" case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped: return ".presentAlert(alertTitle:alertMessage:nextSectionName:action:image:onCloseTapped:okTapped:nextSectionTapped:)" case .m_presentView__transitionStyle_transitionStyleview_view: return ".presentView(transitionStyle:view:)" @@ -679,9 +705,11 @@ open class BaseRouterMock: BaseRouter, Mock { public static func dismiss(animated: Parameter) -> Verify { return Verify(method: .m_dismiss__animated_animated(`animated`))} public static func removeLastView(controllers: Parameter) -> Verify { return Verify(method: .m_removeLastView__controllers_controllers(`controllers`))} public static func showMainOrWhatsNewScreen() -> Verify { return Verify(method: .m_showMainOrWhatsNewScreen)} + public static func showStartupScreen() -> Verify { return Verify(method: .m_showStartupScreen)} public static func showLoginScreen() -> Verify { return Verify(method: .m_showLoginScreen)} public static func showRegisterScreen() -> Verify { return Verify(method: .m_showRegisterScreen)} public static func showForgotPasswordScreen() -> Verify { return Verify(method: .m_showForgotPasswordScreen)} + public static func showDiscoveryScreen(searchQuery: Parameter, fromStartupScreen: Parameter) -> Verify { return Verify(method: .m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(`searchQuery`, `fromStartupScreen`))} public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`))} public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, nextSectionName: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, nextSectionTapped: Parameter<() -> Void>) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`))} public static func presentView(transitionStyle: Parameter, view: Parameter) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStyleview_view(`transitionStyle`, `view`))} @@ -710,6 +738,9 @@ open class BaseRouterMock: BaseRouter, Mock { public static func showMainOrWhatsNewScreen(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_showMainOrWhatsNewScreen, performs: perform) } + public static func showStartupScreen(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_showStartupScreen, performs: perform) + } public static func showLoginScreen(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_showLoginScreen, performs: perform) } @@ -719,6 +750,9 @@ open class BaseRouterMock: BaseRouter, Mock { public static func showForgotPasswordScreen(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_showForgotPasswordScreen, performs: perform) } + public static func showDiscoveryScreen(searchQuery: Parameter, fromStartupScreen: Parameter, perform: @escaping (String?, Bool) -> Void) -> Perform { + return Perform(method: .m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(`searchQuery`, `fromStartupScreen`), performs: perform) + } public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter, perform: @escaping (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void) -> Perform { return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`), performs: perform) } diff --git a/Dashboard/DashboardTests/DashboardMock.generated.swift b/Dashboard/DashboardTests/DashboardMock.generated.swift index 08f998ce9..ba988cfff 100644 --- a/Dashboard/DashboardTests/DashboardMock.generated.swift +++ b/Dashboard/DashboardTests/DashboardMock.generated.swift @@ -496,6 +496,12 @@ open class BaseRouterMock: BaseRouter, Mock { perform?() } + open func showStartupScreen() { + addInvocation(.m_showStartupScreen) + let perform = methodPerformValue(.m_showStartupScreen) as? () -> Void + perform?() + } + open func showLoginScreen() { addInvocation(.m_showLoginScreen) let perform = methodPerformValue(.m_showLoginScreen) as? () -> Void @@ -514,6 +520,12 @@ open class BaseRouterMock: BaseRouter, Mock { perform?() } + open func showDiscoveryScreen(searchQuery: String?, fromStartupScreen: Bool) { + addInvocation(.m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(Parameter.value(`searchQuery`), Parameter.value(`fromStartupScreen`))) + let perform = methodPerformValue(.m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(Parameter.value(`searchQuery`), Parameter.value(`fromStartupScreen`))) as? (String?, Bool) -> Void + perform?(`searchQuery`, `fromStartupScreen`) + } + open func presentAlert(alertTitle: String, alertMessage: String, positiveAction: String, onCloseTapped: @escaping () -> Void, okTapped: @escaping () -> Void, type: AlertViewType) { addInvocation(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`positiveAction`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter.value(`type`))) let perform = methodPerformValue(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`positiveAction`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter.value(`type`))) as? (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void @@ -546,9 +558,11 @@ open class BaseRouterMock: BaseRouter, Mock { case m_dismiss__animated_animated(Parameter) case m_removeLastView__controllers_controllers(Parameter) case m_showMainOrWhatsNewScreen + case m_showStartupScreen case m_showLoginScreen case m_showRegisterScreen case m_showForgotPasswordScreen + case m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(Parameter, Parameter) case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>, Parameter) case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(Parameter, Parameter, Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>, Parameter<() -> Void>) case m_presentView__transitionStyle_transitionStyleview_view(Parameter, Parameter) @@ -580,12 +594,20 @@ open class BaseRouterMock: BaseRouter, Mock { case (.m_showMainOrWhatsNewScreen, .m_showMainOrWhatsNewScreen): return .match + case (.m_showStartupScreen, .m_showStartupScreen): return .match + case (.m_showLoginScreen, .m_showLoginScreen): return .match case (.m_showRegisterScreen, .m_showRegisterScreen): return .match case (.m_showForgotPasswordScreen, .m_showForgotPasswordScreen): return .match + case (.m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(let lhsSearchquery, let lhsFromstartupscreen), .m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(let rhsSearchquery, let rhsFromstartupscreen)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSearchquery, rhs: rhsSearchquery, with: matcher), lhsSearchquery, rhsSearchquery, "searchQuery")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsFromstartupscreen, rhs: rhsFromstartupscreen, with: matcher), lhsFromstartupscreen, rhsFromstartupscreen, "fromStartupScreen")) + return Matcher.ComparisonResult(results) + case (.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(let lhsAlerttitle, let lhsAlertmessage, let lhsPositiveaction, let lhsOnclosetapped, let lhsOktapped, let lhsType), .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(let rhsAlerttitle, let rhsAlertmessage, let rhsPositiveaction, let rhsOnclosetapped, let rhsOktapped, let rhsType)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAlerttitle, rhs: rhsAlerttitle, with: matcher), lhsAlerttitle, rhsAlerttitle, "alertTitle")) @@ -631,9 +653,11 @@ open class BaseRouterMock: BaseRouter, Mock { case let .m_dismiss__animated_animated(p0): return p0.intValue case let .m_removeLastView__controllers_controllers(p0): return p0.intValue case .m_showMainOrWhatsNewScreen: return 0 + case .m_showStartupScreen: return 0 case .m_showLoginScreen: return 0 case .m_showRegisterScreen: return 0 case .m_showForgotPasswordScreen: return 0 + case let .m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(p0, p1): return p0.intValue + p1.intValue case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(p0, p1, p2, p3, p4, p5): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(p0, p1, p2, p3, p4, p5, p6, p7): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue + p6.intValue + p7.intValue case let .m_presentView__transitionStyle_transitionStyleview_view(p0, p1): return p0.intValue + p1.intValue @@ -648,9 +672,11 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_dismiss__animated_animated: return ".dismiss(animated:)" case .m_removeLastView__controllers_controllers: return ".removeLastView(controllers:)" case .m_showMainOrWhatsNewScreen: return ".showMainOrWhatsNewScreen()" + case .m_showStartupScreen: return ".showStartupScreen()" case .m_showLoginScreen: return ".showLoginScreen()" case .m_showRegisterScreen: return ".showRegisterScreen()" case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" + case .m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen: return ".showDiscoveryScreen(searchQuery:fromStartupScreen:)" case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type: return ".presentAlert(alertTitle:alertMessage:positiveAction:onCloseTapped:okTapped:type:)" case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped: return ".presentAlert(alertTitle:alertMessage:nextSectionName:action:image:onCloseTapped:okTapped:nextSectionTapped:)" case .m_presentView__transitionStyle_transitionStyleview_view: return ".presentView(transitionStyle:view:)" @@ -679,9 +705,11 @@ open class BaseRouterMock: BaseRouter, Mock { public static func dismiss(animated: Parameter) -> Verify { return Verify(method: .m_dismiss__animated_animated(`animated`))} public static func removeLastView(controllers: Parameter) -> Verify { return Verify(method: .m_removeLastView__controllers_controllers(`controllers`))} public static func showMainOrWhatsNewScreen() -> Verify { return Verify(method: .m_showMainOrWhatsNewScreen)} + public static func showStartupScreen() -> Verify { return Verify(method: .m_showStartupScreen)} public static func showLoginScreen() -> Verify { return Verify(method: .m_showLoginScreen)} public static func showRegisterScreen() -> Verify { return Verify(method: .m_showRegisterScreen)} public static func showForgotPasswordScreen() -> Verify { return Verify(method: .m_showForgotPasswordScreen)} + public static func showDiscoveryScreen(searchQuery: Parameter, fromStartupScreen: Parameter) -> Verify { return Verify(method: .m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(`searchQuery`, `fromStartupScreen`))} public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`))} public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, nextSectionName: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, nextSectionTapped: Parameter<() -> Void>) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`))} public static func presentView(transitionStyle: Parameter, view: Parameter) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStyleview_view(`transitionStyle`, `view`))} @@ -710,6 +738,9 @@ open class BaseRouterMock: BaseRouter, Mock { public static func showMainOrWhatsNewScreen(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_showMainOrWhatsNewScreen, performs: perform) } + public static func showStartupScreen(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_showStartupScreen, performs: perform) + } public static func showLoginScreen(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_showLoginScreen, performs: perform) } @@ -719,6 +750,9 @@ open class BaseRouterMock: BaseRouter, Mock { public static func showForgotPasswordScreen(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_showForgotPasswordScreen, performs: perform) } + public static func showDiscoveryScreen(searchQuery: Parameter, fromStartupScreen: Parameter, perform: @escaping (String?, Bool) -> Void) -> Perform { + return Perform(method: .m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(`searchQuery`, `fromStartupScreen`), performs: perform) + } public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter, perform: @escaping (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void) -> Perform { return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`), performs: perform) } diff --git a/Discovery/Discovery/Presentation/DiscoveryRouter.swift b/Discovery/Discovery/Presentation/DiscoveryRouter.swift index 61fc564d5..32b13a6e4 100644 --- a/Discovery/Discovery/Presentation/DiscoveryRouter.swift +++ b/Discovery/Discovery/Presentation/DiscoveryRouter.swift @@ -11,9 +11,9 @@ import Core public protocol DiscoveryRouter: BaseRouter { func showCourseDetais(courseID: String, title: String) - func showDiscoverySearch() func showUpdateRequiredView(showAccountLink: Bool) func showUpdateRecomendedView() + func showDiscoverySearch(searchQuery: String?) } // Mark - For testing and SwiftUI preview @@ -23,8 +23,8 @@ public class DiscoveryRouterMock: BaseRouterMock, DiscoveryRouter { public override init() {} public func showCourseDetais(courseID: String, title: String) {} - public func showDiscoverySearch() {} public func showUpdateRequiredView(showAccountLink: Bool) {} - public func showUpdateRecomendedView() {} + public func showUpdateRecomendedView() {} + public func showDiscoverySearch(searchQuery: String? = nil) {} } #endif diff --git a/Discovery/Discovery/Presentation/DiscoveryView.swift b/Discovery/Discovery/Presentation/DiscoveryView.swift index a90b41a9b..95c19a0b0 100644 --- a/Discovery/Discovery/Presentation/DiscoveryView.swift +++ b/Discovery/Discovery/Presentation/DiscoveryView.swift @@ -12,8 +12,15 @@ public struct DiscoveryView: View { @StateObject private var viewModel: DiscoveryViewModel + private var router: DiscoveryRouter + @State private var searchQuery: String = "" @State private var isRefreshing: Bool = false + private var fromStartupScreen: Bool = false + + @Environment (\.isHorizontal) private var isHorizontal + @Environment(\.presentationMode) private var presentationMode + private let discoveryNew: some View = VStack(alignment: .leading) { Text(DiscoveryLocalization.Header.title1) .font(Theme.Fonts.displaySmall) @@ -25,8 +32,16 @@ public struct DiscoveryView: View { .accessibilityElement(children: .ignore) .accessibilityLabel(DiscoveryLocalization.Header.title1 + DiscoveryLocalization.Header.title2) - public init(viewModel: DiscoveryViewModel) { + public init( + viewModel: DiscoveryViewModel, + router: DiscoveryRouter, + searchQuery: String? = nil, + fromStartupScreen: Bool = false + ) { self._viewModel = StateObject(wrappedValue: { viewModel }()) + self.router = router + self.fromStartupScreen = fromStartupScreen + self._searchQuery = State(initialValue: searchQuery ?? "") } public var body: some View { @@ -45,11 +60,11 @@ public struct DiscoveryView: View { Spacer() } .onTapGesture { - viewModel.router.showDiscoverySearch() + router.showDiscoverySearch(searchQuery: searchQuery) viewModel.discoverySearchBarClicked() } .frame(minHeight: 48) - .frame(maxWidth: 532) + .frame(maxWidth: .infinity) .background( Theme.Shapes.textInputShape .fill(Theme.Colors.textInputUnfocusedBackground) @@ -58,11 +73,11 @@ public struct DiscoveryView: View { Theme.Shapes.textInputShape .stroke(lineWidth: 1) .fill(Theme.Colors.textInputUnfocusedStroke) - ) - .onTapGesture { - viewModel.router.showDiscoverySearch() + ).onTapGesture { + router.showDiscoverySearch(searchQuery: searchQuery) viewModel.discoverySearchBarClicked() } + .padding(.top, 11.5) .padding(.horizontal, 24) .padding(.bottom, 20) .accessibilityElement(children: .ignore) @@ -145,7 +160,12 @@ public struct DiscoveryView: View { } } } + .navigationBarHidden(fromStartupScreen ? false : true) .onFirstAppear { + if !(searchQuery.isEmpty) { + router.showDiscoverySearch(searchQuery: searchQuery) + searchQuery = "" + } Task { await viewModel.discovery(page: 1) } @@ -165,11 +185,11 @@ struct DiscoveryView_Previews: PreviewProvider { analytics: DiscoveryAnalyticsMock()) let router = DiscoveryRouterMock() - DiscoveryView(viewModel: vm) + DiscoveryView(viewModel: vm, router: router) .preferredColorScheme(.light) .previewDisplayName("DiscoveryView Light") - DiscoveryView(viewModel: vm) + DiscoveryView(viewModel: vm, router: router) .preferredColorScheme(.dark) .previewDisplayName("DiscoveryView Dark") } diff --git a/Discovery/Discovery/Presentation/SearchView.swift b/Discovery/Discovery/Presentation/SearchView.swift index 77dd693c8..3a857d0bf 100644 --- a/Discovery/Discovery/Presentation/SearchView.swift +++ b/Discovery/Discovery/Presentation/SearchView.swift @@ -17,8 +17,10 @@ public struct SearchView: View { private var viewModel: SearchViewModel @State private var animated: Bool = false - public init(viewModel: SearchViewModel) { + public init(viewModel: SearchViewModel, searchQuery: String? = nil) { self.viewModel = viewModel + self.viewModel.searchText = searchQuery ?? "" + self.viewModel.isSearchActive = !(searchQuery?.isEmpty ?? false) } public var body: some View { @@ -34,7 +36,7 @@ public struct SearchView: View { HStack(spacing: 11) { Image(systemName: "magnifyingglass") .padding(.leading, 16) - .padding(.top, -1) + .padding(.top, 1) .foregroundColor( viewModel.isSearchActive ? Theme.Colors.accentColor @@ -68,7 +70,7 @@ public struct SearchView: View { } } .frame(minHeight: 48) - .frame(maxWidth: 532) + .frame(maxWidth: .infinity) .background( Theme.Shapes.textInputShape .fill(viewModel.isSearchActive @@ -156,6 +158,10 @@ public struct SearchView: View { } } } + + .onDisappear { + viewModel.searchText = "" + } .background(Theme.Colors.background.ignoresSafeArea()) .addTapToEndEditing(isForced: true) } diff --git a/Discovery/DiscoveryTests/DiscoveryMock.generated.swift b/Discovery/DiscoveryTests/DiscoveryMock.generated.swift index d49e7424f..b95cb9f0d 100644 --- a/Discovery/DiscoveryTests/DiscoveryMock.generated.swift +++ b/Discovery/DiscoveryTests/DiscoveryMock.generated.swift @@ -496,6 +496,12 @@ open class BaseRouterMock: BaseRouter, Mock { perform?() } + open func showStartupScreen() { + addInvocation(.m_showStartupScreen) + let perform = methodPerformValue(.m_showStartupScreen) as? () -> Void + perform?() + } + open func showLoginScreen() { addInvocation(.m_showLoginScreen) let perform = methodPerformValue(.m_showLoginScreen) as? () -> Void @@ -514,6 +520,12 @@ open class BaseRouterMock: BaseRouter, Mock { perform?() } + open func showDiscoveryScreen(searchQuery: String?, fromStartupScreen: Bool) { + addInvocation(.m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(Parameter.value(`searchQuery`), Parameter.value(`fromStartupScreen`))) + let perform = methodPerformValue(.m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(Parameter.value(`searchQuery`), Parameter.value(`fromStartupScreen`))) as? (String?, Bool) -> Void + perform?(`searchQuery`, `fromStartupScreen`) + } + open func presentAlert(alertTitle: String, alertMessage: String, positiveAction: String, onCloseTapped: @escaping () -> Void, okTapped: @escaping () -> Void, type: AlertViewType) { addInvocation(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`positiveAction`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter.value(`type`))) let perform = methodPerformValue(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`positiveAction`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter.value(`type`))) as? (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void @@ -546,9 +558,11 @@ open class BaseRouterMock: BaseRouter, Mock { case m_dismiss__animated_animated(Parameter) case m_removeLastView__controllers_controllers(Parameter) case m_showMainOrWhatsNewScreen + case m_showStartupScreen case m_showLoginScreen case m_showRegisterScreen case m_showForgotPasswordScreen + case m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(Parameter, Parameter) case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>, Parameter) case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(Parameter, Parameter, Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>, Parameter<() -> Void>) case m_presentView__transitionStyle_transitionStyleview_view(Parameter, Parameter) @@ -580,12 +594,20 @@ open class BaseRouterMock: BaseRouter, Mock { case (.m_showMainOrWhatsNewScreen, .m_showMainOrWhatsNewScreen): return .match + case (.m_showStartupScreen, .m_showStartupScreen): return .match + case (.m_showLoginScreen, .m_showLoginScreen): return .match case (.m_showRegisterScreen, .m_showRegisterScreen): return .match case (.m_showForgotPasswordScreen, .m_showForgotPasswordScreen): return .match + case (.m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(let lhsSearchquery, let lhsFromstartupscreen), .m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(let rhsSearchquery, let rhsFromstartupscreen)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSearchquery, rhs: rhsSearchquery, with: matcher), lhsSearchquery, rhsSearchquery, "searchQuery")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsFromstartupscreen, rhs: rhsFromstartupscreen, with: matcher), lhsFromstartupscreen, rhsFromstartupscreen, "fromStartupScreen")) + return Matcher.ComparisonResult(results) + case (.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(let lhsAlerttitle, let lhsAlertmessage, let lhsPositiveaction, let lhsOnclosetapped, let lhsOktapped, let lhsType), .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(let rhsAlerttitle, let rhsAlertmessage, let rhsPositiveaction, let rhsOnclosetapped, let rhsOktapped, let rhsType)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAlerttitle, rhs: rhsAlerttitle, with: matcher), lhsAlerttitle, rhsAlerttitle, "alertTitle")) @@ -631,9 +653,11 @@ open class BaseRouterMock: BaseRouter, Mock { case let .m_dismiss__animated_animated(p0): return p0.intValue case let .m_removeLastView__controllers_controllers(p0): return p0.intValue case .m_showMainOrWhatsNewScreen: return 0 + case .m_showStartupScreen: return 0 case .m_showLoginScreen: return 0 case .m_showRegisterScreen: return 0 case .m_showForgotPasswordScreen: return 0 + case let .m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(p0, p1): return p0.intValue + p1.intValue case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(p0, p1, p2, p3, p4, p5): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(p0, p1, p2, p3, p4, p5, p6, p7): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue + p6.intValue + p7.intValue case let .m_presentView__transitionStyle_transitionStyleview_view(p0, p1): return p0.intValue + p1.intValue @@ -648,9 +672,11 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_dismiss__animated_animated: return ".dismiss(animated:)" case .m_removeLastView__controllers_controllers: return ".removeLastView(controllers:)" case .m_showMainOrWhatsNewScreen: return ".showMainOrWhatsNewScreen()" + case .m_showStartupScreen: return ".showStartupScreen()" case .m_showLoginScreen: return ".showLoginScreen()" case .m_showRegisterScreen: return ".showRegisterScreen()" case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" + case .m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen: return ".showDiscoveryScreen(searchQuery:fromStartupScreen:)" case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type: return ".presentAlert(alertTitle:alertMessage:positiveAction:onCloseTapped:okTapped:type:)" case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped: return ".presentAlert(alertTitle:alertMessage:nextSectionName:action:image:onCloseTapped:okTapped:nextSectionTapped:)" case .m_presentView__transitionStyle_transitionStyleview_view: return ".presentView(transitionStyle:view:)" @@ -679,9 +705,11 @@ open class BaseRouterMock: BaseRouter, Mock { public static func dismiss(animated: Parameter) -> Verify { return Verify(method: .m_dismiss__animated_animated(`animated`))} public static func removeLastView(controllers: Parameter) -> Verify { return Verify(method: .m_removeLastView__controllers_controllers(`controllers`))} public static func showMainOrWhatsNewScreen() -> Verify { return Verify(method: .m_showMainOrWhatsNewScreen)} + public static func showStartupScreen() -> Verify { return Verify(method: .m_showStartupScreen)} public static func showLoginScreen() -> Verify { return Verify(method: .m_showLoginScreen)} public static func showRegisterScreen() -> Verify { return Verify(method: .m_showRegisterScreen)} public static func showForgotPasswordScreen() -> Verify { return Verify(method: .m_showForgotPasswordScreen)} + public static func showDiscoveryScreen(searchQuery: Parameter, fromStartupScreen: Parameter) -> Verify { return Verify(method: .m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(`searchQuery`, `fromStartupScreen`))} public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`))} public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, nextSectionName: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, nextSectionTapped: Parameter<() -> Void>) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`))} public static func presentView(transitionStyle: Parameter, view: Parameter) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStyleview_view(`transitionStyle`, `view`))} @@ -710,6 +738,9 @@ open class BaseRouterMock: BaseRouter, Mock { public static func showMainOrWhatsNewScreen(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_showMainOrWhatsNewScreen, performs: perform) } + public static func showStartupScreen(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_showStartupScreen, performs: perform) + } public static func showLoginScreen(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_showLoginScreen, performs: perform) } @@ -719,6 +750,9 @@ open class BaseRouterMock: BaseRouter, Mock { public static func showForgotPasswordScreen(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_showForgotPasswordScreen, performs: perform) } + public static func showDiscoveryScreen(searchQuery: Parameter, fromStartupScreen: Parameter, perform: @escaping (String?, Bool) -> Void) -> Perform { + return Perform(method: .m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(`searchQuery`, `fromStartupScreen`), performs: perform) + } public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter, perform: @escaping (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void) -> Perform { return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`), performs: perform) } diff --git a/Discussion/DiscussionTests/DiscussionMock.generated.swift b/Discussion/DiscussionTests/DiscussionMock.generated.swift index 775ffc794..5f2e17f42 100644 --- a/Discussion/DiscussionTests/DiscussionMock.generated.swift +++ b/Discussion/DiscussionTests/DiscussionMock.generated.swift @@ -496,6 +496,12 @@ open class BaseRouterMock: BaseRouter, Mock { perform?() } + open func showStartupScreen() { + addInvocation(.m_showStartupScreen) + let perform = methodPerformValue(.m_showStartupScreen) as? () -> Void + perform?() + } + open func showLoginScreen() { addInvocation(.m_showLoginScreen) let perform = methodPerformValue(.m_showLoginScreen) as? () -> Void @@ -514,6 +520,12 @@ open class BaseRouterMock: BaseRouter, Mock { perform?() } + open func showDiscoveryScreen(searchQuery: String?, fromStartupScreen: Bool) { + addInvocation(.m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(Parameter.value(`searchQuery`), Parameter.value(`fromStartupScreen`))) + let perform = methodPerformValue(.m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(Parameter.value(`searchQuery`), Parameter.value(`fromStartupScreen`))) as? (String?, Bool) -> Void + perform?(`searchQuery`, `fromStartupScreen`) + } + open func presentAlert(alertTitle: String, alertMessage: String, positiveAction: String, onCloseTapped: @escaping () -> Void, okTapped: @escaping () -> Void, type: AlertViewType) { addInvocation(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`positiveAction`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter.value(`type`))) let perform = methodPerformValue(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`positiveAction`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter.value(`type`))) as? (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void @@ -546,9 +558,11 @@ open class BaseRouterMock: BaseRouter, Mock { case m_dismiss__animated_animated(Parameter) case m_removeLastView__controllers_controllers(Parameter) case m_showMainOrWhatsNewScreen + case m_showStartupScreen case m_showLoginScreen case m_showRegisterScreen case m_showForgotPasswordScreen + case m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(Parameter, Parameter) case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>, Parameter) case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(Parameter, Parameter, Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>, Parameter<() -> Void>) case m_presentView__transitionStyle_transitionStyleview_view(Parameter, Parameter) @@ -580,12 +594,20 @@ open class BaseRouterMock: BaseRouter, Mock { case (.m_showMainOrWhatsNewScreen, .m_showMainOrWhatsNewScreen): return .match + case (.m_showStartupScreen, .m_showStartupScreen): return .match + case (.m_showLoginScreen, .m_showLoginScreen): return .match case (.m_showRegisterScreen, .m_showRegisterScreen): return .match case (.m_showForgotPasswordScreen, .m_showForgotPasswordScreen): return .match + case (.m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(let lhsSearchquery, let lhsFromstartupscreen), .m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(let rhsSearchquery, let rhsFromstartupscreen)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSearchquery, rhs: rhsSearchquery, with: matcher), lhsSearchquery, rhsSearchquery, "searchQuery")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsFromstartupscreen, rhs: rhsFromstartupscreen, with: matcher), lhsFromstartupscreen, rhsFromstartupscreen, "fromStartupScreen")) + return Matcher.ComparisonResult(results) + case (.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(let lhsAlerttitle, let lhsAlertmessage, let lhsPositiveaction, let lhsOnclosetapped, let lhsOktapped, let lhsType), .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(let rhsAlerttitle, let rhsAlertmessage, let rhsPositiveaction, let rhsOnclosetapped, let rhsOktapped, let rhsType)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAlerttitle, rhs: rhsAlerttitle, with: matcher), lhsAlerttitle, rhsAlerttitle, "alertTitle")) @@ -631,9 +653,11 @@ open class BaseRouterMock: BaseRouter, Mock { case let .m_dismiss__animated_animated(p0): return p0.intValue case let .m_removeLastView__controllers_controllers(p0): return p0.intValue case .m_showMainOrWhatsNewScreen: return 0 + case .m_showStartupScreen: return 0 case .m_showLoginScreen: return 0 case .m_showRegisterScreen: return 0 case .m_showForgotPasswordScreen: return 0 + case let .m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(p0, p1): return p0.intValue + p1.intValue case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(p0, p1, p2, p3, p4, p5): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(p0, p1, p2, p3, p4, p5, p6, p7): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue + p6.intValue + p7.intValue case let .m_presentView__transitionStyle_transitionStyleview_view(p0, p1): return p0.intValue + p1.intValue @@ -648,9 +672,11 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_dismiss__animated_animated: return ".dismiss(animated:)" case .m_removeLastView__controllers_controllers: return ".removeLastView(controllers:)" case .m_showMainOrWhatsNewScreen: return ".showMainOrWhatsNewScreen()" + case .m_showStartupScreen: return ".showStartupScreen()" case .m_showLoginScreen: return ".showLoginScreen()" case .m_showRegisterScreen: return ".showRegisterScreen()" case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" + case .m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen: return ".showDiscoveryScreen(searchQuery:fromStartupScreen:)" case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type: return ".presentAlert(alertTitle:alertMessage:positiveAction:onCloseTapped:okTapped:type:)" case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped: return ".presentAlert(alertTitle:alertMessage:nextSectionName:action:image:onCloseTapped:okTapped:nextSectionTapped:)" case .m_presentView__transitionStyle_transitionStyleview_view: return ".presentView(transitionStyle:view:)" @@ -679,9 +705,11 @@ open class BaseRouterMock: BaseRouter, Mock { public static func dismiss(animated: Parameter) -> Verify { return Verify(method: .m_dismiss__animated_animated(`animated`))} public static func removeLastView(controllers: Parameter) -> Verify { return Verify(method: .m_removeLastView__controllers_controllers(`controllers`))} public static func showMainOrWhatsNewScreen() -> Verify { return Verify(method: .m_showMainOrWhatsNewScreen)} + public static func showStartupScreen() -> Verify { return Verify(method: .m_showStartupScreen)} public static func showLoginScreen() -> Verify { return Verify(method: .m_showLoginScreen)} public static func showRegisterScreen() -> Verify { return Verify(method: .m_showRegisterScreen)} public static func showForgotPasswordScreen() -> Verify { return Verify(method: .m_showForgotPasswordScreen)} + public static func showDiscoveryScreen(searchQuery: Parameter, fromStartupScreen: Parameter) -> Verify { return Verify(method: .m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(`searchQuery`, `fromStartupScreen`))} public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`))} public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, nextSectionName: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, nextSectionTapped: Parameter<() -> Void>) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`))} public static func presentView(transitionStyle: Parameter, view: Parameter) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStyleview_view(`transitionStyle`, `view`))} @@ -710,6 +738,9 @@ open class BaseRouterMock: BaseRouter, Mock { public static func showMainOrWhatsNewScreen(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_showMainOrWhatsNewScreen, performs: perform) } + public static func showStartupScreen(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_showStartupScreen, performs: perform) + } public static func showLoginScreen(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_showLoginScreen, performs: perform) } @@ -719,6 +750,9 @@ open class BaseRouterMock: BaseRouter, Mock { public static func showForgotPasswordScreen(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_showForgotPasswordScreen, performs: perform) } + public static func showDiscoveryScreen(searchQuery: Parameter, fromStartupScreen: Parameter, perform: @escaping (String?, Bool) -> Void) -> Perform { + return Perform(method: .m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(`searchQuery`, `fromStartupScreen`), performs: perform) + } public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter, perform: @escaping (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void) -> Perform { return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`), performs: perform) } @@ -2039,6 +2073,12 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { perform?() } + open func showStartupScreen() { + addInvocation(.m_showStartupScreen) + let perform = methodPerformValue(.m_showStartupScreen) as? () -> Void + perform?() + } + open func showLoginScreen() { addInvocation(.m_showLoginScreen) let perform = methodPerformValue(.m_showLoginScreen) as? () -> Void @@ -2057,6 +2097,12 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { perform?() } + open func showDiscoveryScreen(searchQuery: String?, fromStartupScreen: Bool) { + addInvocation(.m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(Parameter.value(`searchQuery`), Parameter.value(`fromStartupScreen`))) + let perform = methodPerformValue(.m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(Parameter.value(`searchQuery`), Parameter.value(`fromStartupScreen`))) as? (String?, Bool) -> Void + perform?(`searchQuery`, `fromStartupScreen`) + } + open func presentAlert(alertTitle: String, alertMessage: String, positiveAction: String, onCloseTapped: @escaping () -> Void, okTapped: @escaping () -> Void, type: AlertViewType) { addInvocation(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`positiveAction`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter.value(`type`))) let perform = methodPerformValue(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`positiveAction`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter.value(`type`))) as? (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void @@ -2095,9 +2141,11 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { case m_dismiss__animated_animated(Parameter) case m_removeLastView__controllers_controllers(Parameter) case m_showMainOrWhatsNewScreen + case m_showStartupScreen case m_showLoginScreen case m_showRegisterScreen case m_showForgotPasswordScreen + case m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(Parameter, Parameter) case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>, Parameter) case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(Parameter, Parameter, Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>, Parameter<() -> Void>) case m_presentView__transitionStyle_transitionStyleview_view(Parameter, Parameter) @@ -2167,12 +2215,20 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { case (.m_showMainOrWhatsNewScreen, .m_showMainOrWhatsNewScreen): return .match + case (.m_showStartupScreen, .m_showStartupScreen): return .match + case (.m_showLoginScreen, .m_showLoginScreen): return .match case (.m_showRegisterScreen, .m_showRegisterScreen): return .match case (.m_showForgotPasswordScreen, .m_showForgotPasswordScreen): return .match + case (.m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(let lhsSearchquery, let lhsFromstartupscreen), .m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(let rhsSearchquery, let rhsFromstartupscreen)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSearchquery, rhs: rhsSearchquery, with: matcher), lhsSearchquery, rhsSearchquery, "searchQuery")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsFromstartupscreen, rhs: rhsFromstartupscreen, with: matcher), lhsFromstartupscreen, rhsFromstartupscreen, "fromStartupScreen")) + return Matcher.ComparisonResult(results) + case (.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(let lhsAlerttitle, let lhsAlertmessage, let lhsPositiveaction, let lhsOnclosetapped, let lhsOktapped, let lhsType), .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(let rhsAlerttitle, let rhsAlertmessage, let rhsPositiveaction, let rhsOnclosetapped, let rhsOktapped, let rhsType)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAlerttitle, rhs: rhsAlerttitle, with: matcher), lhsAlerttitle, rhsAlerttitle, "alertTitle")) @@ -2224,9 +2280,11 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { case let .m_dismiss__animated_animated(p0): return p0.intValue case let .m_removeLastView__controllers_controllers(p0): return p0.intValue case .m_showMainOrWhatsNewScreen: return 0 + case .m_showStartupScreen: return 0 case .m_showLoginScreen: return 0 case .m_showRegisterScreen: return 0 case .m_showForgotPasswordScreen: return 0 + case let .m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(p0, p1): return p0.intValue + p1.intValue case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(p0, p1, p2, p3, p4, p5): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(p0, p1, p2, p3, p4, p5, p6, p7): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue + p6.intValue + p7.intValue case let .m_presentView__transitionStyle_transitionStyleview_view(p0, p1): return p0.intValue + p1.intValue @@ -2247,9 +2305,11 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { case .m_dismiss__animated_animated: return ".dismiss(animated:)" case .m_removeLastView__controllers_controllers: return ".removeLastView(controllers:)" case .m_showMainOrWhatsNewScreen: return ".showMainOrWhatsNewScreen()" + case .m_showStartupScreen: return ".showStartupScreen()" case .m_showLoginScreen: return ".showLoginScreen()" case .m_showRegisterScreen: return ".showRegisterScreen()" case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" + case .m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen: return ".showDiscoveryScreen(searchQuery:fromStartupScreen:)" case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type: return ".presentAlert(alertTitle:alertMessage:positiveAction:onCloseTapped:okTapped:type:)" case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped: return ".presentAlert(alertTitle:alertMessage:nextSectionName:action:image:onCloseTapped:okTapped:nextSectionTapped:)" case .m_presentView__transitionStyle_transitionStyleview_view: return ".presentView(transitionStyle:view:)" @@ -2284,9 +2344,11 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { public static func dismiss(animated: Parameter) -> Verify { return Verify(method: .m_dismiss__animated_animated(`animated`))} public static func removeLastView(controllers: Parameter) -> Verify { return Verify(method: .m_removeLastView__controllers_controllers(`controllers`))} public static func showMainOrWhatsNewScreen() -> Verify { return Verify(method: .m_showMainOrWhatsNewScreen)} + public static func showStartupScreen() -> Verify { return Verify(method: .m_showStartupScreen)} public static func showLoginScreen() -> Verify { return Verify(method: .m_showLoginScreen)} public static func showRegisterScreen() -> Verify { return Verify(method: .m_showRegisterScreen)} public static func showForgotPasswordScreen() -> Verify { return Verify(method: .m_showForgotPasswordScreen)} + public static func showDiscoveryScreen(searchQuery: Parameter, fromStartupScreen: Parameter) -> Verify { return Verify(method: .m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(`searchQuery`, `fromStartupScreen`))} public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`))} public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, nextSectionName: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, nextSectionTapped: Parameter<() -> Void>) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`))} public static func presentView(transitionStyle: Parameter, view: Parameter) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStyleview_view(`transitionStyle`, `view`))} @@ -2333,6 +2395,9 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { public static func showMainOrWhatsNewScreen(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_showMainOrWhatsNewScreen, performs: perform) } + public static func showStartupScreen(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_showStartupScreen, performs: perform) + } public static func showLoginScreen(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_showLoginScreen, performs: perform) } @@ -2342,6 +2407,9 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { public static func showForgotPasswordScreen(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_showForgotPasswordScreen, performs: perform) } + public static func showDiscoveryScreen(searchQuery: Parameter, fromStartupScreen: Parameter, perform: @escaping (String?, Bool) -> Void) -> Perform { + return Perform(method: .m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(`searchQuery`, `fromStartupScreen`), performs: perform) + } public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter, perform: @escaping (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void) -> Perform { return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`), performs: perform) } diff --git a/OpenEdX/DI/ScreenAssembly.swift b/OpenEdX/DI/ScreenAssembly.swift index c50f7e0f0..b87fa2956 100644 --- a/OpenEdX/DI/ScreenAssembly.swift +++ b/OpenEdX/DI/ScreenAssembly.swift @@ -41,6 +41,14 @@ class ScreenAssembly: Assembly { profileInteractor: r.resolve(ProfileInteractorProtocol.self)! ) } + // MARK: Startup screen + container.register(StartupViewModel.self) { r in + StartupViewModel( + interactor: r.resolve(AuthInteractorProtocol.self)!, + router: r.resolve(AuthorizationRouter.self)!, + analytics: r.resolve(AuthorizationAnalytics.self)! + ) + } // MARK: SignIn container.register(SignInViewModel.self) { r in diff --git a/OpenEdX/RouteController.swift b/OpenEdX/RouteController.swift index 7478fac65..d86e7bb6d 100644 --- a/OpenEdX/RouteController.swift +++ b/OpenEdX/RouteController.swift @@ -36,17 +36,24 @@ class RouteController: UIViewController { } } else { DispatchQueue.main.async { - self.showAuthorization() + self.showStartupScreen() } } } - private func showAuthorization() { - let controller = UIHostingController( - rootView: SignInView(viewModel: diContainer.resolve(SignInViewModel.self)!) - ) - navigation.viewControllers = [controller] - present(navigation, animated: false) + private func showStartupScreen() { + if let config = Container.shared.resolve(ConfigProtocol.self), config.features.startupScreenEnabled { + let controller = UIHostingController( + rootView: StartupView(viewModel: diContainer.resolve(StartupViewModel.self)!)) + navigation.viewControllers = [controller] + present(navigation, animated: false) + } else { + let controller = UIHostingController( + rootView: SignInView(viewModel: diContainer.resolve(SignInViewModel.self)!) + ) + navigation.viewControllers = [controller] + present(navigation, animated: false) + } } private func showMainOrWhatsNewScreen() { diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index 389531979..6746ec652 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -85,7 +85,19 @@ public class Router: AuthorizationRouter, public func showLoginScreen() { let view = SignInView(viewModel: Container.shared.resolve(SignInViewModel.self)!) let controller = UIHostingController(rootView: view) - navigationController.setViewControllers([controller], animated: false) + navigationController.pushViewController(controller, animated: true) + } + + public func showStartupScreen() { + if let config = Container.shared.resolve(ConfigProtocol.self), config.features.startupScreenEnabled { + let view = StartupView(viewModel: Container.shared.resolve(StartupViewModel.self)!) + let controller = UIHostingController(rootView: view) + navigationController.setViewControllers([controller], animated: true) + } else { + let view = SignInView(viewModel: Container.shared.resolve(SignInViewModel.self)!) + let controller = UIHostingController(rootView: view) + navigationController.setViewControllers([controller], animated: false) + } } public func presentAppReview() { @@ -175,14 +187,25 @@ public class Router: AuthorizationRouter, navigationController.pushViewController(controller, animated: true) } - public func showDiscoverySearch() { + public func showDiscoverySearch(searchQuery: String? = nil) { let viewModel = Container.shared.resolve(SearchViewModel.self)! - let view = SearchView(viewModel: viewModel) + let view = SearchView(viewModel: viewModel, searchQuery: searchQuery) let controller = UIHostingController(rootView: view) navigationController.pushFade(viewController: controller) } + public func showDiscoveryScreen(searchQuery: String? = nil, fromStartupScreen: Bool = false) { + let view = DiscoveryView( + viewModel: Container.shared.resolve(DiscoveryViewModel.self)!, + router: Container.shared.resolve(DiscoveryRouter.self)!, + searchQuery: searchQuery, + fromStartupScreen: fromStartupScreen + ) + let controller = UIHostingController(rootView: view) + navigationController.pushViewController(controller, animated: true) + } + public func showDiscussionsSearch(courseID: String) { let viewModel = Container.shared.resolve(DiscussionSearchTopicsViewModel.self, argument: courseID)! let view = DiscussionSearchTopicsView(viewModel: viewModel) diff --git a/OpenEdX/View/MainScreenView.swift b/OpenEdX/View/MainScreenView.swift index 2d19c0c8f..db915cdb6 100644 --- a/OpenEdX/View/MainScreenView.swift +++ b/OpenEdX/View/MainScreenView.swift @@ -41,7 +41,8 @@ struct MainScreenView: View { var body: some View { TabView(selection: $selection) { ZStack { - DiscoveryView(viewModel: Container.shared.resolve(DiscoveryViewModel.self)!) + DiscoveryView(viewModel: Container.shared.resolve(DiscoveryViewModel.self)!, + router: Container.shared.resolve(DiscoveryRouter.self)!) if updateAvaliable { UpdateNotificationView(config: viewModel.config) } diff --git a/Profile/Profile/Presentation/Profile/ProfileViewModel.swift b/Profile/Profile/Presentation/Profile/ProfileViewModel.swift index a7647e7df..c5879e0aa 100644 --- a/Profile/Profile/Presentation/Profile/ProfileViewModel.swift +++ b/Profile/Profile/Presentation/Profile/ProfileViewModel.swift @@ -119,7 +119,7 @@ public class ProfileViewModel: ObservableObject { @MainActor func logOut() async { try? await interactor.logOut() - router.showLoginScreen() + router.showStartupScreen() analytics.userLogout(force: false) } diff --git a/Profile/ProfileTests/Presentation/Profile/ProfileViewModelTests.swift b/Profile/ProfileTests/Presentation/Profile/ProfileViewModelTests.swift index a66d1929a..c6888fcaf 100644 --- a/Profile/ProfileTests/Presentation/Profile/ProfileViewModelTests.swift +++ b/Profile/ProfileTests/Presentation/Profile/ProfileViewModelTests.swift @@ -241,7 +241,7 @@ final class ProfileViewModelTests: XCTestCase { await viewModel.logOut() - Verify(router, .showLoginScreen()) + Verify(router, .showStartupScreen()) XCTAssertFalse(viewModel.showError) } diff --git a/Profile/ProfileTests/ProfileMock.generated.swift b/Profile/ProfileTests/ProfileMock.generated.swift index 1321a7d0f..0ebcec11c 100644 --- a/Profile/ProfileTests/ProfileMock.generated.swift +++ b/Profile/ProfileTests/ProfileMock.generated.swift @@ -496,6 +496,12 @@ open class BaseRouterMock: BaseRouter, Mock { perform?() } + open func showStartupScreen() { + addInvocation(.m_showStartupScreen) + let perform = methodPerformValue(.m_showStartupScreen) as? () -> Void + perform?() + } + open func showLoginScreen() { addInvocation(.m_showLoginScreen) let perform = methodPerformValue(.m_showLoginScreen) as? () -> Void @@ -514,6 +520,12 @@ open class BaseRouterMock: BaseRouter, Mock { perform?() } + open func showDiscoveryScreen(searchQuery: String?, fromStartupScreen: Bool) { + addInvocation(.m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(Parameter.value(`searchQuery`), Parameter.value(`fromStartupScreen`))) + let perform = methodPerformValue(.m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(Parameter.value(`searchQuery`), Parameter.value(`fromStartupScreen`))) as? (String?, Bool) -> Void + perform?(`searchQuery`, `fromStartupScreen`) + } + open func presentAlert(alertTitle: String, alertMessage: String, positiveAction: String, onCloseTapped: @escaping () -> Void, okTapped: @escaping () -> Void, type: AlertViewType) { addInvocation(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`positiveAction`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter.value(`type`))) let perform = methodPerformValue(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`positiveAction`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter.value(`type`))) as? (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void @@ -546,9 +558,11 @@ open class BaseRouterMock: BaseRouter, Mock { case m_dismiss__animated_animated(Parameter) case m_removeLastView__controllers_controllers(Parameter) case m_showMainOrWhatsNewScreen + case m_showStartupScreen case m_showLoginScreen case m_showRegisterScreen case m_showForgotPasswordScreen + case m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(Parameter, Parameter) case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>, Parameter) case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(Parameter, Parameter, Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>, Parameter<() -> Void>) case m_presentView__transitionStyle_transitionStyleview_view(Parameter, Parameter) @@ -580,12 +594,20 @@ open class BaseRouterMock: BaseRouter, Mock { case (.m_showMainOrWhatsNewScreen, .m_showMainOrWhatsNewScreen): return .match + case (.m_showStartupScreen, .m_showStartupScreen): return .match + case (.m_showLoginScreen, .m_showLoginScreen): return .match case (.m_showRegisterScreen, .m_showRegisterScreen): return .match case (.m_showForgotPasswordScreen, .m_showForgotPasswordScreen): return .match + case (.m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(let lhsSearchquery, let lhsFromstartupscreen), .m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(let rhsSearchquery, let rhsFromstartupscreen)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSearchquery, rhs: rhsSearchquery, with: matcher), lhsSearchquery, rhsSearchquery, "searchQuery")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsFromstartupscreen, rhs: rhsFromstartupscreen, with: matcher), lhsFromstartupscreen, rhsFromstartupscreen, "fromStartupScreen")) + return Matcher.ComparisonResult(results) + case (.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(let lhsAlerttitle, let lhsAlertmessage, let lhsPositiveaction, let lhsOnclosetapped, let lhsOktapped, let lhsType), .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(let rhsAlerttitle, let rhsAlertmessage, let rhsPositiveaction, let rhsOnclosetapped, let rhsOktapped, let rhsType)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAlerttitle, rhs: rhsAlerttitle, with: matcher), lhsAlerttitle, rhsAlerttitle, "alertTitle")) @@ -631,9 +653,11 @@ open class BaseRouterMock: BaseRouter, Mock { case let .m_dismiss__animated_animated(p0): return p0.intValue case let .m_removeLastView__controllers_controllers(p0): return p0.intValue case .m_showMainOrWhatsNewScreen: return 0 + case .m_showStartupScreen: return 0 case .m_showLoginScreen: return 0 case .m_showRegisterScreen: return 0 case .m_showForgotPasswordScreen: return 0 + case let .m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(p0, p1): return p0.intValue + p1.intValue case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(p0, p1, p2, p3, p4, p5): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(p0, p1, p2, p3, p4, p5, p6, p7): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue + p6.intValue + p7.intValue case let .m_presentView__transitionStyle_transitionStyleview_view(p0, p1): return p0.intValue + p1.intValue @@ -648,9 +672,11 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_dismiss__animated_animated: return ".dismiss(animated:)" case .m_removeLastView__controllers_controllers: return ".removeLastView(controllers:)" case .m_showMainOrWhatsNewScreen: return ".showMainOrWhatsNewScreen()" + case .m_showStartupScreen: return ".showStartupScreen()" case .m_showLoginScreen: return ".showLoginScreen()" case .m_showRegisterScreen: return ".showRegisterScreen()" case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" + case .m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen: return ".showDiscoveryScreen(searchQuery:fromStartupScreen:)" case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type: return ".presentAlert(alertTitle:alertMessage:positiveAction:onCloseTapped:okTapped:type:)" case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped: return ".presentAlert(alertTitle:alertMessage:nextSectionName:action:image:onCloseTapped:okTapped:nextSectionTapped:)" case .m_presentView__transitionStyle_transitionStyleview_view: return ".presentView(transitionStyle:view:)" @@ -679,9 +705,11 @@ open class BaseRouterMock: BaseRouter, Mock { public static func dismiss(animated: Parameter) -> Verify { return Verify(method: .m_dismiss__animated_animated(`animated`))} public static func removeLastView(controllers: Parameter) -> Verify { return Verify(method: .m_removeLastView__controllers_controllers(`controllers`))} public static func showMainOrWhatsNewScreen() -> Verify { return Verify(method: .m_showMainOrWhatsNewScreen)} + public static func showStartupScreen() -> Verify { return Verify(method: .m_showStartupScreen)} public static func showLoginScreen() -> Verify { return Verify(method: .m_showLoginScreen)} public static func showRegisterScreen() -> Verify { return Verify(method: .m_showRegisterScreen)} public static func showForgotPasswordScreen() -> Verify { return Verify(method: .m_showForgotPasswordScreen)} + public static func showDiscoveryScreen(searchQuery: Parameter, fromStartupScreen: Parameter) -> Verify { return Verify(method: .m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(`searchQuery`, `fromStartupScreen`))} public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`))} public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, nextSectionName: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, nextSectionTapped: Parameter<() -> Void>) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`))} public static func presentView(transitionStyle: Parameter, view: Parameter) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStyleview_view(`transitionStyle`, `view`))} @@ -710,6 +738,9 @@ open class BaseRouterMock: BaseRouter, Mock { public static func showMainOrWhatsNewScreen(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_showMainOrWhatsNewScreen, performs: perform) } + public static func showStartupScreen(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_showStartupScreen, performs: perform) + } public static func showLoginScreen(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_showLoginScreen, performs: perform) } @@ -719,6 +750,9 @@ open class BaseRouterMock: BaseRouter, Mock { public static func showForgotPasswordScreen(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_showForgotPasswordScreen, performs: perform) } + public static func showDiscoveryScreen(searchQuery: Parameter, fromStartupScreen: Parameter, perform: @escaping (String?, Bool) -> Void) -> Perform { + return Perform(method: .m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(`searchQuery`, `fromStartupScreen`), performs: perform) + } public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter, perform: @escaping (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void) -> Perform { return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`), performs: perform) } @@ -2370,6 +2404,12 @@ open class ProfileRouterMock: ProfileRouter, Mock { perform?() } + open func showStartupScreen() { + addInvocation(.m_showStartupScreen) + let perform = methodPerformValue(.m_showStartupScreen) as? () -> Void + perform?() + } + open func showLoginScreen() { addInvocation(.m_showLoginScreen) let perform = methodPerformValue(.m_showLoginScreen) as? () -> Void @@ -2388,6 +2428,12 @@ open class ProfileRouterMock: ProfileRouter, Mock { perform?() } + open func showDiscoveryScreen(searchQuery: String?, fromStartupScreen: Bool) { + addInvocation(.m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(Parameter.value(`searchQuery`), Parameter.value(`fromStartupScreen`))) + let perform = methodPerformValue(.m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(Parameter.value(`searchQuery`), Parameter.value(`fromStartupScreen`))) as? (String?, Bool) -> Void + perform?(`searchQuery`, `fromStartupScreen`) + } + open func presentAlert(alertTitle: String, alertMessage: String, positiveAction: String, onCloseTapped: @escaping () -> Void, okTapped: @escaping () -> Void, type: AlertViewType) { addInvocation(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`positiveAction`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter.value(`type`))) let perform = methodPerformValue(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`positiveAction`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter.value(`type`))) as? (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void @@ -2424,9 +2470,11 @@ open class ProfileRouterMock: ProfileRouter, Mock { case m_dismiss__animated_animated(Parameter) case m_removeLastView__controllers_controllers(Parameter) case m_showMainOrWhatsNewScreen + case m_showStartupScreen case m_showLoginScreen case m_showRegisterScreen case m_showForgotPasswordScreen + case m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(Parameter, Parameter) case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>, Parameter) case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(Parameter, Parameter, Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>, Parameter<() -> Void>) case m_presentView__transitionStyle_transitionStyleview_view(Parameter, Parameter) @@ -2474,12 +2522,20 @@ open class ProfileRouterMock: ProfileRouter, Mock { case (.m_showMainOrWhatsNewScreen, .m_showMainOrWhatsNewScreen): return .match + case (.m_showStartupScreen, .m_showStartupScreen): return .match + case (.m_showLoginScreen, .m_showLoginScreen): return .match case (.m_showRegisterScreen, .m_showRegisterScreen): return .match case (.m_showForgotPasswordScreen, .m_showForgotPasswordScreen): return .match + case (.m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(let lhsSearchquery, let lhsFromstartupscreen), .m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(let rhsSearchquery, let rhsFromstartupscreen)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSearchquery, rhs: rhsSearchquery, with: matcher), lhsSearchquery, rhsSearchquery, "searchQuery")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsFromstartupscreen, rhs: rhsFromstartupscreen, with: matcher), lhsFromstartupscreen, rhsFromstartupscreen, "fromStartupScreen")) + return Matcher.ComparisonResult(results) + case (.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(let lhsAlerttitle, let lhsAlertmessage, let lhsPositiveaction, let lhsOnclosetapped, let lhsOktapped, let lhsType), .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(let rhsAlerttitle, let rhsAlertmessage, let rhsPositiveaction, let rhsOnclosetapped, let rhsOktapped, let rhsType)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAlerttitle, rhs: rhsAlerttitle, with: matcher), lhsAlerttitle, rhsAlerttitle, "alertTitle")) @@ -2529,9 +2585,11 @@ open class ProfileRouterMock: ProfileRouter, Mock { case let .m_dismiss__animated_animated(p0): return p0.intValue case let .m_removeLastView__controllers_controllers(p0): return p0.intValue case .m_showMainOrWhatsNewScreen: return 0 + case .m_showStartupScreen: return 0 case .m_showLoginScreen: return 0 case .m_showRegisterScreen: return 0 case .m_showForgotPasswordScreen: return 0 + case let .m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(p0, p1): return p0.intValue + p1.intValue case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(p0, p1, p2, p3, p4, p5): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(p0, p1, p2, p3, p4, p5, p6, p7): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue + p6.intValue + p7.intValue case let .m_presentView__transitionStyle_transitionStyleview_view(p0, p1): return p0.intValue + p1.intValue @@ -2550,9 +2608,11 @@ open class ProfileRouterMock: ProfileRouter, Mock { case .m_dismiss__animated_animated: return ".dismiss(animated:)" case .m_removeLastView__controllers_controllers: return ".removeLastView(controllers:)" case .m_showMainOrWhatsNewScreen: return ".showMainOrWhatsNewScreen()" + case .m_showStartupScreen: return ".showStartupScreen()" case .m_showLoginScreen: return ".showLoginScreen()" case .m_showRegisterScreen: return ".showRegisterScreen()" case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" + case .m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen: return ".showDiscoveryScreen(searchQuery:fromStartupScreen:)" case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type: return ".presentAlert(alertTitle:alertMessage:positiveAction:onCloseTapped:okTapped:type:)" case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped: return ".presentAlert(alertTitle:alertMessage:nextSectionName:action:image:onCloseTapped:okTapped:nextSectionTapped:)" case .m_presentView__transitionStyle_transitionStyleview_view: return ".presentView(transitionStyle:view:)" @@ -2585,9 +2645,11 @@ open class ProfileRouterMock: ProfileRouter, Mock { public static func dismiss(animated: Parameter) -> Verify { return Verify(method: .m_dismiss__animated_animated(`animated`))} public static func removeLastView(controllers: Parameter) -> Verify { return Verify(method: .m_removeLastView__controllers_controllers(`controllers`))} public static func showMainOrWhatsNewScreen() -> Verify { return Verify(method: .m_showMainOrWhatsNewScreen)} + public static func showStartupScreen() -> Verify { return Verify(method: .m_showStartupScreen)} public static func showLoginScreen() -> Verify { return Verify(method: .m_showLoginScreen)} public static func showRegisterScreen() -> Verify { return Verify(method: .m_showRegisterScreen)} public static func showForgotPasswordScreen() -> Verify { return Verify(method: .m_showForgotPasswordScreen)} + public static func showDiscoveryScreen(searchQuery: Parameter, fromStartupScreen: Parameter) -> Verify { return Verify(method: .m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(`searchQuery`, `fromStartupScreen`))} public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`))} public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, nextSectionName: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, nextSectionTapped: Parameter<() -> Void>) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`))} public static func presentView(transitionStyle: Parameter, view: Parameter) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStyleview_view(`transitionStyle`, `view`))} @@ -2628,6 +2690,9 @@ open class ProfileRouterMock: ProfileRouter, Mock { public static func showMainOrWhatsNewScreen(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_showMainOrWhatsNewScreen, performs: perform) } + public static func showStartupScreen(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_showStartupScreen, performs: perform) + } public static func showLoginScreen(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_showLoginScreen, performs: perform) } @@ -2637,6 +2702,9 @@ open class ProfileRouterMock: ProfileRouter, Mock { public static func showForgotPasswordScreen(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_showForgotPasswordScreen, performs: perform) } + public static func showDiscoveryScreen(searchQuery: Parameter, fromStartupScreen: Parameter, perform: @escaping (String?, Bool) -> Void) -> Perform { + return Perform(method: .m_showDiscoveryScreen__searchQuery_searchQueryfromStartupScreen_fromStartupScreen(`searchQuery`, `fromStartupScreen`), performs: perform) + } public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter, perform: @escaping (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void) -> Perform { return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`), performs: perform) } diff --git a/ci_scripts/ci_prepare_env.sh b/ci_scripts/ci_prepare_env.sh index 477bd65a3..a340aabb9 100644 --- a/ci_scripts/ci_prepare_env.sh +++ b/ci_scripts/ci_prepare_env.sh @@ -35,7 +35,6 @@ install_xcode_cloud_brew_dependencies () { setup_github_actions_environment() { # brew update && brew install xcodegen git-lfs imagemagick - brew update && brew install xcodegen git-lfs bundle config path vendor/bundle From 153e19550376eb8502a244471f31097ca936139b Mon Sep 17 00:00:00 2001 From: Saeed Bashir Date: Mon, 4 Dec 2023 19:06:02 +0500 Subject: [PATCH 025/158] feat: Open edX app theming capability improvements (Option 2) (#182) * feat: openedX app theming capability improvements * fix: mixup after rebasing with develop * refactor: code refactor * chore: Apply accentColor to tabbar items for theme configureable * fix: fixup after merging the develop branch * feat: openedX app theming capability improvements * chore: remove configureable assets and font parser from Core * chore: address review feedback(linked the Theme framework with Core framework) * fix: fix broken code after conflicts resolve * fix: fix the broken archive process * refactor: remove unused old Environment file --- .../Presentation/Login/SignInView.swift | 7 +- .../Registration/SignUpView.swift | 7 +- .../Reset Password/ResetPasswordView.swift | 9 +- .../Startup/LogistrationBottomView.swift | 3 + .../Presentation/Startup/StartupView.swift | 3 +- Core/Core.xcodeproj/project.pbxproj | 88 +- .../checkEmail.imageset/Contents.json | 0 .../{Auth => }/checkEmail.imageset/_1-2.svg | 0 .../{Auth => }/checkEmail.imageset/_1.svg | 0 Core/Core/Configuration/CSSInjector.swift | 3 +- Core/Core/Extensions/CGColorExtension.swift | 13 +- .../Extensions/UIApplicationExtension.swift | 7 +- Core/Core/Extensions/ViewExtension.swift | 1 + Core/Core/SwiftGen/Assets.swift | 92 +- Core/Core/Theme.swift | 150 -- Core/Core/View/Base/AlertView.swift | 3 +- .../View/Base/AppReview/AppReviewView.swift | 1 + .../AppReview/Elements/AppReviewButton.swift | 1 + .../Elements/SelectMailClientView.swift | 1 + .../AppReview/Elements/StarRatingView.swift | 1 + Core/Core/View/Base/CourseButton.swift | 1 + Core/Core/View/Base/CourseCellView.swift | 1 + Core/Core/View/Base/DownloadView.swift | 1 + .../View/Base/FlexibleKeyboardInputView.swift | 3 +- Core/Core/View/Base/NavigationBar.swift | 1 + Core/Core/View/Base/OfflineSnackBarView.swift | 1 + Core/Core/View/Base/PickerMenu.swift | 1 + Core/Core/View/Base/PickerView.swift | 1 + Core/Core/View/Base/ProgressBar.swift | 1 + .../View/Base/RegistrationTextField.swift | 1 + Core/Core/View/Base/SnackBarView.swift | 3 +- Core/Core/View/Base/StyledButton.swift | 3 +- Core/Core/View/Base/UnitButtonView.swift | 1 + Core/Core/View/Base/WebBrowser.swift | 3 +- Core/Core/View/Base/WebUnitView.swift | 1 + Core/Core/View/Base/WebView.swift | 3 +- .../Container/CourseContainerView.swift | 2 + .../Presentation/Dates/CourseDatesView.swift | 1 + .../Details/CourseDetailsView.swift | 1 + .../Handouts/HandoutsUpdatesDetailView.swift | 1 + .../Presentation/Handouts/HandoutsView.swift | 1 + .../Outline/ContinueWithView.swift | 1 + .../Outline/CourseOutlineView.swift | 157 +- .../Outline/CourseVerticalView.swift | 1 + .../Presentation/Unit/CourseUnitView.swift | 5 +- .../Unit/Subviews/LessonProgressView.swift | 1 + .../Unit/Subviews/UnknownView.swift | 1 + .../Presentation/Unit/Subviews/WebView.swift | 1 + .../Unit/Subviews/YouTubeView.swift | 1 + .../Presentation/Video/SubtittlesView.swift | 1 + .../Presentation/DashboardView.swift | 1 + .../Presentation/DiscoveryView.swift | 1 + .../Discovery/Presentation/SearchView.swift | 1 + .../UpdateViews/UpdateNotificationView.swift | 5 +- .../UpdateViews/UpdateRecommendedView.swift | 1 + .../UpdateViews/UpdateRequiredView.swift | 1 + .../Presentation/CheckBoxView.swift | 1 + .../Comments/Base/CommentCell.swift | 1 + .../Comments/Base/ParentCommentView.swift | 1 + .../Comments/Responses/ResponsesView.swift | 1 + .../Comments/Thread/ThreadView.swift | 3 +- .../CreateNewThread/CreateNewThreadView.swift | 3 +- .../DiscussionSearchTopicsView.swift | 1 + .../DiscussionTopicsView.swift | 1 + .../Presentation/Posts/PostsView.swift | 3 +- OpenEdX.xcodeproj/project.pbxproj | 6 + OpenEdX.xcworkspace/contents.xcworkspacedata | 3 + OpenEdX/AppDelegate.swift | 1 + .../SplachBackground.colorset/Contents.json | 38 - OpenEdX/View/MainScreenView.swift | 2 + Podfile | 9 + Podfile.lock | 4 +- .../DeleteAccount/DeleteAccountView.swift | 1 + .../EditProfile/EditProfileView.swift | 3 +- .../EditProfile/ProfileBottomSheet.swift | 3 +- .../Presentation/Profile/ProfileView.swift | 1 + .../Profile/UserProfile/UserProfileView.swift | 1 + .../Presentation/Settings/SettingsView.swift | 1 + .../Settings/VideoQualityView.swift | 1 + Theme.xctestplan | 25 + Theme/Info.plist | 14 + .../contents.xcworkspacedata | 40 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/swiftpm/Package.resolved | 14 + Theme/Theme.xcodeproj/project.pbxproj | 1420 +++++++++++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/swiftpm/Package.resolved | 14 + .../xcshareddata/xcschemes/Theme.xcscheme | 71 + .../Theme}/Assets.xcassets/Auth/Contents.json | 0 .../authBackground.imageset/Contents.json | 0 .../authBackground.imageset/Rectangle-2.png | Bin .../authBackground.imageset/Rectangle.png | Bin .../Colors/AccentColor.colorset/Contents.json | 0 .../Colors/Alert.colorset/Contents.json | 0 .../AvatarStroke.colorset/Contents.json | 0 .../Colors/Background.colorset/Contents.json | 0 .../BackgroundStroke.colorset/Contents.json | 0 .../CardViewBackground.colorset/Contents.json | 0 .../CardViewStroke.colorset/Contents.json | 0 .../Colors/CardView/Contents.json | 0 .../Contents.json | 0 .../Contents.json | 0 .../Assets.xcassets/Colors/Contents.json | 0 .../Colors/ShadowColor.colorset/Contents.json | 0 .../Colors/Snackbar/Contents.json | 0 .../SnackbarErrorColor.colorset/Contents.json | 0 .../Contents.json | 0 .../SnackbarInfoAlert.colorset/Contents.json | 0 .../Colors/StyledButton/Contents.json | 0 .../Contents.json | 0 .../StyledButtonText.colorset/Contents.json | 0 .../Colors/TextColor/Contents.json | 0 .../TextPrimary.colorset/Contents.json | 0 .../TextSecondary.colorset/Contents.json | 12 +- .../Colors/TextInput/Contents.json | 0 .../Contents.json | 0 .../TextInputStroke.colorset/Contents.json | 0 .../Contents.json | 0 .../Contents.json | 0 .../Colors/warning.colorset/Contents.json | 0 .../Colors/white.colorset}/Contents.json | 12 +- Theme/Theme/Assets.xcassets/Contents.json | 6 + .../appLogo.imageset/Contents.json | 0 .../appLogo.imageset/Frame 4 1.svg | 0 Theme/Theme/Fonts/FontParser.swift | 61 + Theme/Theme/Fonts/fonts.json | 6 + .../Theme/Fonts/fonts_file.ttf | Bin .../Theme/Helpers}/RoundedCorners.swift | 0 Theme/Theme/SwiftGen/ThemeAssets.swift | 212 +++ Theme/Theme/Theme.swift | 155 ++ Theme/ThemeTests/FontParserTests.swift | 32 + Theme/swiftgen.yml | 9 + .../Presentation/Elements/PageControl.swift | 1 + .../Elements/WhatsNewNavigationButton.swift | 7 +- .../WhatsNew/Presentation/WhatsNewView.swift | 1 + 136 files changed, 2352 insertions(+), 473 deletions(-) rename Core/Core/Assets.xcassets/{Auth => }/checkEmail.imageset/Contents.json (100%) rename Core/Core/Assets.xcassets/{Auth => }/checkEmail.imageset/_1-2.svg (100%) rename Core/Core/Assets.xcassets/{Auth => }/checkEmail.imageset/_1.svg (100%) delete mode 100644 Core/Core/Theme.swift delete mode 100644 OpenEdX/Assets.xcassets/SplachBackground.colorset/Contents.json create mode 100644 Theme.xctestplan create mode 100644 Theme/Info.plist create mode 100644 Theme/Theme.xcodeproj.xcworkspace/contents.xcworkspacedata create mode 100644 Theme/Theme.xcodeproj.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 Theme/Theme.xcodeproj.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 Theme/Theme.xcodeproj/project.pbxproj create mode 100644 Theme/Theme.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 Theme/Theme.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 Theme/Theme.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 Theme/Theme.xcodeproj/xcshareddata/xcschemes/Theme.xcscheme rename {Core/Core => Theme/Theme}/Assets.xcassets/Auth/Contents.json (100%) rename {Core/Core => Theme/Theme}/Assets.xcassets/Auth/authBackground.imageset/Contents.json (100%) rename {Core/Core => Theme/Theme}/Assets.xcassets/Auth/authBackground.imageset/Rectangle-2.png (100%) rename {Core/Core => Theme/Theme}/Assets.xcassets/Auth/authBackground.imageset/Rectangle.png (100%) rename {Core/Core => Theme/Theme}/Assets.xcassets/Colors/AccentColor.colorset/Contents.json (100%) rename {Core/Core => Theme/Theme}/Assets.xcassets/Colors/Alert.colorset/Contents.json (100%) rename {Core/Core => Theme/Theme}/Assets.xcassets/Colors/AvatarStroke.colorset/Contents.json (100%) rename {Core/Core => Theme/Theme}/Assets.xcassets/Colors/Background.colorset/Contents.json (100%) rename {Core/Core => Theme/Theme}/Assets.xcassets/Colors/BackgroundStroke.colorset/Contents.json (100%) rename {Core/Core => Theme/Theme}/Assets.xcassets/Colors/CardView/CardViewBackground.colorset/Contents.json (100%) rename {Core/Core => Theme/Theme}/Assets.xcassets/Colors/CardView/CardViewStroke.colorset/Contents.json (100%) rename {Core/Core => Theme/Theme}/Assets.xcassets/Colors/CardView/Contents.json (100%) rename {Core/Core => Theme/Theme}/Assets.xcassets/Colors/CertificateForeground.colorset/Contents.json (100%) rename {Core/Core => Theme/Theme}/Assets.xcassets/Colors/CommentCellBackground.colorset/Contents.json (100%) rename {Core/Core => Theme/Theme}/Assets.xcassets/Colors/Contents.json (100%) rename {Core/Core => Theme/Theme}/Assets.xcassets/Colors/ShadowColor.colorset/Contents.json (100%) rename {Core/Core => Theme/Theme}/Assets.xcassets/Colors/Snackbar/Contents.json (100%) rename {Core/Core => Theme/Theme}/Assets.xcassets/Colors/Snackbar/SnackbarErrorColor.colorset/Contents.json (100%) rename {Core/Core => Theme/Theme}/Assets.xcassets/Colors/Snackbar/SnackbarErrorTextColor.colorset/Contents.json (100%) rename {Core/Core => Theme/Theme}/Assets.xcassets/Colors/Snackbar/SnackbarInfoAlert.colorset/Contents.json (100%) rename {Core/Core => Theme/Theme}/Assets.xcassets/Colors/StyledButton/Contents.json (100%) rename {Core/Core => Theme/Theme}/Assets.xcassets/Colors/StyledButton/StyledButtonBackground.colorset/Contents.json (100%) rename {Core/Core => Theme/Theme}/Assets.xcassets/Colors/StyledButton/StyledButtonText.colorset/Contents.json (100%) rename {Core/Core => Theme/Theme}/Assets.xcassets/Colors/TextColor/Contents.json (100%) rename {Core/Core => Theme/Theme}/Assets.xcassets/Colors/TextColor/TextPrimary.colorset/Contents.json (100%) rename {Core/Core => Theme/Theme}/Assets.xcassets/Colors/TextColor/TextSecondary.colorset/Contents.json (76%) rename {Core/Core => Theme/Theme}/Assets.xcassets/Colors/TextInput/Contents.json (100%) rename {Core/Core => Theme/Theme}/Assets.xcassets/Colors/TextInput/TextInputBackground.colorset/Contents.json (100%) rename {Core/Core => Theme/Theme}/Assets.xcassets/Colors/TextInput/TextInputStroke.colorset/Contents.json (100%) rename {Core/Core => Theme/Theme}/Assets.xcassets/Colors/TextInput/TextInputUnfocusedBackground.colorset/Contents.json (100%) rename {Core/Core => Theme/Theme}/Assets.xcassets/Colors/TextInput/TextInputUnfocusedStroke.colorset/Contents.json (100%) rename {Core/Core => Theme/Theme}/Assets.xcassets/Colors/warning.colorset/Contents.json (100%) rename {OpenEdX/Assets.xcassets/AccentColor.colorset => Theme/Theme/Assets.xcassets/Colors/white.colorset}/Contents.json (76%) create mode 100644 Theme/Theme/Assets.xcassets/Contents.json rename {Core/Core => Theme/Theme}/Assets.xcassets/appLogo.imageset/Contents.json (100%) rename {Core/Core => Theme/Theme}/Assets.xcassets/appLogo.imageset/Frame 4 1.svg (100%) create mode 100644 Theme/Theme/Fonts/FontParser.swift create mode 100644 Theme/Theme/Fonts/fonts.json rename Core/Core/Fonts/SF-Pro.ttf => Theme/Theme/Fonts/fonts_file.ttf (100%) rename {Core/Core/View/Base => Theme/Theme/Helpers}/RoundedCorners.swift (100%) create mode 100644 Theme/Theme/SwiftGen/ThemeAssets.swift create mode 100644 Theme/Theme/Theme.swift create mode 100644 Theme/ThemeTests/FontParserTests.swift create mode 100644 Theme/swiftgen.yml diff --git a/Authorization/Authorization/Presentation/Login/SignInView.swift b/Authorization/Authorization/Presentation/Login/SignInView.swift index 17be98802..f7dcf262f 100644 --- a/Authorization/Authorization/Presentation/Login/SignInView.swift +++ b/Authorization/Authorization/Presentation/Login/SignInView.swift @@ -7,6 +7,7 @@ import SwiftUI import Core +import Theme import Swinject public struct SignInView: View { @@ -26,7 +27,7 @@ public struct SignInView: View { public var body: some View { ZStack(alignment: .top) { VStack { - CoreAssets.authBackground.swiftUIImage + ThemeAssets.authBackground.swiftUIImage .resizable() .edgesIgnoringSafeArea(.top) }.frame(maxWidth: .infinity, maxHeight: 200) @@ -45,7 +46,7 @@ public struct SignInView: View { } VStack(alignment: .center) { - CoreAssets.appLogo.swiftUIImage + ThemeAssets.appLogo.swiftUIImage .resizable() .frame(maxWidth: 189, maxHeight: 54) .padding(.top, isHorizontal ? 20 : 40) @@ -142,7 +143,7 @@ public struct SignInView: View { VStack { Text(viewModel.alertMessage ?? "") .shadowCardStyle(bgColor: Theme.Colors.accentColor, - textColor: .white) + textColor: Theme.Colors.white) .padding(.top, 80) Spacer() diff --git a/Authorization/Authorization/Presentation/Registration/SignUpView.swift b/Authorization/Authorization/Presentation/Registration/SignUpView.swift index bc4bd7dee..09c484a4c 100644 --- a/Authorization/Authorization/Presentation/Registration/SignUpView.swift +++ b/Authorization/Authorization/Presentation/Registration/SignUpView.swift @@ -7,6 +7,7 @@ import SwiftUI import Core +import Theme public struct SignUpView: View { @@ -28,7 +29,7 @@ public struct SignUpView: View { public var body: some View { ZStack(alignment: .top) { VStack { - CoreAssets.authBackground.swiftUIImage + ThemeAssets.authBackground.swiftUIImage .resizable() .edgesIgnoringSafeArea(.top) }.frame(maxWidth: .infinity, maxHeight: 200) @@ -38,12 +39,12 @@ public struct SignUpView: View { ZStack { HStack { Text(AuthLocalization.SignIn.registerBtn) - .titleSettings(color: .white) + .titleSettings(color: Theme.Colors.white) } VStack { Button(action: { viewModel.router.back() }, label: { CoreAssets.arrowLeft.swiftUIImage.renderingMode(.template) - .backButtonStyle(color: .white) + .backButtonStyle(color: Theme.Colors.white) }) .foregroundColor(Theme.Colors.styledButtonText) .padding(.leading, isHorizontal ? 48 : 0) diff --git a/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift b/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift index ef4d1c7fb..acb2a6df3 100644 --- a/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift +++ b/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift @@ -7,6 +7,7 @@ import SwiftUI import Core +import Theme public struct ResetPasswordView: View { @@ -26,15 +27,15 @@ public struct ResetPasswordView: View { public var body: some View { ZStack(alignment: .top) { VStack { - CoreAssets.authBackground.swiftUIImage + ThemeAssets.authBackground.swiftUIImage .resizable() .edgesIgnoringSafeArea(.top) }.frame(maxWidth: .infinity, maxHeight: 200) VStack(alignment: .center) { NavigationBar(title: AuthLocalization.Forgot.title, - titleColor: .white, - leftButtonColor: .white, + titleColor: Theme.Colors.white, + leftButtonColor: Theme.Colors.white, leftButtonAction: { viewModel.router.back() }).padding(.leading, isHorizontal ? 48 : 0) @@ -125,7 +126,7 @@ public struct ResetPasswordView: View { VStack { Text(viewModel.alertMessage ?? "") .shadowCardStyle(bgColor: Theme.Colors.accentColor, - textColor: .white) + textColor: Theme.Colors.white) .padding(.top, 80) Spacer() diff --git a/Authorization/Authorization/Presentation/Startup/LogistrationBottomView.swift b/Authorization/Authorization/Presentation/Startup/LogistrationBottomView.swift index 2a4b2924c..8bd82e445 100644 --- a/Authorization/Authorization/Presentation/Startup/LogistrationBottomView.swift +++ b/Authorization/Authorization/Presentation/Startup/LogistrationBottomView.swift @@ -8,6 +8,7 @@ import Foundation import SwiftUI import Core +import Theme public struct LogistrationBottomView: View { @ObservedObject @@ -43,6 +44,7 @@ public struct LogistrationBottomView: View { } } +#if DEBUG struct LogistrationBottomView_Previews: PreviewProvider { static var previews: some View { let vm = StartupViewModel( @@ -61,3 +63,4 @@ struct LogistrationBottomView_Previews: PreviewProvider { .loadFonts() } } +#endif diff --git a/Authorization/Authorization/Presentation/Startup/StartupView.swift b/Authorization/Authorization/Presentation/Startup/StartupView.swift index b2c358ca2..8cfc50347 100644 --- a/Authorization/Authorization/Presentation/Startup/StartupView.swift +++ b/Authorization/Authorization/Presentation/Startup/StartupView.swift @@ -8,6 +8,7 @@ import Foundation import SwiftUI import Core +import Theme public struct StartupView: View { @@ -25,7 +26,7 @@ public struct StartupView: View { public var body: some View { ZStack(alignment: .top) { VStack(alignment: .leading) { - CoreAssets.appLogo.swiftUIImage + ThemeAssets.appLogo.swiftUIImage .resizable() .frame(maxWidth: 189, maxHeight: 54) .padding(.top, isHorizontal ? 20 : 40) diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index 036526d61..a54379167 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -63,7 +63,6 @@ 028CE96929858ECC00B6B1C3 /* FlexibleKeyboardInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 028CE96829858ECC00B6B1C3 /* FlexibleKeyboardInputView.swift */; }; 028F9F37293A44C700DE65D0 /* Data_ResetPassword.swift in Sources */ = {isa = PBXBuildFile; fileRef = 028F9F36293A44C700DE65D0 /* Data_ResetPassword.swift */; }; 028F9F39293A452B00DE65D0 /* ResetPassword.swift in Sources */ = {isa = PBXBuildFile; fileRef = 028F9F38293A452B00DE65D0 /* ResetPassword.swift */; }; - 0295B1DC297FF114003B0C65 /* SF-Pro.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 0295B1DA297FF0E9003B0C65 /* SF-Pro.ttf */; }; 0295C885299B99DD00ABE571 /* RefreshableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0295C884299B99DD00ABE571 /* RefreshableScrollView.swift */; }; 02A463112AEA966C00331037 /* AppReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A463102AEA966C00331037 /* AppReviewView.swift */; }; 02A4833529B8A73400D33F33 /* CorePersistenceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A4833429B8A73400D33F33 /* CorePersistenceProtocol.swift */; }; @@ -117,15 +116,15 @@ 0770DE5B28D0B209006D8A5D /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 0770DE5D28D0B209006D8A5D /* Localizable.strings */; }; 0770DE5F28D0B22C006D8A5D /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0770DE5E28D0B22C006D8A5D /* Strings.swift */; }; 0770DE6128D0B2CB006D8A5D /* Assets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0770DE6028D0B2CB006D8A5D /* Assets.swift */; }; - 0770DE7928D0C4A9006D8A5D /* RoundedCorners.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0770DE7828D0C4A9006D8A5D /* RoundedCorners.swift */; }; - 0770DE7B28D0C78C006D8A5D /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0770DE7A28D0C78C006D8A5D /* Theme.swift */; }; 07DDFCBD29A780BB00572595 /* UINavigationController+Animation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07DDFCBC29A780BB00572595 /* UINavigationController+Animation.swift */; }; C8C446EF233F81B9FABB77D2 /* Pods_App_Core.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 349B90CD6579F7B8D257E515 /* Pods_App_Core.framework */; }; CFC84952299F8B890055E497 /* Debounce.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFC84951299F8B890055E497 /* Debounce.swift */; }; - DB4EBE9E2B1075E100CB4DC4 /* ConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB4EBE9D2B1075E100CB4DC4 /* ConfigTests.swift */; }; DBF6F2412B014ADA0098414B /* FirebaseConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF6F2402B014ADA0098414B /* FirebaseConfig.swift */; }; DBF6F2462B01DAFE0098414B /* AgreementConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF6F2452B01DAFE0098414B /* AgreementConfig.swift */; }; DBF6F24A2B0380E00098414B /* FeaturesConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF6F2492B0380E00098414B /* FeaturesConfig.swift */; }; + E055A5392B18DC95008D9E5E /* Theme.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E055A5382B18DC95008D9E5E /* Theme.framework */; }; + E055A53A2B18DC95008D9E5E /* Theme.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = E055A5382B18DC95008D9E5E /* Theme.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + E09179FD2B0F204E002AB695 /* ConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E09179FC2B0F204D002AB695 /* ConfigTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -138,6 +137,20 @@ }; /* End PBXContainerItemProxy section */ +/* Begin PBXCopyFilesBuildPhase section */ + E055A53B2B18DC95008D9E5E /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + E055A53A2B18DC95008D9E5E /* Theme.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 020306CB2932C0C4000949EA /* PickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickerView.swift; sourceTree = ""; }; 02066B472906F73400F4307E /* PickerMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickerMenu.swift; sourceTree = ""; }; @@ -194,7 +207,6 @@ 028CE96829858ECC00B6B1C3 /* FlexibleKeyboardInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlexibleKeyboardInputView.swift; sourceTree = ""; }; 028F9F36293A44C700DE65D0 /* Data_ResetPassword.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_ResetPassword.swift; sourceTree = ""; }; 028F9F38293A452B00DE65D0 /* ResetPassword.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetPassword.swift; sourceTree = ""; }; - 0295B1DA297FF0E9003B0C65 /* SF-Pro.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "SF-Pro.ttf"; sourceTree = ""; }; 0295C884299B99DD00ABE571 /* RefreshableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshableScrollView.swift; sourceTree = ""; }; 02A463102AEA966C00331037 /* AppReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppReviewView.swift; sourceTree = ""; }; 02A4833429B8A73400D33F33 /* CorePersistenceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CorePersistenceProtocol.swift; sourceTree = ""; }; @@ -251,8 +263,6 @@ 0770DE5C28D0B209006D8A5D /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 0770DE5E28D0B22C006D8A5D /* Strings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Strings.swift; sourceTree = ""; }; 0770DE6028D0B2CB006D8A5D /* Assets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Assets.swift; sourceTree = ""; }; - 0770DE7828D0C4A9006D8A5D /* RoundedCorners.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundedCorners.swift; sourceTree = ""; }; - 0770DE7A28D0C78C006D8A5D /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = ""; }; 07DDFCBC29A780BB00572595 /* UINavigationController+Animation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UINavigationController+Animation.swift"; sourceTree = ""; }; 0E13E9173C9C4CFC19F8B6F2 /* Pods-App-Core.debugstage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core.debugstage.xcconfig"; path = "Target Support Files/Pods-App-Core/Pods-App-Core.debugstage.xcconfig"; sourceTree = ""; }; 1A154A95AF4EE85A4A1C083B /* Pods-App-Core.releasedev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core.releasedev.xcconfig"; path = "Target Support Files/Pods-App-Core/Pods-App-Core.releasedev.xcconfig"; sourceTree = ""; }; @@ -263,10 +273,11 @@ 9D5B06CAA99EA5CD49CBE2BB /* Pods-App-Core.debugdev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core.debugdev.xcconfig"; path = "Target Support Files/Pods-App-Core/Pods-App-Core.debugdev.xcconfig"; sourceTree = ""; }; C7E5BCE79CE297B20777B27A /* Pods-App-Core.debugprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core.debugprod.xcconfig"; path = "Target Support Files/Pods-App-Core/Pods-App-Core.debugprod.xcconfig"; sourceTree = ""; }; CFC84951299F8B890055E497 /* Debounce.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Debounce.swift; sourceTree = ""; }; - DB4EBE9D2B1075E100CB4DC4 /* ConfigTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConfigTests.swift; sourceTree = ""; }; DBF6F2402B014ADA0098414B /* FirebaseConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirebaseConfig.swift; sourceTree = ""; }; DBF6F2452B01DAFE0098414B /* AgreementConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgreementConfig.swift; sourceTree = ""; }; DBF6F2492B0380E00098414B /* FeaturesConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeaturesConfig.swift; sourceTree = ""; }; + E055A5382B18DC95008D9E5E /* Theme.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Theme.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + E09179FC2B0F204D002AB695 /* ConfigTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConfigTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -284,6 +295,7 @@ files = ( 025EF2F62971740000B838AB /* YouTubePlayerKit in Frameworks */, C8C446EF233F81B9FABB77D2 /* Pods_App_Core.framework in Frameworks */, + E055A5392B18DC95008D9E5E /* Theme.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -380,14 +392,6 @@ path = Extensions; sourceTree = ""; }; - 0295B1DB297FF0E9003B0C65 /* Fonts */ = { - isa = PBXGroup; - children = ( - 0295B1DA297FF0E9003B0C65 /* SF-Pro.ttf */, - ); - path = Fonts; - sourceTree = ""; - }; 02AFCC162AEFDB0F000360F0 /* ThirdPartyMailer */ = { isa = PBXGroup; children = ( @@ -491,7 +495,7 @@ children = ( 0770DE5328D0B00C006D8A5D /* swiftgen.yml */, 0770DE0A28D07831006D8A5D /* Core */, - DB4EBE9B2B1075E100CB4DC4 /* CoreTests */, + E09179FA2B0F204D002AB695 /* CoreTests */, 0770DE0928D07831006D8A5D /* Products */, C9DFE47E699CFFA85A77AF2C /* Pods */, F1620A3A2C8B0699EAA61B57 /* Frameworks */, @@ -510,7 +514,6 @@ 0770DE0A28D07831006D8A5D /* Core */ = { isa = PBXGroup; children = ( - 0295B1DB297FF0E9003B0C65 /* Fonts */, 027BD3A12909470F00392132 /* AvoidingHelpers */, 0770DE5528D0B142006D8A5D /* SwiftGen */, 0283347E28D4DCC100C828FC /* Extensions */, @@ -519,7 +522,6 @@ 0727876E28D233EC002E9142 /* Configuration */, 0770DE2828D0928B006D8A5D /* Network */, 0770DE7628D0C491006D8A5D /* View */, - 0770DE7A28D0C78C006D8A5D /* Theme.swift */, 0770DE5D28D0B209006D8A5D /* Localizable.strings */, 0770DE5128D0ADFF006D8A5D /* Assets.xcassets */, 071009CF28D1E3A600344290 /* Constants.swift */, @@ -565,7 +567,6 @@ 0770DE7728D0C49E006D8A5D /* Base */ = { isa = PBXGroup; children = ( - 0770DE7828D0C4A9006D8A5D /* RoundedCorners.swift */, 02A4833B29B8C57800D33F33 /* DownloadView.swift */, 02D800CB29348F460099CF16 /* ImagePicker.swift */, 024D723429C8BB1A006D36ED /* NavigationBar.swift */, @@ -597,22 +598,6 @@ path = Base; sourceTree = ""; }; - 078525AC2B0CBFF4007B4521 /* CoreTests */ = { - isa = PBXGroup; - children = ( - 078525AD2B0CC004007B4521 /* Configuration */, - ); - path = CoreTests; - sourceTree = ""; - }; - 078525AD2B0CC004007B4521 /* Configuration */ = { - isa = PBXGroup; - children = ( - DBF6F2472B01E20A0098414B /* ConfigTests.swift */, - ); - path = Configuration; - sourceTree = ""; - }; C9DFE47E699CFFA85A77AF2C /* Pods */ = { isa = PBXGroup; children = ( @@ -637,36 +622,37 @@ path = Combine; sourceTree = ""; }; - DB4EBE9B2B1075E100CB4DC4 /* CoreTests */ = { + DBF6F2422B014AF30098414B /* Config */ = { isa = PBXGroup; children = ( - DB4EBE9C2B1075E100CB4DC4 /* Configuration */, + 0727876F28D23411002E9142 /* Config.swift */, + DBF6F2402B014ADA0098414B /* FirebaseConfig.swift */, + DBF6F2492B0380E00098414B /* FeaturesConfig.swift */, + DBF6F2452B01DAFE0098414B /* AgreementConfig.swift */, ); - path = CoreTests; + path = Config; sourceTree = ""; }; - DB4EBE9C2B1075E100CB4DC4 /* Configuration */ = { + E09179FA2B0F204D002AB695 /* CoreTests */ = { isa = PBXGroup; children = ( - DB4EBE9D2B1075E100CB4DC4 /* ConfigTests.swift */, + E09179FB2B0F204D002AB695 /* Configuration */, ); - path = Configuration; + path = CoreTests; sourceTree = ""; }; - DBF6F2422B014AF30098414B /* Config */ = { + E09179FB2B0F204D002AB695 /* Configuration */ = { isa = PBXGroup; children = ( - 0727876F28D23411002E9142 /* Config.swift */, - DBF6F2402B014ADA0098414B /* FirebaseConfig.swift */, - DBF6F2492B0380E00098414B /* FeaturesConfig.swift */, - DBF6F2452B01DAFE0098414B /* AgreementConfig.swift */, + E09179FC2B0F204D002AB695 /* ConfigTests.swift */, ); - path = Config; + path = Configuration; sourceTree = ""; }; F1620A3A2C8B0699EAA61B57 /* Frameworks */ = { isa = PBXGroup; children = ( + E055A5382B18DC95008D9E5E /* Theme.framework */, 349B90CD6579F7B8D257E515 /* Pods_App_Core.framework */, ); name = Frameworks; @@ -713,6 +699,7 @@ 0770DE0428D07831006D8A5D /* Sources */, 0770DE0528D07831006D8A5D /* Frameworks */, 0770DE0628D07831006D8A5D /* Resources */, + E055A53B2B18DC95008D9E5E /* Embed Frameworks */, ); buildRules = ( ); @@ -782,7 +769,6 @@ buildActionMask = 2147483647; files = ( 0770DE5228D0ADFF006D8A5D /* Assets.xcassets in Resources */, - 0295B1DC297FF114003B0C65 /* SF-Pro.ttf in Resources */, 0770DE5B28D0B209006D8A5D /* Localizable.strings in Resources */, 0770DE5428D0B00C006D8A5D /* swiftgen.yml in Resources */, ); @@ -839,7 +825,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - DB4EBE9E2B1075E100CB4DC4 /* ConfigTests.swift in Sources */, + E09179FD2B0F204E002AB695 /* ConfigTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -852,7 +838,6 @@ DBF6F2462B01DAFE0098414B /* AgreementConfig.swift in Sources */, 027BD3AF2909475000392132 /* DismissKeyboardTapHandler.swift in Sources */, 02E225B0291D29EB0067769A /* UrlExtension.swift in Sources */, - 0770DE7928D0C4A9006D8A5D /* RoundedCorners.swift in Sources */, 02CF46C829546AA200A698EE /* NoCachedDataError.swift in Sources */, 0727877728D23847002E9142 /* DataLayer.swift in Sources */, 0241666B28F5A78B00082765 /* HTMLFormattedText.swift in Sources */, @@ -897,7 +882,6 @@ 023A4DD4299E66BD006C0E48 /* OfflineSnackBarView.swift in Sources */, 021D925728DCF12900ACC565 /* AlertView.swift in Sources */, 027BD3A82909474200392132 /* KeyboardAvoidingViewController.swift in Sources */, - 0770DE7B28D0C78C006D8A5D /* Theme.swift in Sources */, 02E93F852AEBAEBC006C4750 /* AppReviewViewModel.swift in Sources */, 0770DE2528D08FBA006D8A5D /* CoreStorage.swift in Sources */, 020306CC2932C0C4000949EA /* PickerView.swift in Sources */, diff --git a/Core/Core/Assets.xcassets/Auth/checkEmail.imageset/Contents.json b/Core/Core/Assets.xcassets/checkEmail.imageset/Contents.json similarity index 100% rename from Core/Core/Assets.xcassets/Auth/checkEmail.imageset/Contents.json rename to Core/Core/Assets.xcassets/checkEmail.imageset/Contents.json diff --git a/Core/Core/Assets.xcassets/Auth/checkEmail.imageset/_1-2.svg b/Core/Core/Assets.xcassets/checkEmail.imageset/_1-2.svg similarity index 100% rename from Core/Core/Assets.xcassets/Auth/checkEmail.imageset/_1-2.svg rename to Core/Core/Assets.xcassets/checkEmail.imageset/_1-2.svg diff --git a/Core/Core/Assets.xcassets/Auth/checkEmail.imageset/_1.svg b/Core/Core/Assets.xcassets/checkEmail.imageset/_1.svg similarity index 100% rename from Core/Core/Assets.xcassets/Auth/checkEmail.imageset/_1.svg rename to Core/Core/Assets.xcassets/checkEmail.imageset/_1.svg diff --git a/Core/Core/Configuration/CSSInjector.swift b/Core/Core/Configuration/CSSInjector.swift index 57a2c88b0..59beef4cd 100644 --- a/Core/Core/Configuration/CSSInjector.swift +++ b/Core/Core/Configuration/CSSInjector.swift @@ -7,6 +7,7 @@ import Foundation import SwiftUI +import Theme public class CSSInjector { @@ -114,7 +115,7 @@ public class CSSInjector {

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 Ksa(PG)&B$7Se~B% literal 0 HcmV?d00001 diff --git a/WhatsNew/WhatsNew/Assets.xcassets/1.0/image2_1.0.imageset/Contents.json b/WhatsNew/WhatsNew/Assets.xcassets/1.0/image2_1.0.imageset/Contents.json new file mode 100644 index 000000000..80d1a4ce5 --- /dev/null +++ b/WhatsNew/WhatsNew/Assets.xcassets/1.0/image2_1.0.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Group 96-2.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WhatsNew/WhatsNew/Assets.xcassets/1.0/image2_1.0.imageset/Group 96-2.png b/WhatsNew/WhatsNew/Assets.xcassets/1.0/image2_1.0.imageset/Group 96-2.png new file mode 100644 index 0000000000000000000000000000000000000000..36b711417945f7b6457d166efb3b7d453f3b1026 GIT binary patch literal 32412 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