diff --git a/Stepic.xcodeproj/project.pbxproj b/Stepic.xcodeproj/project.pbxproj index f9058af1c5..794cc718e3 100644 --- a/Stepic.xcodeproj/project.pbxproj +++ b/Stepic.xcodeproj/project.pbxproj @@ -550,6 +550,8 @@ 2C6BBBBD22B265B300889A45 /* AttemptsNetworkService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C6BBBBC22B265B300889A45 /* AttemptsNetworkService.swift */; }; 2C6BBBBF22B26DB200889A45 /* SubmissionsNetworkService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C6BBBBE22B26DB100889A45 /* SubmissionsNetworkService.swift */; }; 2C6CA82024ABB2EE00E2EB2F /* NewProfileUserActivityCurrentStreakView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C6CA81F24ABB2EE00E2EB2F /* NewProfileUserActivityCurrentStreakView.swift */; }; + 2C6D61AE24D93E4500D019F2 /* String+Whitespaces.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C6D61AD24D93E4500D019F2 /* String+Whitespaces.swift */; }; + 2C6D61B024D9729D00D019F2 /* NewProfileHeaderViewSkeleton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C6D61AF24D9729D00D019F2 /* NewProfileHeaderViewSkeleton.swift */; }; 2C6DECE123D02DF400E542B9 /* SettingsRightDetailTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C6DECE023D02DF400E542B9 /* SettingsRightDetailTableViewCell.swift */; }; 2C6E9CD41FED657E001821A2 /* Adaptive.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 2C6E9CD31FED657E001821A2 /* Adaptive.storyboard */; }; 2C6E9CDB1FF27543001821A2 /* AdaptiveStorageManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C6E9CDA1FF27543001821A2 /* AdaptiveStorageManager.swift */; }; @@ -1942,6 +1944,8 @@ 2C6BBBBC22B265B300889A45 /* AttemptsNetworkService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttemptsNetworkService.swift; sourceTree = ""; }; 2C6BBBBE22B26DB100889A45 /* SubmissionsNetworkService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubmissionsNetworkService.swift; sourceTree = ""; }; 2C6CA81F24ABB2EE00E2EB2F /* NewProfileUserActivityCurrentStreakView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewProfileUserActivityCurrentStreakView.swift; sourceTree = ""; }; + 2C6D61AD24D93E4500D019F2 /* String+Whitespaces.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Whitespaces.swift"; sourceTree = ""; }; + 2C6D61AF24D9729D00D019F2 /* NewProfileHeaderViewSkeleton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewProfileHeaderViewSkeleton.swift; sourceTree = ""; }; 2C6DECE023D02DF400E542B9 /* SettingsRightDetailTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsRightDetailTableViewCell.swift; sourceTree = ""; }; 2C6E9CD31FED657E001821A2 /* Adaptive.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = Adaptive.storyboard; sourceTree = ""; }; 2C6E9CDA1FF27543001821A2 /* AdaptiveStorageManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptiveStorageManager.swift; sourceTree = ""; }; @@ -2930,6 +2934,7 @@ isa = PBXGroup; children = ( 2C04846424C6844A00B3DAC8 /* NewProfileCertificatesCellSkeletonView.swift */, + 2C6D61AF24D9729D00D019F2 /* NewProfileHeaderViewSkeleton.swift */, 2C22042F20E27E400060117A /* ProfileCellSkeletonPlaceholderView.xib */, 2C7EFCF024D08D8E003A4E93 /* SocialProfiles */, ); @@ -2987,6 +2992,7 @@ children = ( 2C9A8D2D22D348A5009434DB /* String+HTMLEscape.swift */, 2C546C1223DA02AB00352F27 /* String+SafeSubscript.swift */, + 2C6D61AD24D93E4500D019F2 /* String+Whitespaces.swift */, ); path = SwiftStdlib; sourceTree = ""; @@ -8335,6 +8341,7 @@ 2C7545A824C01C00004F9074 /* AchievementProgressesNetworkService.swift in Sources */, 62E982132F6F132B7B228D1D /* UserAccountService.swift in Sources */, 2C04BA502407369E00D74D4B /* SubmissionStatus.swift in Sources */, + 2C6D61AE24D93E4500D019F2 /* String+Whitespaces.swift in Sources */, 2CC2770023EB9E2200E88D6E /* SubmissionsSkeletonView.swift in Sources */, 62E981DD2E1E99BB20641A87 /* ExploreBlockContainerView.swift in Sources */, 62E98285466828069E357EC0 /* ScrollableStackView.swift in Sources */, @@ -8567,6 +8574,7 @@ 62E987E02EC520C92F15E0B9 /* ExploreCoursesCollectionHeaderView.swift in Sources */, 2CEBEBF4242F8F9900DBFDF0 /* StepikRequestAdapter.swift in Sources */, 62E98F7CBC091ADD515E1E89 /* GradientCoursesPlaceholderView.swift in Sources */, + 2C6D61B024D9729D00D019F2 /* NewProfileHeaderViewSkeleton.swift in Sources */, 62E98E98F2F7FB7925AF2730 /* GradientCoursesPlaceholderViewFactory.swift in Sources */, 62E98C3CA2749DA6E0C98BC9 /* TagsAssembly.swift in Sources */, 62E98351301E9DA3680C2C26 /* TagsDataFlow.swift in Sources */, diff --git a/Stepic/Legacy/Controllers/Placeholders/ControllerWithStepikPlaceholder.swift b/Stepic/Legacy/Controllers/Placeholders/ControllerWithStepikPlaceholder.swift index 2362005941..f843793fce 100644 --- a/Stepic/Legacy/Controllers/Placeholders/ControllerWithStepikPlaceholder.swift +++ b/Stepic/Legacy/Controllers/Placeholders/ControllerWithStepikPlaceholder.swift @@ -11,45 +11,56 @@ import UIKit typealias StepikPlaceholderControllerState = StepikPlaceholderControllerContainer.PlaceholderState -class StepikPlaceholderControllerContainer: StepikPlaceholderViewDelegate { - static let shared = StepikPlaceholderControllerContainer() - - open class PlaceholderState: Equatable, Hashable { - var id: String - - init(id: String) { - self.id = id - } +extension StepikPlaceholderControllerContainer { + struct Appearance { + var placeholderAppearance = StepikPlaceholderView.Appearance() + } +} +final class StepikPlaceholderControllerContainer: StepikPlaceholderViewDelegate { + final class PlaceholderState: Equatable, Hashable { static let anonymous = PlaceholderState(id: "anonymous") static let connectionError = PlaceholderState(id: "connectionError") static let refreshing = PlaceholderState(id: "refreshing") static let empty = PlaceholderState(id: "empty") static let adaptiveCoursePassed = PlaceholderState(id: "adaptiveCoursePassed") + var id: String + var hashValue: Int { get { return id.hashValue } } - public static func == (lhs: PlaceholderState, rhs: PlaceholderState) -> Bool { + static func == (lhs: PlaceholderState, rhs: PlaceholderState) -> Bool { return lhs.id == rhs.id } + + init(id: String) { + self.id = id + } } - var registeredPlaceholders: [PlaceholderState: StepikPlaceholder] = [:] - var currentPlaceholderButtonAction: (() -> Void)? - var isPlaceholderShown: Bool = false + let appearance: Appearance lazy var placeholderView: StepikPlaceholderView = { let view = StepikPlaceholderView() + view.appearance = self.appearance.placeholderAppearance return view }() + fileprivate var registeredPlaceholders: [PlaceholderState: StepikPlaceholder] = [:] + fileprivate var currentPlaceholderButtonAction: (() -> Void)? + fileprivate var isPlaceholderShown: Bool = false + func buttonDidClick(_ button: UIButton) { currentPlaceholderButtonAction?() } + + init(appearance: Appearance = Appearance()) { + self.appearance = appearance + } } protocol ControllerWithStepikPlaceholder: AnyObject { diff --git a/Stepic/Legacy/Controllers/Placeholders/StepikPlaceholderView/StepikPlaceholderView.swift b/Stepic/Legacy/Controllers/Placeholders/StepikPlaceholderView/StepikPlaceholderView.swift index f2ee2ca24c..c56cb9d4f4 100644 --- a/Stepic/Legacy/Controllers/Placeholders/StepikPlaceholderView/StepikPlaceholderView.swift +++ b/Stepic/Legacy/Controllers/Placeholders/StepikPlaceholderView/StepikPlaceholderView.swift @@ -12,6 +12,15 @@ protocol StepikPlaceholderViewDelegate: AnyObject { func buttonDidClick(_ button: UIButton) } +extension StepikPlaceholderView { + struct Appearance { + var backgroundColor = UIColor.stepikBackground + let textColor = UIColor.stepikSystemSecondaryText + let actionButtonBorderColor = UIColor.stepikOpaqueSeparator + let actionButtonTitleColor = UIColor.stepikPrimaryText + } +} + final class StepikPlaceholderView: NibInitializableView { struct maxHeight { static let horizontal = CGFloat(500) @@ -44,6 +53,12 @@ final class StepikPlaceholderView: NibInitializableView { weak var delegate: StepikPlaceholderViewDelegate? + var appearance = Appearance() { + didSet { + self.colorize() + } + } + override var nibName: String { "StepikPlaceholderView" } convenience init(placeholder: StepikPlaceholderStyle) { @@ -75,10 +90,10 @@ final class StepikPlaceholderView: NibInitializableView { } private func colorize() { - self.view.backgroundColor = .stepikBackground - self.textLabel.textColor = .stepikSystemSecondaryText - self.actionButton.layer.borderColor = UIColor.stepikOpaqueSeparator.cgColor - self.actionButton.setTitleColor(.stepikPrimaryText, for: .normal) + self.view.backgroundColor = self.appearance.backgroundColor + self.textLabel.textColor = self.appearance.textColor + self.actionButton.layer.borderColor = self.appearance.actionButtonBorderColor.cgColor + self.actionButton.setTitleColor(self.appearance.actionButtonTitleColor, for: .normal) } private func rebuildConstraints(for placeholder: StepikPlaceholderStyle) { diff --git a/Stepic/Legacy/Views/Skeleton/Views/Profile/NewProfileHeaderViewSkeleton.swift b/Stepic/Legacy/Views/Skeleton/Views/Profile/NewProfileHeaderViewSkeleton.swift new file mode 100644 index 0000000000..cce0660ca9 --- /dev/null +++ b/Stepic/Legacy/Views/Skeleton/Views/Profile/NewProfileHeaderViewSkeleton.swift @@ -0,0 +1,96 @@ +import SnapKit +import UIKit + +extension NewProfileHeaderViewSkeleton { + struct Appearance { + let avatarViewWithHeight: CGFloat = 64 + let avatarViewInsets = LayoutInsets(top: 16, left: 16) + + let usernameInsets = LayoutInsets(left: 16) + let usernameHeight: CGFloat = 24 + + let shortBioInsets = LayoutInsets(right: 16) + let shortBioHeight: CGFloat = 29 + + let spacing: CGFloat = 8 + let labelsCornerRadius: CGFloat = 5 + } +} + +final class NewProfileHeaderViewSkeleton: UIView { + let appearance: Appearance + + private let topContentInset: CGFloat + + private lazy var avatarView = UIView() + private lazy var usernameView = UIView() + private lazy var shortBioView = UIView() + + init( + frame: CGRect = .zero, + appearance: Appearance = Appearance(), + topContentInset: CGFloat = 0 + ) { + self.appearance = appearance + self.topContentInset = topContentInset + super.init(frame: frame) + + self.setupView() + self.addSubviews() + self.makeConstraints() + } + + @available(*, unavailable) + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension NewProfileHeaderViewSkeleton: ProgrammaticallyInitializableViewProtocol { + func setupView() { + self.backgroundColor = .clear + + self.avatarView.clipsToBounds = true + self.avatarView.layer.cornerRadius = self.appearance.avatarViewWithHeight / 2 + + self.usernameView.clipsToBounds = true + self.usernameView.layer.cornerRadius = self.appearance.labelsCornerRadius + + self.shortBioView.clipsToBounds = true + self.shortBioView.layer.cornerRadius = self.appearance.labelsCornerRadius + } + + func addSubviews() { + self.addSubview(self.avatarView) + self.addSubview(self.usernameView) + self.addSubview(self.shortBioView) + } + + func makeConstraints() { + self.avatarView.translatesAutoresizingMaskIntoConstraints = false + self.avatarView.snp.makeConstraints { make in + make.top + .equalToSuperview() + .offset(self.topContentInset + self.appearance.avatarViewInsets.top) + make.leading.equalToSuperview().offset(self.appearance.avatarViewInsets.left) + make.width.equalTo(self.appearance.avatarViewWithHeight) + make.height.equalTo(self.appearance.avatarViewWithHeight) + } + + self.usernameView.translatesAutoresizingMaskIntoConstraints = false + self.usernameView.snp.makeConstraints { make in + make.top.equalTo(self.avatarView.snp.top) + make.leading.equalTo(self.avatarView.snp.trailing).offset(self.appearance.usernameInsets.left) + make.height.equalTo(self.appearance.usernameHeight) + make.width.equalToSuperview().multipliedBy(0.3) + } + + self.shortBioView.translatesAutoresizingMaskIntoConstraints = false + self.shortBioView.snp.makeConstraints { make in + make.top.equalTo(self.usernameView.snp.bottom).offset(self.appearance.spacing) + make.leading.equalTo(self.usernameView.snp.leading) + make.trailing.equalToSuperview().offset(-self.appearance.shortBioInsets.right) + make.height.equalTo(self.appearance.shortBioHeight) + } + } +} diff --git a/Stepic/Sources/Extensions/SwiftStdlib/String+Whitespaces.swift b/Stepic/Sources/Extensions/SwiftStdlib/String+Whitespaces.swift new file mode 100644 index 0000000000..aa4fa12aad --- /dev/null +++ b/Stepic/Sources/Extensions/SwiftStdlib/String+Whitespaces.swift @@ -0,0 +1,17 @@ +import Foundation + +extension String { + /// String with no spaces or new lines in beginning and end. + func trimmed() -> String { + self.trimmingCharacters(in: .whitespacesAndNewlines) + } + + func condenseWhitespace() -> String { + let components = self.components(separatedBy: .whitespacesAndNewlines) + return components.filter { !$0.isEmpty }.joined(separator: " ") + } + + func normalizeNewline() -> String { + self.replacingOccurrences(of: "\n+", with: "\n", options: .regularExpression, range: nil) + } +} diff --git a/Stepic/Sources/Frameworks/ContentProcessor/ContentProcessingRule.swift b/Stepic/Sources/Frameworks/ContentProcessor/ContentProcessingRule.swift index 1ae1391d86..6e3e1f53ad 100644 --- a/Stepic/Sources/Frameworks/ContentProcessor/ContentProcessingRule.swift +++ b/Stepic/Sources/Frameworks/ContentProcessor/ContentProcessingRule.swift @@ -164,10 +164,3 @@ final class ReplaceModelViewerWithARImageRule: BaseHTMLExtractionRule { return content } } - -private extension String { - func condenseWhitespace() -> String { - let components = self.components(separatedBy: .whitespacesAndNewlines) - return components.filter { !$0.isEmpty }.joined(separator: " ") - } -} diff --git a/Stepic/Sources/Modules/CourseList/Views/CourseListColorMode.swift b/Stepic/Sources/Modules/CourseList/Views/CourseListColorMode.swift index 0af3400907..b1c0aa3484 100644 --- a/Stepic/Sources/Modules/CourseList/Views/CourseListColorMode.swift +++ b/Stepic/Sources/Modules/CourseList/Views/CourseListColorMode.swift @@ -3,7 +3,7 @@ import Foundation enum CourseListColorMode { case light case dark - case clear + case grouped static var `default`: CourseListColorMode { .light } } @@ -11,7 +11,7 @@ enum CourseListColorMode { extension CourseListColorMode { var exploreBlockHeaderViewAppearance: ExploreBlockHeaderView.Appearance { switch self { - case .light, .clear: + case .light, .grouped: return .init( titleLabelColor: .stepikPrimaryText, showAllButtonColor: .stepikTertiaryText @@ -34,7 +34,7 @@ extension CourseListColorMode { if #available(iOS 13.0, *) { return UIColor { (traitCollection: UITraitCollection) -> UIColor in switch self { - case .light, .clear: + case .light, .grouped: return .stepikBackground case .dark: if traitCollection.userInterfaceStyle == .dark { @@ -45,7 +45,7 @@ extension CourseListColorMode { } } else { switch self { - case .light, .clear: + case .light, .grouped: return .white case .dark: return .stepikAccentFixed @@ -59,7 +59,7 @@ extension CourseListColorMode { var courseWidgetStatsViewAppearance: CourseWidgetStatsView.Appearance { switch self { - case .light, .clear: + case .light, .grouped: return .init( imagesRenderingBackgroundColor: .stepikAccent, imagesRenderingTintColor: .stepikGreenFixed, @@ -83,7 +83,7 @@ extension CourseListColorMode { ) switch self { - case .light, .clear: + case .light, .grouped: appearance.textColor = .stepikPrimaryText case .dark: appearance.textColor = .white @@ -99,7 +99,7 @@ extension CourseListColorMode { ) switch self { - case .light, .clear: + case .light, .grouped: appearance.textColor = .stepikSecondaryText case .dark: appearance.textColor = UIColor.dynamic( @@ -113,7 +113,7 @@ extension CourseListColorMode { var courseWidgetBorderColor: UIColor { switch self { - case .light, .clear: + case .light, .grouped: return .dynamic(light: .stepikGrey8Fixed, dark: .stepikSeparator) case .dark: if #available(iOS 13.0, *) { diff --git a/Stepic/Sources/Modules/CourseList/Views/CourseListView.swift b/Stepic/Sources/Modules/CourseList/Views/CourseListView.swift index e2bb10d8a8..1bf096338a 100644 --- a/Stepic/Sources/Modules/CourseList/Views/CourseListView.swift +++ b/Stepic/Sources/Modules/CourseList/Views/CourseListView.swift @@ -12,6 +12,7 @@ extension CourseListView { let lightModeBackgroundColor = UIColor.stepikBackground let darkModeBackgroundColor = UIColor.dynamic(light: .stepikAccent, dark: .stepikSecondaryBackground) + let groupedModeBackgroundColor = UIColor.stepikGroupedBackground let horizontalLayoutNextPageWidth: CGFloat = 12.0 } @@ -139,8 +140,8 @@ class CourseListView: UIView { return self.appearance.lightModeBackgroundColor case .dark: return self.appearance.darkModeBackgroundColor - case .clear: - return .clear + case .grouped: + return self.appearance.groupedModeBackgroundColor } } @@ -188,10 +189,10 @@ extension CourseListView: ProgrammaticallyInitializableViewProtocol { DarkCourseListCollectionViewCell.self, forCellWithReuseIdentifier: DarkCourseListCollectionViewCell.defaultReuseIdentifier ) - case .clear: + case .grouped: self.collectionView.register( - ClearCourseListCollectionViewCell.self, - forCellWithReuseIdentifier: ClearCourseListCollectionViewCell.defaultReuseIdentifier + GroupedCourseListCollectionViewCell.self, + forCellWithReuseIdentifier: GroupedCourseListCollectionViewCell.defaultReuseIdentifier ) } @@ -539,9 +540,9 @@ private class DarkCourseListCollectionViewCell: CourseListCollectionViewCell { } } -private class ClearCourseListCollectionViewCell: CourseListCollectionViewCell { +private class GroupedCourseListCollectionViewCell: CourseListCollectionViewCell { override init(frame: CGRect) { - super.init(frame: frame, colorMode: .clear) + super.init(frame: frame, colorMode: .grouped) } @available(*, unavailable) diff --git a/Stepic/Sources/Modules/CourseList/Views/Widget/CourseWidgetView.swift b/Stepic/Sources/Modules/CourseList/Views/Widget/CourseWidgetView.swift index 4a3e5d5834..f573d7b6e0 100644 --- a/Stepic/Sources/Modules/CourseList/Views/Widget/CourseWidgetView.swift +++ b/Stepic/Sources/Modules/CourseList/Views/Widget/CourseWidgetView.swift @@ -125,8 +125,8 @@ final class CourseWidgetView: UIView { extension CourseWidgetView: ProgrammaticallyInitializableViewProtocol { func setupView() { - if self.colorMode == .clear { - self.backgroundColor = .stepikBackground + if self.colorMode == .grouped { + self.backgroundColor = .stepikSecondaryGroupedBackground } } diff --git a/Stepic/Sources/Modules/NewProfile/NewProfileInteractor.swift b/Stepic/Sources/Modules/NewProfile/NewProfileInteractor.swift index 673a2a3865..def202307a 100644 --- a/Stepic/Sources/Modules/NewProfile/NewProfileInteractor.swift +++ b/Stepic/Sources/Modules/NewProfile/NewProfileInteractor.swift @@ -157,7 +157,7 @@ final class NewProfileInteractor: NewProfileInteractorProtocol { for (uniqueIdentifier, submodule) in request.submodules { self.submodules[uniqueIdentifier] = submodule } - self.pushCurrentUserToSubmodules(Array(self.submodules.values)) + self.pushCurrentUserToSubmodules(Array(request.submodules.values)) } // MARK: Private API diff --git a/Stepic/Sources/Modules/NewProfile/NewProfileViewController.swift b/Stepic/Sources/Modules/NewProfile/NewProfileViewController.swift index c8df50dfbe..d3d72f0e1f 100644 --- a/Stepic/Sources/Modules/NewProfile/NewProfileViewController.swift +++ b/Stepic/Sources/Modules/NewProfile/NewProfileViewController.swift @@ -175,8 +175,12 @@ final class NewProfileViewController: UIViewController, ControllerWithStepikPlac case .anonymous: self.showPlaceholder(for: .anonymous) case .result(let viewModel): - if (self.title?.isEmpty ?? true) && !viewModel.headerViewModel.isOrganization { - self.title = NSLocalizedString("Profile", comment: "") + if self.title?.isEmpty ?? true { + if !viewModel.isOrganization { + self.title = NSLocalizedString("Profile", comment: "") + } else if viewModel.headerViewModel.coverURL == nil { + self.title = NSLocalizedString("Organization", comment: "") + } } self.isPlaceholderShown = false @@ -201,13 +205,16 @@ final class NewProfileViewController: UIViewController, ControllerWithStepikPlac let shouldShowStreakNotifications = viewModel.isCurrentUserProfile self.refreshStreakNotificationsState(shouldShowStreakNotifications ? .visible : .hidden) - self.refreshUserActivityState() - self.refreshAchievementsState() + let shouldShowUserActivity = !viewModel.isOrganization + self.refreshUserActivityState(shouldShowUserActivity ? .visible : .hidden) + + let shouldShowAchievements = !viewModel.isOrganization + self.refreshAchievementsState(shouldShowAchievements ? .visible : .hidden) - let shouldShowCertificates = self.currentCertificatesState != .hidden + let shouldShowCertificates = !viewModel.isOrganization && self.currentCertificatesState != .hidden self.refreshCertificatesState(shouldShowCertificates ? .visible : .hidden) - let shouldShowSocialProfiles = viewModel.socialProfilesCount > 0 && viewModel.headerViewModel.isOrganization + let shouldShowSocialProfiles = viewModel.isOrganization && viewModel.socialProfilesCount > 0 self.refreshSocialProfilesState(shouldShowSocialProfiles ? .visible : .hidden) self.refreshProfileDetailsState(viewModel: viewModel) @@ -409,72 +416,96 @@ final class NewProfileViewController: UIViewController, ControllerWithStepikPlac // MARK: User Activity - private func refreshUserActivityState() { - guard self.getSubmodule(type: NewProfile.Submodule.userActivity) == nil else { - return - } + private enum UserActivityState { + case visible + case hidden + } - let assembly = NewProfileUserActivityAssembly() - let viewController = assembly.makeModule() + private func refreshUserActivityState(_ state: UserActivityState) { + switch state { + case .visible: + guard self.getSubmodule(type: NewProfile.Submodule.userActivity) == nil else { + return + } - let headerView = NewProfileBlockHeaderView() - headerView.titleText = NSLocalizedString("NewProfileBlockTitleActivity", comment: "") - headerView.isShowAllButtonHidden = true - headerView.isUserInteractionEnabled = false + let assembly = NewProfileUserActivityAssembly() + let viewController = assembly.makeModule() - let containerView = NewProfileBlockContainerView( - headerView: headerView, - contentView: viewController.view - ) + let headerView = NewProfileBlockHeaderView() + headerView.titleText = NSLocalizedString("NewProfileBlockTitleActivity", comment: "") + headerView.isShowAllButtonHidden = true + headerView.isUserInteractionEnabled = false - self.registerSubmodule( - .init( - viewController: viewController, - view: containerView, - type: NewProfile.Submodule.userActivity + let containerView = NewProfileBlockContainerView( + headerView: headerView, + contentView: viewController.view ) - ) - if let moduleInput = assembly.moduleInput { - self.interactor.doSubmodulesRegistration( - request: .init(submodules: [NewProfile.Submodule.userActivity.uniqueIdentifier: moduleInput]) + self.registerSubmodule( + .init( + viewController: viewController, + view: containerView, + type: NewProfile.Submodule.userActivity + ) ) + + if let moduleInput = assembly.moduleInput { + self.interactor.doSubmodulesRegistration( + request: .init(submodules: [NewProfile.Submodule.userActivity.uniqueIdentifier: moduleInput]) + ) + } + case .hidden: + if let submodule = self.getSubmodule(type: NewProfile.Submodule.userActivity) { + self.removeSubmodule(submodule) + } } } // MARK: Achievements - private func refreshAchievementsState() { - guard self.getSubmodule(type: NewProfile.Submodule.achievements) == nil else { - return - } + private enum AchievementsState { + case visible + case hidden + } - let assembly = NewProfileAchievementsAssembly() - let viewController = assembly.makeModule() + private func refreshAchievementsState(_ state: AchievementsState) { + switch state { + case .visible: + guard self.getSubmodule(type: NewProfile.Submodule.achievements) == nil else { + return + } - let headerView = NewProfileBlockHeaderView() - headerView.titleText = NSLocalizedString("NewProfileBlockTitleAchievements", comment: "") - headerView.onShowAllButtonClick = { [weak self] in - self?.interactor.doAchievementsListPresentation(request: .init()) - } + let assembly = NewProfileAchievementsAssembly() + let viewController = assembly.makeModule() - let containerView = NewProfileBlockContainerView( - headerView: headerView, - contentView: viewController.view - ) + let headerView = NewProfileBlockHeaderView() + headerView.titleText = NSLocalizedString("NewProfileBlockTitleAchievements", comment: "") + headerView.onShowAllButtonClick = { [weak self] in + self?.interactor.doAchievementsListPresentation(request: .init()) + } - self.registerSubmodule( - .init( - viewController: viewController, - view: containerView, - type: NewProfile.Submodule.achievements + let containerView = NewProfileBlockContainerView( + headerView: headerView, + contentView: viewController.view ) - ) - if let moduleInput = assembly.moduleInput { - self.interactor.doSubmodulesRegistration( - request: .init(submodules: [NewProfile.Submodule.achievements.uniqueIdentifier: moduleInput]) + self.registerSubmodule( + .init( + viewController: viewController, + view: containerView, + type: NewProfile.Submodule.achievements + ) ) + + if let moduleInput = assembly.moduleInput { + self.interactor.doSubmodulesRegistration( + request: .init(submodules: [NewProfile.Submodule.achievements.uniqueIdentifier: moduleInput]) + ) + } + case .hidden: + if let submodule = self.getSubmodule(type: NewProfile.Submodule.achievements) { + self.removeSubmodule(submodule) + } } } @@ -584,12 +615,12 @@ final class NewProfileViewController: UIViewController, ControllerWithStepikPlac private func refreshProfileDetailsState(viewModel: NewProfileViewModel) { if let submodule = self.getSubmodule(type: NewProfile.Submodule.details), - let profileDetailsViewController = submodule.viewController as? NewProfileDetailsViewController { + let profileDetailsViewController = submodule.viewController as? NewProfileDetailsViewController { profileDetailsViewController.newProfileDetailsView?.configure( viewModel: .init( userID: viewModel.userID, profileDetailsText: viewModel.userDetails, - isOrganization: viewModel.headerViewModel.isOrganization + isOrganization: viewModel.isOrganization ) ) } else { @@ -597,7 +628,7 @@ final class NewProfileViewController: UIViewController, ControllerWithStepikPlac let profileDetailsViewController = profileDetailsAssembly.makeModule() let headerView = NewProfileBlockHeaderView() - headerView.titleText = viewModel.headerViewModel.isOrganization + headerView.titleText = viewModel.isOrganization ? NSLocalizedString("NewProfileBlockTitleDetailsOrganization", comment: "") : NSLocalizedString("NewProfileBlockTitleDetails", comment: "") headerView.isShowAllButtonHidden = true diff --git a/Stepic/Sources/Modules/NewProfile/NewProfileViewModel.swift b/Stepic/Sources/Modules/NewProfile/NewProfileViewModel.swift index ae07b624bd..11870c8a4a 100644 --- a/Stepic/Sources/Modules/NewProfile/NewProfileViewModel.swift +++ b/Stepic/Sources/Modules/NewProfile/NewProfileViewModel.swift @@ -23,4 +23,6 @@ struct NewProfileViewModel { let userDetails: String let isCurrentUserProfile: Bool let socialProfilesCount: Int + + var isOrganization: Bool { self.headerViewModel.isOrganization } } diff --git a/Stepic/Sources/Modules/NewProfile/Views/NewProfileView.swift b/Stepic/Sources/Modules/NewProfile/Views/NewProfileView.swift index 99747b1481..39240416b9 100644 --- a/Stepic/Sources/Modules/NewProfile/Views/NewProfileView.swift +++ b/Stepic/Sources/Modules/NewProfile/Views/NewProfileView.swift @@ -49,9 +49,17 @@ final class NewProfileView: UIView { fatalError("init(coder:) has not been implemented") } - func showLoading() {} + func showLoading() { + let topContentInset = self.scrollableStackView.contentInsets.top + self.skeleton.viewBuilder = { + NewProfileHeaderViewSkeleton(topContentInset: topContentInset) + } + self.skeleton.show() + } - func hideLoading() {} + func hideLoading() { + self.skeleton.hide() + } func configure(viewModel: NewProfileViewModel) { self.storedViewModel = viewModel @@ -60,18 +68,10 @@ final class NewProfileView: UIView { // MARK: Blocks - func addBlockView(_ view: UIView) { - self.scrollableStackView.addArrangedView(view) - } - func removeBlockView(_ view: UIView) { self.scrollableStackView.removeArrangedView(view) } - func insertBlockView(_ view: UIView, at position: Int) { - self.scrollableStackView.insertArrangedView(view, at: position) - } - func insertBlockView(_ view: UIView, before previousView: UIView) { for (index, subview) in self.scrollableStackView.arrangedSubviews.enumerated() where subview === previousView { self.scrollableStackView.insertArrangedView(view, at: index) diff --git a/Stepic/Sources/Modules/NewProfileSubmodules/Achievements/NewProfileAchievementsInteractor.swift b/Stepic/Sources/Modules/NewProfileSubmodules/Achievements/NewProfileAchievementsInteractor.swift index 19cc681855..346ec04894 100644 --- a/Stepic/Sources/Modules/NewProfileSubmodules/Achievements/NewProfileAchievementsInteractor.swift +++ b/Stepic/Sources/Modules/NewProfileSubmodules/Achievements/NewProfileAchievementsInteractor.swift @@ -17,8 +17,6 @@ final class NewProfileAchievementsInteractor: NewProfileAchievementsInteractorPr private var currentAchievements = [AchievementProgressData]() private var didLoadAchievements = false - private let debouncer: DebouncerProtocol = Debouncer() - private let fetchSemaphore = DispatchSemaphore(value: 1) private lazy var fetchBackgroundQueue = DispatchQueue( label: "com.AlexKarpov.Stepic.NewProfileAchievementsInteractor.AchievementsFetch" @@ -58,7 +56,6 @@ final class NewProfileAchievementsInteractor: NewProfileAchievementsInteractorPr } } }.ensure { - strongSelf.debouncer.action = nil strongSelf.fetchSemaphore.signal() }.catch { error in print("NewProfileAchievementsInteractor :: failed fetch achievements, error = \(error)") @@ -140,9 +137,14 @@ final class NewProfileAchievementsInteractor: NewProfileAchievementsInteractorPr return .value(kinds.sorted(by: { $0.1 && !$1.1 }).map { $0.0 }) } }.then(on: .global(qos: .userInitiated)) { kinds -> Promise<[AchievementProgressData]> in + if kinds.isEmpty { + throw Error.emptyAchievementKinds + } + let fetchAchievementProgressPromises = kinds.compactMap { [weak self] kind in self?.provider.fetchAchievementProgress(userID: userID, kind: kind) } + return when(fulfilled: fetchAchievementProgressPromises) }.done { achievementsProgressesData in seal.fulfill(.init(result: .success(achievementsProgressesData))) @@ -159,6 +161,7 @@ final class NewProfileAchievementsInteractor: NewProfileAchievementsInteractorPr enum Error: Swift.Error { case networkFetchFailed + case emptyAchievementKinds } } @@ -167,10 +170,6 @@ extension NewProfileAchievementsInteractor: NewProfileSubmoduleProtocol { self.currentUserID = user.id self.isCurrentUserProfile = isCurrentUserProfile - if self.debouncer.action == nil { - self.debouncer.action = { [weak self] in - self?.doAchievementsLoad(request: .init()) - } - } + self.doAchievementsLoad(request: .init()) } } diff --git a/Stepic/Sources/Modules/NewProfileSubmodules/CreatedCourses/NewProfileCreatedCoursesViewController.swift b/Stepic/Sources/Modules/NewProfileSubmodules/CreatedCourses/NewProfileCreatedCoursesViewController.swift index 5660b67cd6..e149366230 100644 --- a/Stepic/Sources/Modules/NewProfileSubmodules/CreatedCourses/NewProfileCreatedCoursesViewController.swift +++ b/Stepic/Sources/Modules/NewProfileSubmodules/CreatedCourses/NewProfileCreatedCoursesViewController.swift @@ -15,7 +15,9 @@ final class NewProfileCreatedCoursesViewController: UIViewController, Controller private var teacherID: User.IdType? private var submoduleViewController: UIViewController? - var placeholderContainer = StepikPlaceholderControllerContainer() + var placeholderContainer = StepikPlaceholderControllerContainer( + appearance: .init(placeholderAppearance: .init(backgroundColor: .stepikGroupedBackground)) + ) init(interactor: NewProfileCreatedCoursesInteractorProtocol) { self.interactor = interactor @@ -60,7 +62,7 @@ final class NewProfileCreatedCoursesViewController: UIViewController, Controller let courseListAssembly = HorizontalCourseListAssembly( type: TeacherCourseListType(teacherID: teacherID), - colorMode: .clear, + colorMode: .grouped, courseViewSource: .profile(id: teacherID), output: self.interactor as? CourseListOutputProtocol ) diff --git a/Stepic/Sources/Modules/NewProfileSubmodules/Details/NewProfileDetailsView.swift b/Stepic/Sources/Modules/NewProfileSubmodules/Details/NewProfileDetailsView.swift index e81eeaf48e..190fcc2480 100644 --- a/Stepic/Sources/Modules/NewProfileSubmodules/Details/NewProfileDetailsView.swift +++ b/Stepic/Sources/Modules/NewProfileSubmodules/Details/NewProfileDetailsView.swift @@ -72,6 +72,11 @@ final class NewProfileDetailsView: UIView { return button }() + private var userIDTopToSuperviewConstraint: Constraint? + private var userIDTopToBottomOfSeparatorConstraint: Constraint? + + private var currentViewModel: NewProfileDetailsViewModel? + override var intrinsicContentSize: CGSize { let attributedLabelHeight = self.attributedLabel.intrinsicContentSize.height let separatorHeightWithInsets = self.appearance.separatorInsets.top + self.appearance.separatorHeight @@ -84,14 +89,15 @@ final class NewProfileDetailsView: UIView { ) } - private var lastViewModel: NewProfileDetailsViewModel? - init( frame: CGRect = .zero, appearance: Appearance = Appearance() ) { self.appearance = appearance - self.htmlToAttributedStringConverter = HTMLToAttributedStringConverter(font: appearance.labelFont) + self.htmlToAttributedStringConverter = HTMLToAttributedStringConverter( + font: appearance.labelFont, + tagTransformers: [] + ) super.init(frame: frame) self.setupView() @@ -105,31 +111,36 @@ final class NewProfileDetailsView: UIView { } func configure(viewModel: NewProfileDetailsViewModel) { - self.setText(viewModel.profileDetailsText) + if let text = viewModel.profileDetailsText { + self.attributedLabel.attributedText = self.htmlToAttributedStringConverter.convertToAttributedText( + htmlString: text.trimmed() + ) as? AttributedText + } else { + self.attributedLabel.attributedText = nil + } let formattedUserID = viewModel.isOrganization ? "Organization ID: \(viewModel.userID)" : "User ID: \(viewModel.userID)" self.userIDButton.setTitle(formattedUserID, for: .normal) - self.lastViewModel = viewModel - self.invalidateIntrinsicContentSize() - } - - private func setText(_ text: String?) { - if let text = text { - let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines) - self.attributedLabel.attributedText = self.htmlToAttributedStringConverter.convertToAttributedText( - htmlString: trimmedText - ) as? AttributedText + if self.attributedLabel.attributedText?.string.isEmpty ?? true { + self.userIDTopToBottomOfSeparatorConstraint?.deactivate() + self.userIDTopToSuperviewConstraint?.activate() + self.separatorView.isHidden = true } else { - self.attributedLabel.attributedText = nil + self.userIDTopToBottomOfSeparatorConstraint?.activate() + self.userIDTopToSuperviewConstraint?.deactivate() + self.separatorView.isHidden = false } + + self.currentViewModel = viewModel + self.invalidateIntrinsicContentSize() } @objc private func userIDButtonClicked() { - if let lastViewModel = self.lastViewModel { + if let lastViewModel = self.currentViewModel { self.delegate?.newProfileDetailsView(self, didSelectUserID: lastViewModel.userID) } } @@ -166,9 +177,14 @@ extension NewProfileDetailsView: ProgrammaticallyInitializableViewProtocol { self.userIDButton.translatesAutoresizingMaskIntoConstraints = false self.userIDButton.snp.makeConstraints { make in - make.top + self.userIDTopToBottomOfSeparatorConstraint = make.top .equalTo(self.separatorView.snp.bottom) .offset(self.appearance.userIDButtonInsets.top) + .constraint + + self.userIDTopToSuperviewConstraint = make.top.equalToSuperview().constraint + self.userIDTopToSuperviewConstraint?.deactivate() + make.bottom .equalToSuperview() .offset(-self.appearance.userIDButtonInsets.bottom) diff --git a/Stepic/Sources/Modules/NewProfileSubmodules/SocialProfiles/NewProfileSocialProfilesInteractor.swift b/Stepic/Sources/Modules/NewProfileSubmodules/SocialProfiles/NewProfileSocialProfilesInteractor.swift index 7cc32c8b03..af0e80f13d 100644 --- a/Stepic/Sources/Modules/NewProfileSubmodules/SocialProfiles/NewProfileSocialProfilesInteractor.swift +++ b/Stepic/Sources/Modules/NewProfileSubmodules/SocialProfiles/NewProfileSocialProfilesInteractor.swift @@ -14,7 +14,7 @@ final class NewProfileSocialProfilesInteractor: NewProfileSocialProfilesInteract private var isOnline = false private var didLoadFromCache = false - private var didLoadFromRemote = false + private var didLoadFromNetwork = false private let fetchSemaphore = DispatchSemaphore(value: 1) private lazy var fetchBackgroundQueue = DispatchQueue( @@ -42,12 +42,12 @@ final class NewProfileSocialProfilesInteractor: NewProfileSocialProfilesInteract strongSelf.fetchSemaphore.wait() let hasEqualIDs = Set(user.socialProfilesArray) == strongSelf.currentSocialProfilesIDs - if !request.forceUpdate && strongSelf.didLoadFromRemote && hasEqualIDs { + if !request.forceUpdate && strongSelf.didLoadFromNetwork && hasEqualIDs { strongSelf.fetchSemaphore.signal() return } - let isOnline = strongSelf.isOnline + let isOnline = request.forceUpdate ? true : strongSelf.isOnline print("NewProfileSocialProfilesInteractor :: start fetching social profile, isOnline = \(isOnline)") strongSelf.fetchSocialProfilesInAppropriateMode( @@ -57,11 +57,17 @@ final class NewProfileSocialProfilesInteractor: NewProfileSocialProfilesInteract ).done { response in DispatchQueue.main.async { print("NewProfileSocialProfilesInteractor :: finish fetching, isOnline = \(isOnline)") - strongSelf.presenter.presentSocialProfiles(response: response) + switch response.result { + case .success: + strongSelf.presenter.presentSocialProfiles(response: response) + case .failure: + break + } } }.ensure { if !strongSelf.didLoadFromCache { strongSelf.didLoadFromCache = true + strongSelf.isOnline = true strongSelf.doSocialProfilesLoad(request: .init()) } strongSelf.fetchSemaphore.signal() @@ -79,22 +85,28 @@ final class NewProfileSocialProfilesInteractor: NewProfileSocialProfilesInteract isOnline: Bool ) -> Promise { Promise { seal in + let shouldFetchRemote = isOnline && self.didLoadFromCache firstly { - isOnline && self.didLoadFromCache + shouldFetchRemote ? self.provider.fetchRemote(ids: ids, userID: userID) : self.provider.fetchCached(ids: ids, userID: userID) }.done { socialProfiles in - if isOnline && self.didLoadFromCache && !self.didLoadFromRemote { - self.didLoadFromRemote = true + if shouldFetchRemote && !self.didLoadFromNetwork { + self.didLoadFromNetwork = true } self.currentSocialProfilesIDs = Set(socialProfiles.map(\.id)) - seal.fulfill(.init(result: .success(socialProfiles))) + // There are no social profiles in cache, ignore and wait for network response. + if self.currentSocialProfilesIDs.isEmpty && !shouldFetchRemote { + seal.fulfill(.init(result: .failure(Error.emptyCache))) + } else { + seal.fulfill(.init(result: .success(socialProfiles))) + } }.catch { error in if case NewProfileSocialProfilesProvider.Error.networkFetchFailed = error, - self.didLoadFromCache, - !self.currentSocialProfilesIDs.isEmpty { + self.didLoadFromCache, + !self.currentSocialProfilesIDs.isEmpty { // Offline mode: we already presented cached social profiles, but network request failed // so let's ignore it and show only cached seal.fulfill(.init(result: .failure(Error.networkFetchFailed))) @@ -107,6 +119,8 @@ final class NewProfileSocialProfilesInteractor: NewProfileSocialProfilesInteract enum Error: Swift.Error { case networkFetchFailed + case fetchFailed + case emptyCache } } diff --git a/Stepic/Sources/Modules/NewProfileSubmodules/SocialProfiles/NewProfileSocialProfilesViewController.swift b/Stepic/Sources/Modules/NewProfileSubmodules/SocialProfiles/NewProfileSocialProfilesViewController.swift index e411c186c3..579e8b23bb 100644 --- a/Stepic/Sources/Modules/NewProfileSubmodules/SocialProfiles/NewProfileSocialProfilesViewController.swift +++ b/Stepic/Sources/Modules/NewProfileSubmodules/SocialProfiles/NewProfileSocialProfilesViewController.swift @@ -71,6 +71,7 @@ final class NewProfileSocialProfilesViewController: UIViewController, Controller switch newState { case .result(let viewModel): + self.isPlaceholderShown = false self.socialProfilesView?.configure(viewModel: viewModel) case .error: self.showPlaceholder(for: .connectionError)