diff --git a/Gemfile b/Gemfile index 624e588fad..6dfe80816a 100644 --- a/Gemfile +++ b/Gemfile @@ -1,7 +1,7 @@ source "https://rubygems.org" ruby "2.6.5" -gem "fastlane", "2.184.0" +gem "fastlane", "2.184.1" gem "cocoapods", "1.10.1" gem "generamba", "1.5.0" diff --git a/Gemfile.lock b/Gemfile.lock index 99bd119e96..deddef4cab 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -15,7 +15,7 @@ GEM artifactory (3.0.15) atomos (0.1.3) aws-eventstream (1.1.1) - aws-partitions (1.461.0) + aws-partitions (1.465.0) aws-sdk-core (3.114.0) aws-eventstream (~> 1, >= 1.0.2) aws-partitions (~> 1, >= 1.239.0) @@ -24,7 +24,7 @@ GEM aws-sdk-kms (1.43.0) aws-sdk-core (~> 3, >= 3.112.0) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.95.0) + aws-sdk-s3 (1.95.1) aws-sdk-core (~> 3, >= 3.112.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.1) @@ -84,7 +84,7 @@ GEM escape (0.0.4) ethon (0.14.0) ffi (>= 1.15.0) - excon (0.81.0) + excon (0.82.0) faraday (1.4.2) faraday-em_http (~> 1.0) faraday-em_synchrony (~> 1.0) @@ -104,7 +104,7 @@ GEM faraday_middleware (1.0.0) faraday (~> 1.0) fastimage (2.2.3) - fastlane (2.184.0) + fastlane (2.184.1) CFPropertyList (>= 2.3, < 4.0.0) addressable (>= 2.3, < 3.0.0) artifactory (~> 3.0) @@ -155,7 +155,7 @@ GEM xcodeproj (>= 1.5.0, < 2.0.0) gh_inspector (1.1.3) git (1.2.9.1) - google-apis-androidpublisher_v3 (0.3.0) + google-apis-androidpublisher_v3 (0.4.0) google-apis-core (~> 0.1) google-apis-core (0.3.0) addressable (~> 2.5, >= 2.5.1) @@ -167,11 +167,11 @@ GEM rexml signet (~> 0.14) webrick - google-apis-iamcredentials_v1 (0.3.0) + google-apis-iamcredentials_v1 (0.4.0) google-apis-core (~> 0.1) - google-apis-playcustomapp_v1 (0.2.0) + google-apis-playcustomapp_v1 (0.3.0) google-apis-core (~> 0.1) - google-apis-storage_v1 (0.3.0) + google-apis-storage_v1 (0.4.0) google-apis-core (~> 0.1) google-cloud-core (1.6.0) google-cloud-env (~> 1.0) @@ -273,7 +273,7 @@ PLATFORMS DEPENDENCIES cocoapods (= 1.10.1) - fastlane (= 2.184.0) + fastlane (= 2.184.1) fastlane-plugin-firebase_app_distribution generamba (= 1.5.0) diff --git a/Stepic.xcodeproj/project.pbxproj b/Stepic.xcodeproj/project.pbxproj index c17fe53534..a19e8fa81b 100644 --- a/Stepic.xcodeproj/project.pbxproj +++ b/Stepic.xcodeproj/project.pbxproj @@ -8,6 +8,8 @@ /* Begin PBXBuildFile section */ 008437078C67C7149BE6DE53 /* DownloadARQuickLookViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BCD8888462BD21ECF82C602 /* DownloadARQuickLookViewController.swift */; }; + 03B85109D5524A87EF421737 /* UserCoursesReviewsBlockAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC5ECBB626DE50DBB703854D /* UserCoursesReviewsBlockAssembly.swift */; }; + 03DCDF023C3BC6656039CD69 /* UserCoursesReviewsPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E3F2BCCADA04E9DF30E59D8 /* UserCoursesReviewsPresenter.swift */; }; 04098B05AEAAF838AC6A1F66 /* CatalogBlocksAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 511B5A8F33033E182CFE4949 /* CatalogBlocksAssembly.swift */; }; 062AB834A48F37524A84D976 /* NewProfileInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69DE4213EB9F9BB5C2C25E78 /* NewProfileInteractor.swift */; }; 0791DFE4B73F0C765044006C /* FillBlanksQuizInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F6D07472E32C2DC0EE070CE /* FillBlanksQuizInteractor.swift */; }; @@ -347,12 +349,14 @@ 10EE105C3899569C32A7E24C /* NewProfileAchievementsAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BA42C58C967F7FED2305306 /* NewProfileAchievementsAssembly.swift */; }; 1232DCE0E54B310C809D031C /* LessonFinishedDemoPanModalDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FB20549B185EBFAFD229A56 /* LessonFinishedDemoPanModalDataFlow.swift */; }; 12CB070461601452E35A6334 /* NewProfileUserActivityViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27CF7A377B4C8BC247ECBD29 /* NewProfileUserActivityViewController.swift */; }; + 135A33403FC045E6FA7C6EA9 /* UserCoursesReviewsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0D427267EAF2E5D511C1A6C /* UserCoursesReviewsProvider.swift */; }; 1403FF0C9A5259CF2D63D98C /* NewProfileUserActivityProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4E46A7B951582DFB9DA8130 /* NewProfileUserActivityProvider.swift */; }; 14AD2486D6A2159C56EA464E /* LessonFinishedDemoPanModalOutputProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2C30C27A2878BBE9557F5BA /* LessonFinishedDemoPanModalOutputProtocol.swift */; }; 19BCC6AE85FA7F15895BFDD0 /* NewProfileCertificatesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D43830C1F0B97682B9CC38DB /* NewProfileCertificatesView.swift */; }; 19EF931AB14F0F3BEC303CD5 /* LessonFinishedStepsPanModalInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D61DBD05355014717A35F04 /* LessonFinishedStepsPanModalInteractor.swift */; }; 1C748C5B088908FB323176B6 /* NewProfileSocialProfilesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9B98DD75AFD25B4028D8A48 /* NewProfileSocialProfilesProvider.swift */; }; 209E27FAF79E14028BA3E27E /* NewProfileStreakNotificationsDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0135D703CAD3B1C16BC95B63 /* NewProfileStreakNotificationsDataFlow.swift */; }; + 21FB2FC61272B623B47A457F /* UserCoursesReviewsBlockPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E3F17A80C27FC320C04904E /* UserCoursesReviewsBlockPresenter.swift */; }; 224F80151254989529458041 /* NewProfileCreatedCoursesAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A5A3AC14BE54DE59AD0C0DE /* NewProfileCreatedCoursesAssembly.swift */; }; 23A9551EEBE3A772D03905A6 /* CatalogBlocksOutputProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D00C7451ED9DEC94AA85DD4 /* CatalogBlocksOutputProtocol.swift */; }; 2431A273489AF7D40AB21348 /* NewProfileStreakNotificationsInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B9DB0A6C0B38CE13676ED50 /* NewProfileStreakNotificationsInteractor.swift */; }; @@ -703,6 +707,8 @@ 2C82029A26371855002C2C37 /* LessonFinishedStepsPanModalViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C82029926371855002C2C37 /* LessonFinishedStepsPanModalViewModel.swift */; }; 2C82D51721830DD500C10805 /* NotificationsRegistrationServiceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C82D51621830DD500C10805 /* NotificationsRegistrationServiceProtocol.swift */; }; 2C82F22421D64DFF00E0CB2A /* CourseInfoTabSyllabusCellSkeletonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C82F22321D64DFF00E0CB2A /* CourseInfoTabSyllabusCellSkeletonView.swift */; }; + 2C83B543265FA04B006E1CC5 /* CourseReviewPlainObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C83B542265FA04B006E1CC5 /* CourseReviewPlainObject.swift */; }; + 2C83B545265FB572006E1CC5 /* UserCoursesReviewsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C83B544265FB572006E1CC5 /* UserCoursesReviewsViewModel.swift */; }; 2C83E20C2549754400D0C1F3 /* SettingsRightDetailCheckboxTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C83E20B2549754400D0C1F3 /* SettingsRightDetailCheckboxTableViewCell.swift */; }; 2C83E215254975B200D0C1F3 /* SettingsRightDetailCheckboxCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C83E214254975B200D0C1F3 /* SettingsRightDetailCheckboxCellView.swift */; }; 2C85C6A522D38A3800FDBAFE /* VotesNetworkService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C85C6A422D38A3800FDBAFE /* VotesNetworkService.swift */; }; @@ -788,6 +794,9 @@ 2CA3E8AF24C173C600FA3059 /* CourseInfoTabInfoAboutBlockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CA3E8AE24C173C600FA3059 /* CourseInfoTabInfoAboutBlockView.swift */; }; 2CA47D4A248ABF0E00925335 /* LogoutDataClearService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CA47D49248ABF0E00925335 /* LogoutDataClearService.swift */; }; 2CA51BB024AB9B4300A400AE /* NewProfileUserActivityViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CA51BAF24AB9B4300A400AE /* NewProfileUserActivityViewModel.swift */; }; + 2CA6A1E6266624F4000AAA90 /* ReviewsAndWishlistContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CA6A1E5266624F4000AAA90 /* ReviewsAndWishlistContainerViewController.swift */; }; + 2CA6A1E82666551F000AAA90 /* UserCoursesReviewsBlockViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CA6A1E72666551F000AAA90 /* UserCoursesReviewsBlockViewModel.swift */; }; + 2CA6A1EA26665AC2000AAA90 /* UserCoursesReviewsBlockSkeletonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CA6A1E926665AC2000AAA90 /* UserCoursesReviewsBlockSkeletonView.swift */; }; 2CA785F32592689400873CB3 /* StepStudentDisabledView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CA785F22592689400873CB3 /* StepStudentDisabledView.swift */; }; 2CA867C12588FFF40006576E /* GridSimpleCourseListCollectionHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CA867C02588FFF40006576E /* GridSimpleCourseListCollectionHeaderView.swift */; }; 2CA867C925892AE70006576E /* GridSimpleCourseListCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CA867C825892AE70006576E /* GridSimpleCourseListCollectionViewCell.swift */; }; @@ -914,6 +923,8 @@ 2CC3519C1F6837B4004255B6 /* RegistrationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CC3519B1F6837B4004255B6 /* RegistrationViewController.swift */; }; 2CC3519D1F683E7C004255B6 /* AuthNavigationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08D674921D78B46900B60963 /* AuthNavigationViewController.swift */; }; 2CC38AB025750A5700BE3826 /* AuthorsCourseListWidgetRatingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CC38AAF25750A5700BE3826 /* AuthorsCourseListWidgetRatingView.swift */; }; + 2CC4FD5C2664CBA000A33178 /* UserCoursesReviewsPossibleReviewCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CC4FD5B2664CBA000A33178 /* UserCoursesReviewsPossibleReviewCellView.swift */; }; + 2CC4FD5F2664EB2D00A33178 /* UserCoursesReviewsTableSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CC4FD5E2664EB2D00A33178 /* UserCoursesReviewsTableSectionView.swift */; }; 2CC5AA6C242A34C500C09F94 /* RecommendationsAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CC5AA6B242A34C500C09F94 /* RecommendationsAPI.swift */; }; 2CC5AA70242A34FA00C09F94 /* AdaptiveRatingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CC5AA6D242A34F900C09F94 /* AdaptiveRatingManager.swift */; }; 2CC5AA71242A34FA00C09F94 /* AdaptiveStatsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CC5AA6E242A34F900C09F94 /* AdaptiveStatsManager.swift */; }; @@ -1002,6 +1013,10 @@ 2CEDC65A260893E500B0B018 /* CourseRecommendationsNetworkService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CEDC659260893E500B0B018 /* CourseRecommendationsNetworkService.swift */; }; 2CEDC661260898F700B0B018 /* PlatformType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CEDC660260898F700B0B018 /* PlatformType.swift */; }; 2CEDC67226089DAE00B0B018 /* PlatformTypeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CEDC67126089DAE00B0B018 /* PlatformTypeTests.swift */; }; + 2CEE66AA2661123C0079F03B /* UserCoursesReviewsTableViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CEE66A92661123C0079F03B /* UserCoursesReviewsTableViewDataSource.swift */; }; + 2CEE66AD266116AC0079F03B /* UserCoursesReviewsPossibleReviewTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CEE66AC266116AC0079F03B /* UserCoursesReviewsPossibleReviewTableViewCell.swift */; }; + 2CEE66AF266116BD0079F03B /* UserCoursesReviewsLeavedReviewTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CEE66AE266116BD0079F03B /* UserCoursesReviewsLeavedReviewTableViewCell.swift */; }; + 2CEE66B32661180E0079F03B /* UserCoursesReviewsLeavedReviewCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CEE66B22661180E0079F03B /* UserCoursesReviewsLeavedReviewCellView.swift */; }; 2CF0848F244EF5750062EDB6 /* StepARQuickLookPreviewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CF0848E244EF5750062EDB6 /* StepARQuickLookPreviewDataSource.swift */; }; 2CF0885A205BEBF500FCB9C0 /* StepikTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CF08859205BEBF500FCB9C0 /* StepikTableView.swift */; }; 2CF0885D205BED9700FCB9C0 /* StepikPlaceholderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 2CF0885C205BED9700FCB9C0 /* StepikPlaceholderView.xib */; }; @@ -1048,14 +1063,17 @@ 2CFED66E252C649400FCAD41 /* DiscussionsTableViewDataSourceDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CFED66D252C649400FCAD41 /* DiscussionsTableViewDataSourceDelegate.swift */; }; 2CFF2B21260B3AAA0040821A /* WebCacheCleaner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CFF2B20260B3AAA0040821A /* WebCacheCleaner.swift */; }; 3203AD6A1594995EDE114EA0 /* Pods_StepicTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 938533EAAD61D57EF139C60C /* Pods_StepicTests.framework */; }; + 3547E584AFEE09996EB90808 /* UserCoursesReviewsOutputProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A64AF153016C7406F9244FD9 /* UserCoursesReviewsOutputProtocol.swift */; }; 354FA57C59AEF5BEFCEE2936 /* AuthorsCourseListProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A02FE3CE59DBF4D12871ED55 /* AuthorsCourseListProvider.swift */; }; 375F2EB161DE5DAAED3FEA98 /* NewProfileStreakNotificationsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3846EE2E0A7FD5405D8D1AF /* NewProfileStreakNotificationsViewController.swift */; }; 3817EBF30A4CB97036ABA9B7 /* TableQuizAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E5C2A5CDF6CD9FA686E5D06 /* TableQuizAssembly.swift */; }; 38C0348C761EF7B1032835EE /* FillBlanksQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 079BEAD644C47C1E37629DAF /* FillBlanksQuizView.swift */; }; 39AB2324EB824124A83D7534 /* UserCoursesInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09AF7B8FAACF8A4D31AF7583 /* UserCoursesInteractor.swift */; }; + 3B56681D248C577ED6D9491F /* UserCoursesReviewsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB9EABC3559C7152B7A21484 /* UserCoursesReviewsView.swift */; }; 3D1926CF24EF88C0E8DD4810 /* LessonFinishedStepsPanModalDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81AA980E65D068EB9A20F675 /* LessonFinishedStepsPanModalDataFlow.swift */; }; 3E467D12F5BA4AE24B7BB030 /* FillBlanksQuizPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40D8B41CD0EE056CA3361EEB /* FillBlanksQuizPresenter.swift */; }; 3F8A824B6CDEA20CEC0DCC4F /* LessonFinishedDemoPanModalProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED3EA298A00E3C1DF87D33A7 /* LessonFinishedDemoPanModalProvider.swift */; }; + 4411DBAC80EEAF128EE11E61 /* UserCoursesReviewsBlockInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5640965B54B334D3C660E01 /* UserCoursesReviewsBlockInteractor.swift */; }; 46115BB4590732AAB387046D /* NewProfileUserActivityAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C03F8E163838C4E026CAE5 /* NewProfileUserActivityAssembly.swift */; }; 46A0BB292879D0FB31A504E7 /* CatalogBlocksInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA9ABFDBF465C10326697700 /* CatalogBlocksInteractor.swift */; }; 48B0EC48DA9989013F58FB2A /* CourseListFilterInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AA93A7BBE66130A3D60D17A /* CourseListFilterInteractor.swift */; }; @@ -1552,6 +1570,7 @@ 6ADEFFDE33B06A7049CF8E2F /* NewProfileCertificatesAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CC925F9685393FA44977549 /* NewProfileCertificatesAssembly.swift */; }; 6D3288F90505044F2DB76FA8 /* SubmissionsFilterDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8336942AE67DE9A75C7FC37 /* SubmissionsFilterDataFlow.swift */; }; 6FFCCAD4A767D51713215282 /* SubmissionsFilterAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E59399897BC3C31A430FA2B /* SubmissionsFilterAssembly.swift */; }; + 71832D1CCA2BF7D78AF0CB60 /* UserCoursesReviewsBlockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DDFFE939BC2A621E0E1FAD3 /* UserCoursesReviewsBlockView.swift */; }; 725A032976CEA104C4F958AC /* CatalogBlocksViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4356ADE389830A4DB65CC0D7 /* CatalogBlocksViewController.swift */; }; 7356E585097D33A455C5D212 /* NewProfileAchievementsInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4B98B071C1C8E3FB13F9717 /* NewProfileAchievementsInteractor.swift */; }; 79A20D8F6931BCF4BDD7656E /* NewProfileUserActivityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72D1EE8518225C9F49BE44EA /* NewProfileUserActivityView.swift */; }; @@ -1562,6 +1581,7 @@ 7ED87AD657E488FBD3C0D88E /* StepikAcademyCourseListDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A25E416101A848548101C7DA /* StepikAcademyCourseListDataFlow.swift */; }; 8489EE7725FAF13B004A85C5 /* StepicUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8489EE7625FAF13B004A85C5 /* StepicUITests.swift */; }; 8489EEE425FFBE8E004A85C5 /* Common.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8489EEE325FFBE8E004A85C5 /* Common.swift */; }; + 85CFA575736E6C0570D48065 /* UserCoursesReviewsInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3ED7422D4B76AAD73F35E6E /* UserCoursesReviewsInteractor.swift */; }; 861B96371FE1DF7F00773EDA /* CAGradientLayer+Init.swift in Sources */ = {isa = PBXBuildFile; fileRef = 861B96361FE1DF7F00773EDA /* CAGradientLayer+Init.swift */; }; 8622056B2055561F00F14255 /* PinsMapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8622056A2055561F00F14255 /* PinsMapView.swift */; }; 86356B693AF04C13CE3C856F /* UserCoursesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAF219462BE0AB704DED8077 /* UserCoursesView.swift */; }; @@ -1581,6 +1601,7 @@ 89232565DB802FFBC4D97BCD /* AuthorsCourseListInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFA819F60D02BFA28BBD75EF /* AuthorsCourseListInteractor.swift */; }; 8A0F5D90705B7837864ECB6E /* CatalogBlocksProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12E92785106799D1BD990B14 /* CatalogBlocksProvider.swift */; }; 8A248387CF216CCCFFC39E76 /* NewProfileUserActivityDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFD58CADEE08103A32F03B4D /* NewProfileUserActivityDataFlow.swift */; }; + 8B096A7617D4999095F0DCD1 /* UserCoursesReviewsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9B5E3A57F5CDE395E9306E0 /* UserCoursesReviewsViewController.swift */; }; 8B172316D4E6435D8AB2AEE8 /* CourseListFilterDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F7E50C377F0BC1E2DF61326 /* CourseListFilterDataFlow.swift */; }; 8B9418BEBCCD97593E303719 /* StepikAcademyCourseListAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7380FB54898647E5E6B699C /* StepikAcademyCourseListAssembly.swift */; }; 8E5A0978488591A50B3FB3A6 /* TableQuizInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FE204B976936C0DE5174D02 /* TableQuizInteractor.swift */; }; @@ -1588,15 +1609,18 @@ 9290A9228A9C0AF5A25850A8 /* CatalogBlocksPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2459AF7EE2D2D9797E89FC9A /* CatalogBlocksPresenter.swift */; }; 942F44578E193384B1E4DAD9 /* UserCoursesPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 582946156484A5655EF72E43 /* UserCoursesPresenter.swift */; }; 9501A244263B1A63587A4111 /* NewProfileSocialProfilesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC0BA4BBDDD434836B383B7A /* NewProfileSocialProfilesViewController.swift */; }; + 967846575CD3702AF484AF52 /* UserCoursesReviewsDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 858D7264845A798247AE1350 /* UserCoursesReviewsDataFlow.swift */; }; 96A3827B7E57E4B055221311 /* NewProfileUserActivityPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9217C4CCCAFEF3BDE7DD27A5 /* NewProfileUserActivityPresenter.swift */; }; 98230689D5C33F2FF95CD633 /* StepikAcademyCourseListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44CA1414D85B7D7645957E8F /* StepikAcademyCourseListViewController.swift */; }; 9953B53F378A70879B2FCDC7 /* SimpleCourseListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8CDB43E62B13FFD968CF503 /* SimpleCourseListView.swift */; }; + 9A1785762A6B8F97FE347121 /* UserCoursesReviewsBlockDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06CBC774ED03C2B9755265F7 /* UserCoursesReviewsBlockDataFlow.swift */; }; 9BA64E04E1D50A89885BFACD /* LessonFinishedDemoPanModalAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = F68EADA2E738B56A7547D295 /* LessonFinishedDemoPanModalAssembly.swift */; }; 9E98A1AF097008C236DF309D /* LessonFinishedDemoPanModalPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC51547B4BFE999EA2BF12FC /* LessonFinishedDemoPanModalPresenter.swift */; }; 9FAD76150240B4F8C13ADFA2 /* NewProfileUserActivityInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53EE6AAA8E539D30FAF22B0B /* NewProfileUserActivityInteractor.swift */; }; A2E34812AB4F1593A4EB413B /* CourseListFilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25A02BBBBC283CED244F6983 /* CourseListFilterView.swift */; }; A4ED8B7CACFF7AB41B163E79 /* NewProfileSocialProfilesInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E358732932D647EFD60F96E8 /* NewProfileSocialProfilesInteractor.swift */; }; A826D894A04FB5E2C37490B3 /* DebugMenuAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01172B34FA1118BF9A9A0A18 /* DebugMenuAssembly.swift */; }; + A8D492721CB2DAB7300BDFAE /* UserCoursesReviewsBlockInputProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F2D9C81EAADC493838F5DF9 /* UserCoursesReviewsBlockInputProtocol.swift */; }; AA4038BFF0FB9031371EBB38 /* NewProfileCreatedCoursesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FBE5DD451A3D87D0E23671D3 /* NewProfileCreatedCoursesViewController.swift */; }; AAB4F45B011C05D59E6EEEEB /* DownloadARQuickLookPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61DCDAA39E6747A12CC239E3 /* DownloadARQuickLookPresenter.swift */; }; ADDBDEC3E1355186610E10B2 /* NewProfileSocialProfilesPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18044FE3F18268E484D32CE6 /* NewProfileSocialProfilesPresenter.swift */; }; @@ -1604,6 +1628,7 @@ B4A54A3274B3A91CCBE2DED9 /* AuthorsCourseListOutputProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 342AEC970102A4B3CA9FD0DC /* AuthorsCourseListOutputProtocol.swift */; }; B9981FBB642874661D5AC922 /* SimpleCourseListProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F9C45B2B7960506F756CDE59 /* SimpleCourseListProvider.swift */; }; BAF3545D8CC66168A37E2A58 /* LessonFinishedStepsPanModalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EE4ED6E9648215F1B416059 /* LessonFinishedStepsPanModalView.swift */; }; + BD1C9F8F65E99FCF6959F92A /* UserCoursesReviewsAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = B26B27DECDA72A6EF6835559 /* UserCoursesReviewsAssembly.swift */; }; BF08E100FC52E10D3D085BE7 /* AuthorsCourseListAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95B4F28C7AA736A30433FE00 /* AuthorsCourseListAssembly.swift */; }; BF27AF2C4494791ED729A180 /* DebugMenuDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AF24E512EA999A0AB92E8A6 /* DebugMenuDataFlow.swift */; }; C05E2F62D3F01907A1F6F914 /* CatalogBlocksDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB3FB0B427E038E36BA99318 /* CatalogBlocksDataFlow.swift */; }; @@ -1632,7 +1657,9 @@ E3FA6135B20135AF33E347C2 /* LessonFinishedDemoPanModalInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BF050BD34E0B0A6DE33AE83 /* LessonFinishedDemoPanModalInteractor.swift */; }; E50909BCE8F32A2EB817ED1B /* SubmissionsFilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D71CBD4CD4E534E50BEFFFC /* SubmissionsFilterView.swift */; }; E68D5D39475BA651C54FD4C9 /* NewProfileStreakNotificationsAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05F0E49F1F2245F037EE6305 /* NewProfileStreakNotificationsAssembly.swift */; }; + EC2BD2389537AF3BD14D51EA /* UserCoursesReviewsBlockViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5EFB9BB0FE158509DFEE4CB /* UserCoursesReviewsBlockViewController.swift */; }; ECD21878641B360C12E8C585 /* NewProfileCreatedCoursesOutputProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD4A385A37C2127557150147 /* NewProfileCreatedCoursesOutputProtocol.swift */; }; + EFAD92E3201AC22530847975 /* UserCoursesReviewsBlockProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59E13FD794C7B66976D1C5A2 /* UserCoursesReviewsBlockProvider.swift */; }; F26EBB85670ECB562C65D84C /* DownloadARQuickLookInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC129A0368739A641E6ED26A /* DownloadARQuickLookInteractor.swift */; }; F3E3754E1307F57F39624528 /* FillBlanksQuizViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52DE5FD0F368D7B48B0E3B5E /* FillBlanksQuizViewController.swift */; }; F40280C566FFF3A1E7E1D1A3 /* StepikAcademyCourseListPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 424840FC832A3CF79ACD98CF /* StepikAcademyCourseListPresenter.swift */; }; @@ -1696,6 +1723,7 @@ 052EBB6A9C3BB54E11625E38 /* Pods-Stepic.release release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Stepic.release release.xcconfig"; path = "Target Support Files/Pods-Stepic/Pods-Stepic.release release.xcconfig"; sourceTree = ""; }; 05F0E49F1F2245F037EE6305 /* NewProfileStreakNotificationsAssembly.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NewProfileStreakNotificationsAssembly.swift; sourceTree = ""; }; 06455AE519D17C1948944F0E /* FillBlanksQuizAssembly.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FillBlanksQuizAssembly.swift; sourceTree = ""; }; + 06CBC774ED03C2B9755265F7 /* UserCoursesReviewsBlockDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserCoursesReviewsBlockDataFlow.swift; sourceTree = ""; }; 079BEAD644C47C1E37629DAF /* FillBlanksQuizView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FillBlanksQuizView.swift; sourceTree = ""; }; 0800B8171D06D961006C987E /* DiscussionProxy.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DiscussionProxy.swift; sourceTree = ""; }; 0800B81A1D06DC1B006C987E /* Comment.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Comment.swift; sourceTree = ""; }; @@ -2464,6 +2492,8 @@ 2C82D51621830DD500C10805 /* NotificationsRegistrationServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsRegistrationServiceProtocol.swift; sourceTree = ""; }; 2C82F22321D64DFF00E0CB2A /* CourseInfoTabSyllabusCellSkeletonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseInfoTabSyllabusCellSkeletonView.swift; sourceTree = ""; }; 2C8345D123156D5E00E6CE91 /* Model_course_can_continue.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Model_course_can_continue.xcdatamodel; sourceTree = ""; }; + 2C83B542265FA04B006E1CC5 /* CourseReviewPlainObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseReviewPlainObject.swift; sourceTree = ""; }; + 2C83B544265FB572006E1CC5 /* UserCoursesReviewsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserCoursesReviewsViewModel.swift; sourceTree = ""; }; 2C83E20B2549754400D0C1F3 /* SettingsRightDetailCheckboxTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsRightDetailCheckboxTableViewCell.swift; sourceTree = ""; }; 2C83E214254975B200D0C1F3 /* SettingsRightDetailCheckboxCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsRightDetailCheckboxCellView.swift; sourceTree = ""; }; 2C84D42B24AFFCD300A77EF1 /* Model_new_profile_v53.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Model_new_profile_v53.xcdatamodel; sourceTree = ""; }; @@ -2552,6 +2582,9 @@ 2CA3E8AE24C173C600FA3059 /* CourseInfoTabInfoAboutBlockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseInfoTabInfoAboutBlockView.swift; sourceTree = ""; }; 2CA47D49248ABF0E00925335 /* LogoutDataClearService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogoutDataClearService.swift; sourceTree = ""; }; 2CA51BAF24AB9B4300A400AE /* NewProfileUserActivityViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewProfileUserActivityViewModel.swift; sourceTree = ""; }; + 2CA6A1E5266624F4000AAA90 /* ReviewsAndWishlistContainerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewsAndWishlistContainerViewController.swift; sourceTree = ""; }; + 2CA6A1E72666551F000AAA90 /* UserCoursesReviewsBlockViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserCoursesReviewsBlockViewModel.swift; sourceTree = ""; }; + 2CA6A1E926665AC2000AAA90 /* UserCoursesReviewsBlockSkeletonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserCoursesReviewsBlockSkeletonView.swift; sourceTree = ""; }; 2CA785EE2592238F00873CB3 /* Model_steps_is_enabled_v70.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Model_steps_is_enabled_v70.xcdatamodel; sourceTree = ""; }; 2CA785F22592689400873CB3 /* StepStudentDisabledView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepStudentDisabledView.swift; sourceTree = ""; }; 2CA867C02588FFF40006576E /* GridSimpleCourseListCollectionHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridSimpleCourseListCollectionHeaderView.swift; sourceTree = ""; }; @@ -2689,6 +2722,8 @@ 2CC351991F68339A004255B6 /* AuthTextField.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthTextField.swift; sourceTree = ""; }; 2CC3519B1F6837B4004255B6 /* RegistrationViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RegistrationViewController.swift; sourceTree = ""; }; 2CC38AAF25750A5700BE3826 /* AuthorsCourseListWidgetRatingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorsCourseListWidgetRatingView.swift; sourceTree = ""; }; + 2CC4FD5B2664CBA000A33178 /* UserCoursesReviewsPossibleReviewCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserCoursesReviewsPossibleReviewCellView.swift; sourceTree = ""; }; + 2CC4FD5E2664EB2D00A33178 /* UserCoursesReviewsTableSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserCoursesReviewsTableSectionView.swift; sourceTree = ""; }; 2CC5AA6B242A34C500C09F94 /* RecommendationsAPI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RecommendationsAPI.swift; sourceTree = ""; }; 2CC5AA6D242A34F900C09F94 /* AdaptiveRatingManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AdaptiveRatingManager.swift; sourceTree = ""; }; 2CC5AA6E242A34F900C09F94 /* AdaptiveStatsManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AdaptiveStatsManager.swift; sourceTree = ""; }; @@ -2782,6 +2817,10 @@ 2CEDC660260898F700B0B018 /* PlatformType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformType.swift; sourceTree = ""; }; 2CEDC67126089DAE00B0B018 /* PlatformTypeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformTypeTests.swift; sourceTree = ""; }; 2CEDC67D2608A2F100B0B018 /* Model_catalog_block_platform_v74.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Model_catalog_block_platform_v74.xcdatamodel; sourceTree = ""; }; + 2CEE66A92661123C0079F03B /* UserCoursesReviewsTableViewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserCoursesReviewsTableViewDataSource.swift; sourceTree = ""; }; + 2CEE66AC266116AC0079F03B /* UserCoursesReviewsPossibleReviewTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserCoursesReviewsPossibleReviewTableViewCell.swift; sourceTree = ""; }; + 2CEE66AE266116BD0079F03B /* UserCoursesReviewsLeavedReviewTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserCoursesReviewsLeavedReviewTableViewCell.swift; sourceTree = ""; }; + 2CEE66B22661180E0079F03B /* UserCoursesReviewsLeavedReviewCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserCoursesReviewsLeavedReviewCellView.swift; sourceTree = ""; }; 2CEE97B0239123EA005503EF /* Model_step_passed_by_correct_ratio.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Model_step_passed_by_correct_ratio.xcdatamodel; sourceTree = ""; }; 2CF0848E244EF5750062EDB6 /* StepARQuickLookPreviewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepARQuickLookPreviewDataSource.swift; sourceTree = ""; }; 2CF08859205BEBF500FCB9C0 /* StepikTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepikTableView.swift; sourceTree = ""; }; @@ -2844,6 +2883,7 @@ 3C1049108DA5FB2370275330 /* Pods-StepicTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-StepicTests.release.xcconfig"; path = "Target Support Files/Pods-StepicTests/Pods-StepicTests.release.xcconfig"; sourceTree = ""; }; 3CF9B962777693E708B98356 /* NewProfileSocialProfilesView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NewProfileSocialProfilesView.swift; sourceTree = ""; }; 3D61DBD05355014717A35F04 /* LessonFinishedStepsPanModalInteractor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LessonFinishedStepsPanModalInteractor.swift; sourceTree = ""; }; + 3DDFFE939BC2A621E0E1FAD3 /* UserCoursesReviewsBlockView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserCoursesReviewsBlockView.swift; sourceTree = ""; }; 3E5C2A5CDF6CD9FA686E5D06 /* TableQuizAssembly.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TableQuizAssembly.swift; sourceTree = ""; }; 3F6D07472E32C2DC0EE070CE /* FillBlanksQuizInteractor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FillBlanksQuizInteractor.swift; sourceTree = ""; }; 4026ACBBA0C3F084DA6D5A6E /* NewProfileStreakNotificationsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NewProfileStreakNotificationsView.swift; sourceTree = ""; }; @@ -2854,6 +2894,7 @@ 481132AEA7BC453EB69DA98B /* LessonFinishedDemoPanModalView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LessonFinishedDemoPanModalView.swift; sourceTree = ""; }; 489380E1AE5474348FB1450D /* Pods-Stepic.production debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Stepic.production debug.xcconfig"; path = "Target Support Files/Pods-Stepic/Pods-Stepic.production debug.xcconfig"; sourceTree = ""; }; 49B8797DC84D64C5BAA84E76 /* Pods-Stepic.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Stepic.debug.xcconfig"; path = "Target Support Files/Pods-Stepic/Pods-Stepic.debug.xcconfig"; sourceTree = ""; }; + 4E3F2BCCADA04E9DF30E59D8 /* UserCoursesReviewsPresenter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserCoursesReviewsPresenter.swift; sourceTree = ""; }; 5099C818F5A180372348FC30 /* NewProfileCreatedCoursesDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NewProfileCreatedCoursesDataFlow.swift; sourceTree = ""; }; 511B5A8F33033E182CFE4949 /* CatalogBlocksAssembly.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CatalogBlocksAssembly.swift; sourceTree = ""; }; 51520BD9E09C97D14794CC7F /* TableQuizViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TableQuizViewController.swift; sourceTree = ""; }; @@ -2864,7 +2905,9 @@ 5808833D97EE19419B9277C1 /* SimpleCourseListViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SimpleCourseListViewController.swift; sourceTree = ""; }; 582946156484A5655EF72E43 /* UserCoursesPresenter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserCoursesPresenter.swift; sourceTree = ""; }; 58C6ED5894DADC5B17310B92 /* NewProfileStreakNotificationsPresenter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NewProfileStreakNotificationsPresenter.swift; sourceTree = ""; }; + 59E13FD794C7B66976D1C5A2 /* UserCoursesReviewsBlockProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserCoursesReviewsBlockProvider.swift; sourceTree = ""; }; 5AF3FBAFFE1578394C476293 /* NewProfileAchievementsDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NewProfileAchievementsDataFlow.swift; sourceTree = ""; }; + 5E3F17A80C27FC320C04904E /* UserCoursesReviewsBlockPresenter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserCoursesReviewsBlockPresenter.swift; sourceTree = ""; }; 5EE4ED6E9648215F1B416059 /* LessonFinishedStepsPanModalView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LessonFinishedStepsPanModalView.swift; sourceTree = ""; }; 5F44F022FE4FF6422DC2CAA9 /* DebugMenuProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DebugMenuProvider.swift; sourceTree = ""; }; 61C03F8E163838C4E026CAE5 /* NewProfileUserActivityAssembly.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NewProfileUserActivityAssembly.swift; sourceTree = ""; }; @@ -3355,6 +3398,7 @@ 8489EE7425FAF13A004A85C5 /* StepicUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = StepicUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 8489EE7625FAF13B004A85C5 /* StepicUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepicUITests.swift; sourceTree = ""; }; 8489EEE325FFBE8E004A85C5 /* Common.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Common.swift; sourceTree = ""; }; + 858D7264845A798247AE1350 /* UserCoursesReviewsDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserCoursesReviewsDataFlow.swift; sourceTree = ""; }; 861B96361FE1DF7F00773EDA /* CAGradientLayer+Init.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CAGradientLayer+Init.swift"; sourceTree = ""; }; 8622056A2055561F00F14255 /* PinsMapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinsMapView.swift; sourceTree = ""; }; 86624A721FC76578008E7E6C /* NotificationStatusesAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationStatusesAPI.swift; sourceTree = ""; }; @@ -3381,13 +3425,16 @@ 9D375711B0841DF75C1D6C6D /* NewProfileCreatedCoursesInteractor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NewProfileCreatedCoursesInteractor.swift; sourceTree = ""; }; 9E4F5E8235E0245E5D5CB6BC /* NewProfileCertificatesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NewProfileCertificatesProvider.swift; sourceTree = ""; }; 9EC26997F83B299C9E3FCC5D /* UserCoursesAssembly.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserCoursesAssembly.swift; sourceTree = ""; }; + 9F2D9C81EAADC493838F5DF9 /* UserCoursesReviewsBlockInputProtocol.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserCoursesReviewsBlockInputProtocol.swift; sourceTree = ""; }; 9F98579F526E5A4D162C3356 /* Pods_Stepic.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Stepic.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 9FB20549B185EBFAFD229A56 /* LessonFinishedDemoPanModalDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LessonFinishedDemoPanModalDataFlow.swift; sourceTree = ""; }; A0250C97AA6FB7D60234B25A /* LessonFinishedStepsPanModalProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LessonFinishedStepsPanModalProvider.swift; sourceTree = ""; }; A02FE3CE59DBF4D12871ED55 /* AuthorsCourseListProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AuthorsCourseListProvider.swift; sourceTree = ""; }; + A0D427267EAF2E5D511C1A6C /* UserCoursesReviewsProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserCoursesReviewsProvider.swift; sourceTree = ""; }; A1A67C6A3908851371ABC4C8 /* NewProfileDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NewProfileDataFlow.swift; sourceTree = ""; }; A25E416101A848548101C7DA /* StepikAcademyCourseListDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StepikAcademyCourseListDataFlow.swift; sourceTree = ""; }; A3F263ADE90D66C1622121F5 /* TableQuizDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TableQuizDataFlow.swift; sourceTree = ""; }; + A64AF153016C7406F9244FD9 /* UserCoursesReviewsOutputProtocol.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserCoursesReviewsOutputProtocol.swift; sourceTree = ""; }; A726EBAA61AAE8C3D81F0506 /* NewProfileAchievementsProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NewProfileAchievementsProvider.swift; sourceTree = ""; }; A7380FB54898647E5E6B699C /* StepikAcademyCourseListAssembly.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StepikAcademyCourseListAssembly.swift; sourceTree = ""; }; ABCEAAA2CD44CB8C3B268980 /* DebugMenuPresenter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DebugMenuPresenter.swift; sourceTree = ""; }; @@ -3395,6 +3442,7 @@ AD0054F69D610FEA28F620F8 /* Pods-StepicTests.production debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-StepicTests.production debug.xcconfig"; path = "Target Support Files/Pods-StepicTests/Pods-StepicTests.production debug.xcconfig"; sourceTree = ""; }; AFD58CADEE08103A32F03B4D /* NewProfileUserActivityDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NewProfileUserActivityDataFlow.swift; sourceTree = ""; }; B259C3826C752171E0305872 /* DownloadARQuickLookAssembly.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DownloadARQuickLookAssembly.swift; sourceTree = ""; }; + B26B27DECDA72A6EF6835559 /* UserCoursesReviewsAssembly.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserCoursesReviewsAssembly.swift; sourceTree = ""; }; B3846EE2E0A7FD5405D8D1AF /* NewProfileStreakNotificationsViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NewProfileStreakNotificationsViewController.swift; sourceTree = ""; }; B3FDC926BE23A9131D45DFF0 /* DownloadARQuickLookView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DownloadARQuickLookView.swift; sourceTree = ""; }; B7B8028216D0178B5F2E2594 /* LessonFinishedStepsPanModalViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LessonFinishedStepsPanModalViewController.swift; sourceTree = ""; }; @@ -3425,25 +3473,31 @@ D7FDCEA2CEBE8396085B5648 /* NewProfileCreatedCoursesView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NewProfileCreatedCoursesView.swift; sourceTree = ""; }; D82EBE189B9B826DA622520E /* Pods-Stepic.develop debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Stepic.develop debug.xcconfig"; path = "Target Support Files/Pods-Stepic/Pods-Stepic.develop debug.xcconfig"; sourceTree = ""; }; D850EA3EA4ED70B2E50B3650 /* StepikAcademyCourseListOutputProtocol.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StepikAcademyCourseListOutputProtocol.swift; sourceTree = ""; }; + D9B5E3A57F5CDE395E9306E0 /* UserCoursesReviewsViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserCoursesReviewsViewController.swift; sourceTree = ""; }; DB041A813271DC2DB7168CD4 /* LessonFinishedStepsPanModalAssembly.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LessonFinishedStepsPanModalAssembly.swift; sourceTree = ""; }; DD4A385A37C2127557150147 /* NewProfileCreatedCoursesOutputProtocol.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NewProfileCreatedCoursesOutputProtocol.swift; sourceTree = ""; }; E358732932D647EFD60F96E8 /* NewProfileSocialProfilesInteractor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NewProfileSocialProfilesInteractor.swift; sourceTree = ""; }; + E3ED7422D4B76AAD73F35E6E /* UserCoursesReviewsInteractor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserCoursesReviewsInteractor.swift; sourceTree = ""; }; E4A0BB5098635CCE7F5825B0 /* NewProfileAchievementsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NewProfileAchievementsView.swift; sourceTree = ""; }; + E5640965B54B334D3C660E01 /* UserCoursesReviewsBlockInteractor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserCoursesReviewsBlockInteractor.swift; sourceTree = ""; }; E9B98DD75AFD25B4028D8A48 /* NewProfileSocialProfilesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NewProfileSocialProfilesProvider.swift; sourceTree = ""; }; EA3DD53832CC0C786A41D65C /* DebugMenuInteractor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DebugMenuInteractor.swift; sourceTree = ""; }; EB3FB0B427E038E36BA99318 /* CatalogBlocksDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CatalogBlocksDataFlow.swift; sourceTree = ""; }; + EB9EABC3559C7152B7A21484 /* UserCoursesReviewsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserCoursesReviewsView.swift; sourceTree = ""; }; ECD9168AF529D0D527B1DCDB /* Pods-StepicTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-StepicTests.debug.xcconfig"; path = "Target Support Files/Pods-StepicTests/Pods-StepicTests.debug.xcconfig"; sourceTree = ""; }; ED3EA298A00E3C1DF87D33A7 /* LessonFinishedDemoPanModalProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LessonFinishedDemoPanModalProvider.swift; sourceTree = ""; }; EF17244B78AAAC345805C0DA /* NewProfileAchievementsViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NewProfileAchievementsViewController.swift; sourceTree = ""; }; F09E6F2AFFE7DFA834D0EA1E /* AuthorsCourseListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AuthorsCourseListView.swift; sourceTree = ""; }; F4B98B071C1C8E3FB13F9717 /* NewProfileAchievementsInteractor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NewProfileAchievementsInteractor.swift; sourceTree = ""; }; F4E46A7B951582DFB9DA8130 /* NewProfileUserActivityProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NewProfileUserActivityProvider.swift; sourceTree = ""; }; + F5EFB9BB0FE158509DFEE4CB /* UserCoursesReviewsBlockViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserCoursesReviewsBlockViewController.swift; sourceTree = ""; }; F68EADA2E738B56A7547D295 /* LessonFinishedDemoPanModalAssembly.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LessonFinishedDemoPanModalAssembly.swift; sourceTree = ""; }; F7F8266FC6C7815F9D8AA8A8 /* CourseListFilterPresenter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CourseListFilterPresenter.swift; sourceTree = ""; }; F8336942AE67DE9A75C7FC37 /* SubmissionsFilterDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SubmissionsFilterDataFlow.swift; sourceTree = ""; }; F8CDB43E62B13FFD968CF503 /* SimpleCourseListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SimpleCourseListView.swift; sourceTree = ""; }; F9C45B2B7960506F756CDE59 /* SimpleCourseListProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SimpleCourseListProvider.swift; sourceTree = ""; }; FBE5DD451A3D87D0E23671D3 /* NewProfileCreatedCoursesViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NewProfileCreatedCoursesViewController.swift; sourceTree = ""; }; + FC5ECBB626DE50DBB703854D /* UserCoursesReviewsBlockAssembly.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UserCoursesReviewsBlockAssembly.swift; sourceTree = ""; }; FD262F6408255BFC2FE5DD6F /* SimpleCourseListOutputProtocol.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SimpleCourseListOutputProtocol.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -3696,7 +3750,9 @@ 2C03627F2456DE0F00551807 /* HomeSubmodules */ = { isa = PBXGroup; children = ( + 2CA6A1E5266624F4000AAA90 /* ReviewsAndWishlistContainerViewController.swift */, C5254161C4484A82273DE3C7 /* UserCourses */, + 54DEFDF63A2F46813CEBF043 /* UserCoursesReviewsBlock */, ); path = HomeSubmodules; sourceTree = ""; @@ -5791,6 +5847,14 @@ path = PermissionStatus; sourceTree = ""; }; + 2CC4FD5D2664EB0500A33178 /* Section */ = { + isa = PBXGroup; + children = ( + 2CC4FD5E2664EB2D00A33178 /* UserCoursesReviewsTableSectionView.swift */, + ); + path = Section; + sourceTree = ""; + }; 2CCDD1FF24DBB39100A48EE0 /* Concurrency */ = { isa = PBXGroup; children = ( @@ -6048,6 +6112,44 @@ path = RightDetailCells; sourceTree = ""; }; + 2CEE66A8266111F40079F03B /* View */ = { + isa = PBXGroup; + children = ( + 2CEE66A92661123C0079F03B /* UserCoursesReviewsTableViewDataSource.swift */, + EB9EABC3559C7152B7A21484 /* UserCoursesReviewsView.swift */, + 2CEE66AB266116750079F03B /* Cell */, + 2CC4FD5D2664EB0500A33178 /* Section */, + ); + path = View; + sourceTree = ""; + }; + 2CEE66AB266116750079F03B /* Cell */ = { + isa = PBXGroup; + children = ( + 2CEE66B02661173A0079F03B /* Leaved */, + 2CEE66B1266117470079F03B /* Possible */, + ); + path = Cell; + sourceTree = ""; + }; + 2CEE66B02661173A0079F03B /* Leaved */ = { + isa = PBXGroup; + children = ( + 2CEE66B22661180E0079F03B /* UserCoursesReviewsLeavedReviewCellView.swift */, + 2CEE66AE266116BD0079F03B /* UserCoursesReviewsLeavedReviewTableViewCell.swift */, + ); + path = Leaved; + sourceTree = ""; + }; + 2CEE66B1266117470079F03B /* Possible */ = { + isa = PBXGroup; + children = ( + 2CC4FD5B2664CBA000A33178 /* UserCoursesReviewsPossibleReviewCellView.swift */, + 2CEE66AC266116AC0079F03B /* UserCoursesReviewsPossibleReviewTableViewCell.swift */, + ); + path = Possible; + sourceTree = ""; + }; 2CF1505123D532D900915E5A /* Downloader */ = { isa = PBXGroup; children = ( @@ -6388,6 +6490,7 @@ 62E989B9228830C92A91B2DE /* CourseListsCollectionSkeletonView.swift */, 62E98A399A313681138734B4 /* CourseWidgetSkeletonView.swift */, 2C71F117256DA2FF00A1D40B /* SimpleCourseListCellSkeletonView.swift */, + 2CA6A1E926665AC2000AAA90 /* UserCoursesReviewsBlockSkeletonView.swift */, ); path = CourseList; sourceTree = ""; @@ -7052,6 +7155,7 @@ 62E98321CB033D7736CD4DDB /* CoursePayment.swift */, 2C97765125D6CF0F008778D6 /* CoursePlainObject.swift */, 2CEDC6482608919C00B0B018 /* CourseRecommendation.swift */, + 2C83B542265FA04B006E1CC5 /* CourseReviewPlainObject.swift */, 2CDA1B1E26139C4100E36BF7 /* ExamSessionPlainObject.swift */, 62E986D2ADA8942B2C685F05 /* LessonPlainObject.swift */, 2CB3563D2476CB7B00E59A03 /* MagicLink.swift */, @@ -7138,6 +7242,22 @@ path = FillBlanksQuiz; sourceTree = ""; }; + 54DEFDF63A2F46813CEBF043 /* UserCoursesReviewsBlock */ = { + isa = PBXGroup; + children = ( + FC5ECBB626DE50DBB703854D /* UserCoursesReviewsBlockAssembly.swift */, + 06CBC774ED03C2B9755265F7 /* UserCoursesReviewsBlockDataFlow.swift */, + E5640965B54B334D3C660E01 /* UserCoursesReviewsBlockInteractor.swift */, + 5E3F17A80C27FC320C04904E /* UserCoursesReviewsBlockPresenter.swift */, + 59E13FD794C7B66976D1C5A2 /* UserCoursesReviewsBlockProvider.swift */, + 3DDFFE939BC2A621E0E1FAD3 /* UserCoursesReviewsBlockView.swift */, + F5EFB9BB0FE158509DFEE4CB /* UserCoursesReviewsBlockViewController.swift */, + 2CA6A1E72666551F000AAA90 /* UserCoursesReviewsBlockViewModel.swift */, + A9768FA44DEFE47C74CEB268 /* InputOutput */, + ); + path = UserCoursesReviewsBlock; + sourceTree = ""; + }; 54F19206D03FD40FD65BC419 /* SubmissionsFilter */ = { isa = PBXGroup; children = ( @@ -7365,6 +7485,7 @@ 62E98C8FCDBED079878D6845 /* Step */, 62E984A2F7E518F5D5F35693 /* Submissions */, 54F19206D03FD40FD65BC419 /* SubmissionsFilter */, + BECBB066BD8A837799F5CE6A /* UserCoursesReviews */, 62E9817E31C5C831440A0314 /* WriteComment */, 62E98DCF4852B5F26AAB3F64 /* WriteCourseReview */, ); @@ -8672,6 +8793,14 @@ path = CourseListFilter; sourceTree = ""; }; + A9768FA44DEFE47C74CEB268 /* InputOutput */ = { + isa = PBXGroup; + children = ( + 9F2D9C81EAADC493838F5DF9 /* UserCoursesReviewsBlockInputProtocol.swift */, + ); + path = InputOutput; + sourceTree = ""; + }; B0EC82D3DB3A11DA382B3D42 /* StreakNotifications */ = { isa = PBXGroup; children = ( @@ -8686,6 +8815,14 @@ path = StreakNotifications; sourceTree = ""; }; + B8CE0C6079E333390CF54586 /* InputOutput */ = { + isa = PBXGroup; + children = ( + A64AF153016C7406F9244FD9 /* UserCoursesReviewsOutputProtocol.swift */, + ); + path = InputOutput; + sourceTree = ""; + }; BC550002F8EC0D2B2C1D5970 /* InputOutput */ = { isa = PBXGroup; children = ( @@ -8708,6 +8845,22 @@ path = CreatedCourses; sourceTree = ""; }; + BECBB066BD8A837799F5CE6A /* UserCoursesReviews */ = { + isa = PBXGroup; + children = ( + B26B27DECDA72A6EF6835559 /* UserCoursesReviewsAssembly.swift */, + 858D7264845A798247AE1350 /* UserCoursesReviewsDataFlow.swift */, + E3ED7422D4B76AAD73F35E6E /* UserCoursesReviewsInteractor.swift */, + 4E3F2BCCADA04E9DF30E59D8 /* UserCoursesReviewsPresenter.swift */, + A0D427267EAF2E5D511C1A6C /* UserCoursesReviewsProvider.swift */, + D9B5E3A57F5CDE395E9306E0 /* UserCoursesReviewsViewController.swift */, + 2C83B544265FB572006E1CC5 /* UserCoursesReviewsViewModel.swift */, + B8CE0C6079E333390CF54586 /* InputOutput */, + 2CEE66A8266111F40079F03B /* View */, + ); + path = UserCoursesReviews; + sourceTree = ""; + }; C5254161C4484A82273DE3C7 /* UserCourses */ = { isa = PBXGroup; children = ( @@ -9678,6 +9831,7 @@ 2CF20A55260140A800BF050B /* FeedbackStoryFormView.swift in Sources */, 2C7AC49925B37ACA0024D4D2 /* FileManager+SharedContainer.swift in Sources */, 08FCEB641B9ED2AC00FC4F8B /* AuthAPI.swift in Sources */, + 2CEE66AD266116AC0079F03B /* UserCoursesReviewsPossibleReviewTableViewCell.swift in Sources */, 08E43E9E214C27B200E3CB50 /* ModalRouter.swift in Sources */, 083E1DA61C96E9F100B305E4 /* ApplicationInfo.swift in Sources */, 08F555501C4F93B700C877E8 /* Dataset.swift in Sources */, @@ -9688,6 +9842,7 @@ 2CA93091253C86D0007B717A /* VideoURLPlainObject.swift in Sources */, 08484F03211AF4320006266F /* SegmentedProgressView.swift in Sources */, 0861E6721CD80A9600B45652 /* Executable.swift in Sources */, + 2CC4FD5C2664CBA000A33178 /* UserCoursesReviewsPossibleReviewCellView.swift in Sources */, 083AABE91BE8D63D005E1E96 /* Progress.swift in Sources */, 2CDA1B5E2613C06E00E36BF7 /* ProctorSessionPlainObject.swift in Sources */, 2CBBCB9823980EB2006D6C15 /* MulticastDelegate.swift in Sources */, @@ -9911,6 +10066,7 @@ 2C5D340225C988FA00372C61 /* PromoCodesNetworkService.swift in Sources */, 08407EC71DE4891D0082C4E7 /* FBSocialSDKProvider.swift in Sources */, 2C3A035624AE3DCB007D28F7 /* NewProfileStreakNotificationsSwitchView.swift in Sources */, + 2CC4FD5F2664EB2D00A33178 /* UserCoursesReviewsTableSectionView.swift in Sources */, 2C8EE76E2604B65B003512BC /* CourseType.swift in Sources */, 089574561E5B76E700C12D21 /* UIImageView+SVGDownload.swift in Sources */, 2C2F0BEE2186F196007DCA0A /* NotificationsRequestAlertDataSource.swift in Sources */, @@ -9968,6 +10124,7 @@ 2CE3BCA71FBF13CE000AD405 /* SQLReply.swift in Sources */, 0885F8561BA9F18900F2A188 /* Parser.swift in Sources */, 087585A31FB50D640047A269 /* CourseListsAPI.swift in Sources */, + 2CA6A1E6266624F4000AAA90 /* ReviewsAndWishlistContainerViewController.swift in Sources */, 2C11C9F424EFCB2600A4647B /* UIFont+SizeOfString.swift in Sources */, 08E43E98214C279700E3CB50 /* PushRouter.swift in Sources */, 2CC3518A1F682A02004255B6 /* SocialAuthCollectionViewCell.swift in Sources */, @@ -9975,6 +10132,7 @@ 0813EEA61BFE5A5400DB4B83 /* Assignment+CoreDataProperties.swift in Sources */, 2C284DB92474418600669736 /* CoursePaymentsAPI.swift in Sources */, 080C5E7B1EFC13ED0036EB3D /* CodeSample.swift in Sources */, + 2CA6A1EA26665AC2000AAA90 /* UserCoursesReviewsBlockSkeletonView.swift in Sources */, 2C49BA8926331CF20000BB50 /* LessonOutputProtocol.swift in Sources */, 2CB37E6623902BB80050D85E /* StorageUsageService.swift in Sources */, 2C2E44D524FE5FEA006B7303 /* VisitedCoursesCleaner.swift in Sources */, @@ -10588,6 +10746,7 @@ 2CF4661525402077002415AF /* TableQuizSelectColumnsViewController.swift in Sources */, 62E985B65951147A86D63FD4 /* DiscussionsTableViewCell.swift in Sources */, 62E98D8C47E3B4A08EE1B392 /* DownloadsAssembly.swift in Sources */, + 2C83B545265FB572006E1CC5 /* UserCoursesReviewsViewModel.swift in Sources */, 62E9823089CDCEB82765F6C2 /* DownloadsDataFlow.swift in Sources */, 62E98A5063ED92F25442D325 /* DownloadsInteractor.swift in Sources */, 62E98A545486B2DF6E1BC5D5 /* DownloadsPresenter.swift in Sources */, @@ -10643,6 +10802,7 @@ 2CEDC661260898F700B0B018 /* PlatformType.swift in Sources */, 62E98779C46941AA1F710191 /* ContinueCourseOutputProtocol.swift in Sources */, 62E984E7020CA7E523C824EA /* ContinueCoursePresenter.swift in Sources */, + 2CEE66AF266116BD0079F03B /* UserCoursesReviewsLeavedReviewTableViewCell.swift in Sources */, 62E983C5C87C83700311EFCC /* ContinueCourseProvider.swift in Sources */, 2C57276D252C89C900C4C7C0 /* DiscussionsBottomControlsView.swift in Sources */, 2C7AC4B425B452540024D4D2 /* WidgetContentFileManager+Promise.swift in Sources */, @@ -10654,6 +10814,7 @@ 62E983D1190F2A5B3A400598 /* CourseListsCollectionDataFlow.swift in Sources */, 62E984F88D4253FDA7C8D7BA /* CourseListsCollectionInteractor.swift in Sources */, 2C2E44CF24FE331F006B7303 /* CourseListCardStyle.swift in Sources */, + 2CA6A1E82666551F000AAA90 /* UserCoursesReviewsBlockViewModel.swift in Sources */, 62E984EF97B59BCBB5FE7401 /* CourseListsCollectionPresenter.swift in Sources */, 62E98434178EA051C7B20A56 /* CourseListsCollectionViewController.swift in Sources */, 62E9871AD527F876580625C3 /* CourseListsCollectionViewModel.swift in Sources */, @@ -10820,6 +10981,7 @@ 62E98706EE08B59BC5CB5B03 /* NewSortingQuizDataFlow.swift in Sources */, 62E9844FD1845C298B281950 /* NewSortingQuizInteractor.swift in Sources */, 62E9885EBB5FC595D3F8622D /* NewSortingQuizPresenter.swift in Sources */, + 2C83B543265FA04B006E1CC5 /* CourseReviewPlainObject.swift in Sources */, 62E98A9EE808444DF405438B /* NewSortingQuizViewController.swift in Sources */, 2CF20A4326012B5300BF050B /* StoryPartTitleLabel.swift in Sources */, 62E9820D47A040FC9BD74080 /* NewSortingQuizViewModel.swift in Sources */, @@ -10857,6 +11019,7 @@ 62E9885D8B64C53C2BA545F5 /* SettingsOutputProtocol.swift in Sources */, 62E988DEEC0358B72B725D3A /* SolutionAssembly.swift in Sources */, 62E989C07C8092DDD028E286 /* SolutionDataFlow.swift in Sources */, + 2CEE66B32661180E0079F03B /* UserCoursesReviewsLeavedReviewCellView.swift in Sources */, 62E9874B2367F47B416932CF /* SolutionInteractor.swift in Sources */, 2CB31C72256D11E500265CC5 /* CourseListsNetworkService.swift in Sources */, 62E98CB8722C1DBC5B578107 /* SolutionPresenter.swift in Sources */, @@ -11056,6 +11219,7 @@ B4A54A3274B3A91CCBE2DED9 /* AuthorsCourseListOutputProtocol.swift in Sources */, 62E980E9D98AADCB95AC92EE /* LessonPlainObject.swift in Sources */, 6FFCCAD4A767D51713215282 /* SubmissionsFilterAssembly.swift in Sources */, + 2CEE66AA2661123C0079F03B /* UserCoursesReviewsTableViewDataSource.swift in Sources */, 6D3288F90505044F2DB76FA8 /* SubmissionsFilterDataFlow.swift in Sources */, 2C9D69B72617334100A0641F /* CatalogBlockHorizontalCollectionViewFlowLayout.swift in Sources */, D8CBAA006512D47ABCFC91EE /* SubmissionsFilterInteractor.swift in Sources */, @@ -11094,6 +11258,22 @@ BAF3545D8CC66168A37E2A58 /* LessonFinishedStepsPanModalView.swift in Sources */, C56B3693FD36033B00C6AEB7 /* LessonFinishedStepsPanModalViewController.swift in Sources */, 5BB0F4923200056C332A59FF /* LessonFinishedStepsPanModalOutputProtocol.swift in Sources */, + BD1C9F8F65E99FCF6959F92A /* UserCoursesReviewsAssembly.swift in Sources */, + 967846575CD3702AF484AF52 /* UserCoursesReviewsDataFlow.swift in Sources */, + 85CFA575736E6C0570D48065 /* UserCoursesReviewsInteractor.swift in Sources */, + 03DCDF023C3BC6656039CD69 /* UserCoursesReviewsPresenter.swift in Sources */, + 135A33403FC045E6FA7C6EA9 /* UserCoursesReviewsProvider.swift in Sources */, + 3B56681D248C577ED6D9491F /* UserCoursesReviewsView.swift in Sources */, + 8B096A7617D4999095F0DCD1 /* UserCoursesReviewsViewController.swift in Sources */, + 3547E584AFEE09996EB90808 /* UserCoursesReviewsOutputProtocol.swift in Sources */, + 03B85109D5524A87EF421737 /* UserCoursesReviewsBlockAssembly.swift in Sources */, + 9A1785762A6B8F97FE347121 /* UserCoursesReviewsBlockDataFlow.swift in Sources */, + 4411DBAC80EEAF128EE11E61 /* UserCoursesReviewsBlockInteractor.swift in Sources */, + 21FB2FC61272B623B47A457F /* UserCoursesReviewsBlockPresenter.swift in Sources */, + EFAD92E3201AC22530847975 /* UserCoursesReviewsBlockProvider.swift in Sources */, + 71832D1CCA2BF7D78AF0CB60 /* UserCoursesReviewsBlockView.swift in Sources */, + EC2BD2389537AF3BD14D51EA /* UserCoursesReviewsBlockViewController.swift in Sources */, + A8D492721CB2DAB7300BDFAE /* UserCoursesReviewsBlockInputProtocol.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -11354,7 +11534,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 333; + CURRENT_PROJECT_VERSION = 335; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = UJ4KC2QN7B; INFOPLIST_FILE = "StickerPackExtension/Info-Production.plist"; @@ -11379,7 +11559,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 333; + CURRENT_PROJECT_VERSION = 335; DEVELOPMENT_TEAM = UJ4KC2QN7B; INFOPLIST_FILE = "StickerPackExtension/Info-Production.plist"; IPHONEOS_DEPLOYMENT_TARGET = 11.0; @@ -11521,7 +11701,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 333; + CURRENT_PROJECT_VERSION = 335; DEVELOPMENT_TEAM = UJ4KC2QN7B; ENABLE_BITCODE = YES; INFOPLIST_FILE = "Stepic/Info-Production.plist"; @@ -11551,7 +11731,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 333; + CURRENT_PROJECT_VERSION = 335; DEVELOPMENT_TEAM = UJ4KC2QN7B; ENABLE_BITCODE = YES; "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = arm64; @@ -11642,7 +11822,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 333; + CURRENT_PROJECT_VERSION = 335; DEVELOPMENT_TEAM = UJ4KC2QN7B; ENABLE_BITCODE = YES; INFOPLIST_FILE = "Stepic/Info-Develop.plist"; @@ -11694,7 +11874,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 333; + CURRENT_PROJECT_VERSION = 335; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = UJ4KC2QN7B; INFOPLIST_FILE = "StickerPackExtension/Info-Develop.plist"; @@ -11775,7 +11955,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 333; + CURRENT_PROJECT_VERSION = 335; DEVELOPMENT_TEAM = UJ4KC2QN7B; ENABLE_BITCODE = YES; INFOPLIST_FILE = "Stepic/Info-Develop.plist"; @@ -11823,7 +12003,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 333; + CURRENT_PROJECT_VERSION = 335; DEVELOPMENT_TEAM = UJ4KC2QN7B; INFOPLIST_FILE = "StickerPackExtension/Info-Develop.plist"; IPHONEOS_DEPLOYMENT_TARGET = 11.0; @@ -12344,7 +12524,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 333; + CURRENT_PROJECT_VERSION = 335; DEVELOPMENT_TEAM = UJ4KC2QN7B; ENABLE_BITCODE = YES; INFOPLIST_FILE = "Stepic/Info-Release.plist"; @@ -12398,7 +12578,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 333; + CURRENT_PROJECT_VERSION = 335; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = UJ4KC2QN7B; INFOPLIST_FILE = "StickerPackExtension/Info-Release.plist"; @@ -12480,7 +12660,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 333; + CURRENT_PROJECT_VERSION = 335; DEVELOPMENT_TEAM = UJ4KC2QN7B; ENABLE_BITCODE = YES; INFOPLIST_FILE = "Stepic/Info-Release.plist"; @@ -12528,7 +12708,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 333; + CURRENT_PROJECT_VERSION = 335; DEVELOPMENT_TEAM = UJ4KC2QN7B; INFOPLIST_FILE = "StickerPackExtension/Info-Release.plist"; IPHONEOS_DEPLOYMENT_TARGET = 11.0; diff --git a/Stepic/Images.xcassets/New course list/Contents.json b/Stepic/Images.xcassets/New course list/Contents.json index da4a164c91..73c00596a7 100644 --- a/Stepic/Images.xcassets/New course list/Contents.json +++ b/Stepic/Images.xcassets/New course list/Contents.json @@ -1,6 +1,6 @@ { "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 } -} \ No newline at end of file +} diff --git a/Stepic/Images.xcassets/New course list/user-courses-reviews-block-stars.imageset/Contents.json b/Stepic/Images.xcassets/New course list/user-courses-reviews-block-stars.imageset/Contents.json new file mode 100644 index 0000000000..29f2e7922d --- /dev/null +++ b/Stepic/Images.xcassets/New course list/user-courses-reviews-block-stars.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "user-courses-reviews-block-stars.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Stepic/Images.xcassets/New course list/user-courses-reviews-block-stars.imageset/user-courses-reviews-block-stars.pdf b/Stepic/Images.xcassets/New course list/user-courses-reviews-block-stars.imageset/user-courses-reviews-block-stars.pdf new file mode 100644 index 0000000000..64caa95e62 Binary files /dev/null and b/Stepic/Images.xcassets/New course list/user-courses-reviews-block-stars.imageset/user-courses-reviews-block-stars.pdf differ diff --git a/Stepic/Info-Develop.plist b/Stepic/Info-Develop.plist index e8ee044ef9..1c78678a40 100644 --- a/Stepic/Info-Develop.plist +++ b/Stepic/Info-Develop.plist @@ -17,7 +17,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.174-develop + 1.175-develop CFBundleSignature ???? CFBundleURLTypes @@ -62,7 +62,7 @@ CFBundleVersion - 333 + 335 FacebookAppID 171127739724012 FacebookDisplayName diff --git a/Stepic/Info-Production.plist b/Stepic/Info-Production.plist index bdaf93af8b..2e1ad0d79f 100644 --- a/Stepic/Info-Production.plist +++ b/Stepic/Info-Production.plist @@ -17,7 +17,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.174 + 1.175 CFBundleSignature ???? CFBundleURLTypes @@ -62,7 +62,7 @@ CFBundleVersion - 333 + 335 FacebookAppID 171127739724012 FacebookDisplayName diff --git a/Stepic/Info-Release.plist b/Stepic/Info-Release.plist index fc2a22b334..a3459b2b4b 100644 --- a/Stepic/Info-Release.plist +++ b/Stepic/Info-Release.plist @@ -17,7 +17,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.174-release + 1.175-release CFBundleSignature ???? CFBundleURLTypes @@ -62,7 +62,7 @@ CFBundleVersion - 333 + 335 FacebookAppID 171127739724012 FacebookDisplayName diff --git a/Stepic/Legacy/Analytics/Events/AmplitudeAnalyticsEvents.swift b/Stepic/Legacy/Analytics/Events/AmplitudeAnalyticsEvents.swift index 3662aaca6a..e4f825f938 100644 --- a/Stepic/Legacy/Analytics/Events/AmplitudeAnalyticsEvents.swift +++ b/Stepic/Legacy/Analytics/Events/AmplitudeAnalyticsEvents.swift @@ -519,6 +519,7 @@ extension AnalyticsEvent { case widgetExtension(url: String) case notification case profile(id: Int) + case userCoursesReviews case unknown var name: String { @@ -551,6 +552,8 @@ extension AnalyticsEvent { return "notification" case .profile: return "profile" + case .userCoursesReviews: + return "user_courses_reviews" case .unknown: return "unknown" } @@ -558,7 +561,14 @@ extension AnalyticsEvent { var params: [String: Any]? { switch self { - case .myCourses, .visitedCourses, .downloads, .fastContinue, .notification, .unknown, .recommendation: + case .myCourses, + .visitedCourses, + .downloads, + .fastContinue, + .notification, + .recommendation, + .userCoursesReviews, + .unknown: return nil case .search(let query): return ["query": query] diff --git a/Stepic/Legacy/Controllers/Placeholders/StepikPlaceholderStyle+Placeholders.swift b/Stepic/Legacy/Controllers/Placeholders/StepikPlaceholderStyle+Placeholders.swift index 3159021ef7..4660c1a002 100644 --- a/Stepic/Legacy/Controllers/Placeholders/StepikPlaceholderStyle+Placeholders.swift +++ b/Stepic/Legacy/Controllers/Placeholders/StepikPlaceholderStyle+Placeholders.swift @@ -132,6 +132,12 @@ extension StepikPlaceholder.Style { text: NSLocalizedString("SubmissionsPlaceholderEmptyTitle", comment: ""), buttonTitle: nil ) + static let emptyReviews = StepikPlaceholderStyle( + id: "emptyReviews", + image: PlaceholderImage(image: UIImage(named: "new-empty-empty"), scale: 0.99), + text: NSLocalizedString("UserCoursesReviewsPlaceholderEmptyTitle", comment: ""), + buttonTitle: nil + ) static let emptyProfileLoading = StepikPlaceholderStyle( id: "emptyProfileLoading", image: PlaceholderImage(image: UIImage(named: "new-empty-empty"), scale: 0.99), diff --git a/Stepic/Legacy/Model/Entities/CourseReview/CourseReview.swift b/Stepic/Legacy/Model/Entities/CourseReview/CourseReview.swift index 50a12ce92d..9cf8b78812 100644 --- a/Stepic/Legacy/Model/Entities/CourseReview/CourseReview.swift +++ b/Stepic/Legacy/Model/Entities/CourseReview/CourseReview.swift @@ -65,6 +65,30 @@ final class CourseReview: NSManagedObject, JSONSerializable, IDFetchable { } } + static func fetch(userID: User.IdType) -> Guarantee<[CourseReview]> { + let request = CourseReview.fetchRequest + let descriptor = NSSortDescriptor(key: "managedId", ascending: false) + + let predicate = NSPredicate(format: "managedUserId == %@", userID.fetchValue) + + request.predicate = predicate + request.sortDescriptors = [descriptor] + + return Guarantee { seal in + DispatchQueue.doWorkOnMain { + let context = CoreDataHelper.shared.context + context.performAndWait { + do { + let courseReviews = try context.fetch(request) + seal(courseReviews) + } catch { + seal([]) + } + } + } + } + } + static func fetch(courseID: Course.IdType, userID: User.IdType) -> Guarantee<[CourseReview]> { let request = NSFetchRequest(entityName: "CourseReview") let descriptor = NSSortDescriptor(key: "managedId", ascending: false) diff --git a/Stepic/Legacy/Model/Network/Endpoints/CourseReviewsAPI.swift b/Stepic/Legacy/Model/Network/Endpoints/CourseReviewsAPI.swift index c568fb1881..8aae598fe2 100644 --- a/Stepic/Legacy/Model/Network/Endpoints/CourseReviewsAPI.swift +++ b/Stepic/Legacy/Model/Network/Endpoints/CourseReviewsAPI.swift @@ -64,6 +64,44 @@ final class CourseReviewsAPI: APIEndpoint { } } + /// Get course review by user id. + func retrieve(userID: User.IdType, page: Int = 1) -> Promise<([CourseReview], Meta)> { + Promise { seal in + let parameters: Parameters = [ + "user": userID, + "page": page + ] + + CourseReview.fetch(userID: userID).then { + cachedReviews -> Promise<([CourseReview], Meta, JSON)> in + self.retrieve.request( + requestEndpoint: self.name, + paramName: self.name, + params: parameters, + updatingObjects: cachedReviews, + withManager: self.manager + ) + }.done { reviews, meta, _ in + seal.fulfill((reviews, meta)) + }.catch { error in + seal.reject(error) + } + } + } + + /// Get all course reviews by user id. + func retrieveAll(userID: User.IdType) -> Promise<[CourseReview]> { + CourseReview.fetch(userID: userID).then { + self.retrieve.requestWithCollectAllPages( + requestEndpoint: self.name, + paramName: self.name, + params: ["user": userID], + updatingObjects: $0, + withManager: self.manager + ) + } + } + func create( courseID: Course.IdType, userID: User.IdType, diff --git a/Stepic/Legacy/Model/Network/RequestMakers/RetrieveRequestMaker.swift b/Stepic/Legacy/Model/Network/RequestMakers/RetrieveRequestMaker.swift index 62038b9926..00aec41c4d 100644 --- a/Stepic/Legacy/Model/Network/RequestMakers/RetrieveRequestMaker.swift +++ b/Stepic/Legacy/Model/Network/RequestMakers/RetrieveRequestMaker.swift @@ -133,6 +133,7 @@ final class RetrieveRequestMaker { requestEndpoint: String, paramName: String, params: Parameters, + updatingObjects: [T] = [], withManager manager: Alamofire.Session ) -> Promise<[T]> { var allObjects = [T]() @@ -147,7 +148,7 @@ final class RetrieveRequestMaker { requestEndpoint: requestEndpoint, paramName: paramName, params: currentPageParams, - updatingObjects: [], + updatingObjects: updatingObjects, withManager: manager ) }.done { objects, meta in diff --git a/Stepic/Legacy/Model/PlainObjects/CoursePlainObject.swift b/Stepic/Legacy/Model/PlainObjects/CoursePlainObject.swift index b8d6001ad8..30df2d2de3 100644 --- a/Stepic/Legacy/Model/PlainObjects/CoursePlainObject.swift +++ b/Stepic/Legacy/Model/PlainObjects/CoursePlainObject.swift @@ -3,6 +3,7 @@ import Foundation struct CoursePlainObject { let id: Int let title: String + let coverURLString: String let sectionsIDs: [Int] let sections: [SectionPlainObject] let isEnrolled: Bool @@ -11,11 +12,12 @@ struct CoursePlainObject { } extension CoursePlainObject { - init(course: Course) { + init(course: Course, withSections: Bool = true) { self.id = course.id self.title = course.title + self.coverURLString = course.coverURLString self.sectionsIDs = course.sectionsArray - self.sections = course.sections.map(SectionPlainObject.init) + self.sections = withSections ? course.sections.map(SectionPlainObject.init) : [] self.isEnrolled = course.enrolled self.isPaid = course.isPaid self.isProctored = course.isProctored diff --git a/Stepic/Legacy/Model/PlainObjects/CourseReviewPlainObject.swift b/Stepic/Legacy/Model/PlainObjects/CourseReviewPlainObject.swift new file mode 100644 index 0000000000..580aa06069 --- /dev/null +++ b/Stepic/Legacy/Model/PlainObjects/CourseReviewPlainObject.swift @@ -0,0 +1,26 @@ +import Foundation + +struct CourseReviewPlainObject { + let id: Int + let courseID: Int + let userID: Int + let score: Int + let text: String + let creationDate: Date + var course: CoursePlainObject? +} + +extension CourseReviewPlainObject { + init(courseReview: CourseReview) { + self.id = courseReview.id + self.courseID = courseReview.courseID + self.userID = courseReview.userID + self.score = courseReview.score + self.text = courseReview.text + self.creationDate = courseReview.creationDate + + if let course = courseReview.course { + self.course = CoursePlainObject(course: course, withSections: false) + } + } +} diff --git a/Stepic/Legacy/Views/Skeleton/Views/CourseList/UserCoursesReviewsBlockSkeletonView.swift b/Stepic/Legacy/Views/Skeleton/Views/CourseList/UserCoursesReviewsBlockSkeletonView.swift new file mode 100644 index 0000000000..fddafe2890 --- /dev/null +++ b/Stepic/Legacy/Views/Skeleton/Views/CourseList/UserCoursesReviewsBlockSkeletonView.swift @@ -0,0 +1,49 @@ +import SnapKit +import UIKit + +extension UserCoursesReviewsBlockSkeletonView { + struct Appearance { + let cornerRadius: CGFloat = 4 + } +} + +final class UserCoursesReviewsBlockSkeletonView: UIView { + let appearance: Appearance + + private lazy var titleLabelSkeleton = UIView() + + init( + frame: CGRect = .zero, + appearance: Appearance = Appearance() + ) { + self.appearance = appearance + 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 UserCoursesReviewsBlockSkeletonView: ProgrammaticallyInitializableViewProtocol { + func setupView() { + self.backgroundColor = .clear + + self.titleLabelSkeleton.clipsToBounds = true + self.titleLabelSkeleton.layer.cornerRadius = self.appearance.cornerRadius + } + + func addSubviews() { + self.addSubview(self.titleLabelSkeleton) + } + + func makeConstraints() { + self.titleLabelSkeleton.translatesAutoresizingMaskIntoConstraints = false + self.titleLabelSkeleton.snp.makeConstraints { $0.edges.equalToSuperview() } + } +} diff --git a/Stepic/Sources/Helpers/FormatterHelper.swift b/Stepic/Sources/Helpers/FormatterHelper.swift index 505c198157..3925177381 100644 --- a/Stepic/Sources/Helpers/FormatterHelper.swift +++ b/Stepic/Sources/Helpers/FormatterHelper.swift @@ -156,6 +156,32 @@ enum FormatterHelper { } } + /// Format reviews count with localized and pluralized suffix; 1 -> "1 review", 5 -> "5 reviews" + static func reviewsCount(_ count: Int) -> String { + let pluralizedCountString = StringHelper.pluralize( + number: count, + forms: [ + NSLocalizedString("reviews1", comment: ""), + NSLocalizedString("reviews234", comment: ""), + NSLocalizedString("reviews567890", comment: "") + ] + ) + return "\(count) \(pluralizedCountString)" + } + + /// Format reviews count with localized and pluralized suffix; 1 -> "1 new course for review", 5 -> "5 new courses for review" + static func userCoursesReviewsPossibleReviewsCount(_ count: Int) -> String { + let pluralizedCountString = StringHelper.pluralize( + number: count, + forms: [ + NSLocalizedString("UserCoursesReviewsPossibleReviews1", comment: ""), + NSLocalizedString("UserCoursesReviewsPossibleReviews234", comment: ""), + NSLocalizedString("UserCoursesReviewsPossibleReviews567890", comment: "") + ] + ) + return "\(count) \(pluralizedCountString)" + } + // MARK: Date /// Format days count with localized and pluralized suffix; 1 -> "1 day", 5 -> "5 days" diff --git a/Stepic/Sources/Modules/CourseInfoSubmodules/CourseInfoTabReviews/CourseInfoTabReviewsProvider.swift b/Stepic/Sources/Modules/CourseInfoSubmodules/CourseInfoTabReviews/CourseInfoTabReviewsProvider.swift index 177a1fa988..4b52479fbe 100644 --- a/Stepic/Sources/Modules/CourseInfoSubmodules/CourseInfoTabReviews/CourseInfoTabReviewsProvider.swift +++ b/Stepic/Sources/Modules/CourseInfoSubmodules/CourseInfoTabReviews/CourseInfoTabReviewsProvider.swift @@ -32,7 +32,7 @@ final class CourseInfoTabReviewsProvider: CourseInfoTabReviewsProviderProtocol { func fetchCached(course: Course) -> Promise<([CourseReview], Meta)> { Promise { seal in - self.courseReviewsPersistenceService.fetch(by: course.id).done { + self.courseReviewsPersistenceService.fetch(courseID: course.id).done { seal.fulfill(($0, Meta.oneAndOnlyPage)) }.catch { _ in seal.reject(Error.persistenceFetchFailed) @@ -74,7 +74,7 @@ final class CourseInfoTabReviewsProvider: CourseInfoTabReviewsProviderProtocol { } return Promise { seal in - self.courseReviewsPersistenceService.fetch(by: course.id, userID: currentUserID).done { + self.courseReviewsPersistenceService.fetch(courseID: course.id, userID: currentUserID).done { seal.fulfill($0) }.catch { _ in seal.reject(Error.persistenceFetchFailed) diff --git a/Stepic/Sources/Modules/CourseInfoSubmodules/CourseInfoTabReviews/CourseInfoTabReviewsViewController.swift b/Stepic/Sources/Modules/CourseInfoSubmodules/CourseInfoTabReviews/CourseInfoTabReviewsViewController.swift index 3e02d26e99..b5335e5e8a 100644 --- a/Stepic/Sources/Modules/CourseInfoSubmodules/CourseInfoTabReviews/CourseInfoTabReviewsViewController.swift +++ b/Stepic/Sources/Modules/CourseInfoSubmodules/CourseInfoTabReviews/CourseInfoTabReviewsViewController.swift @@ -116,9 +116,16 @@ extension CourseInfoTabReviewsViewController: CourseInfoTabReviewsViewController func displayWriteCourseReview(viewModel: CourseInfoTabReviews.WriteCourseReviewPresentation.ViewModel) { let modalPresentationStyle = UIModalPresentationStyle.stepikAutomatic + let presentationContext: WriteCourseReview.PresentationContext = { + if let review = viewModel.review { + return .update(review) + } + return .create + }() + let assembly = WriteCourseReviewAssembly( courseID: viewModel.courseID, - courseReview: viewModel.review, + presentationContext: presentationContext, navigationBarAppearance: modalPresentationStyle.isSheetStyle ? .pageSheetAppearance() : .init(), output: self.interactor as? WriteCourseReviewOutputProtocol ) diff --git a/Stepic/Sources/Modules/Home/HomeDataFlow.swift b/Stepic/Sources/Modules/Home/HomeDataFlow.swift index e7afcb7699..c27998a357 100644 --- a/Stepic/Sources/Modules/Home/HomeDataFlow.swift +++ b/Stepic/Sources/Modules/Home/HomeDataFlow.swift @@ -7,6 +7,7 @@ enum Home { case streakActivity case continueCourse case enrolledCourses + case reviewsAndWishlist case visitedCourses case popularCourses diff --git a/Stepic/Sources/Modules/Home/HomeViewController.swift b/Stepic/Sources/Modules/Home/HomeViewController.swift index ce27cd7dea..d1fac48bd0 100644 --- a/Stepic/Sources/Modules/Home/HomeViewController.swift +++ b/Stepic/Sources/Modules/Home/HomeViewController.swift @@ -17,6 +17,7 @@ final class HomeViewController: BaseExploreViewController { .streakActivity, .continueCourse, .enrolledCourses, + .reviewsAndWishlist, .visitedCourses, .popularCourses ] @@ -25,6 +26,7 @@ final class HomeViewController: BaseExploreViewController { private var lastIsAuthorizedFlag = false private var currentEnrolledCourseListState: EnrolledCourseListState? + private var currentReviewsAndWishlistState: ReviewsAndWishlistState? private lazy var streakView = StreakActivityView() private lazy var homeInteractor = self.interactor as? HomeInteractorProtocol @@ -60,6 +62,9 @@ final class HomeViewController: BaseExploreViewController { if strongSelf.currentEnrolledCourseListState == .empty { strongSelf.refreshStateForEnrolledCourses(state: .normal) } + if strongSelf.currentReviewsAndWishlistState == .shown { + strongSelf.refreshReviewsAndWishlist(state: .shown) + } strongSelf.refreshStateForVisitedCourses(state: .shown) } @@ -244,10 +249,15 @@ final class HomeViewController: BaseExploreViewController { (view, viewController) = (placeholderView, nil) } + let contentViewInsets = state == .normal + ? .zero + : CourseListContainerViewFactory.Appearance.horizontalContentInsets + let containerView = CourseListContainerViewFactory(colorMode: .light) .makeHorizontalContainerView( for: view, - headerDescription: state.headerDescription + headerDescription: state.headerDescription, + contentViewInsets: contentViewInsets ) containerView.onShowAllButtonClick = { [weak self] in @@ -267,6 +277,42 @@ final class HomeViewController: BaseExploreViewController { self.currentEnrolledCourseListState = state } + // MARK: - Reviews and wishlist submodule + + private enum ReviewsAndWishlistState { + case shown + case hidden + } + + private func refreshReviewsAndWishlist(state: ReviewsAndWishlistState) { + let submoduleType = Home.Submodule.reviewsAndWishlist + + switch state { + case .shown: + if let submodule = self.getSubmodule(type: submoduleType), + let containerViewController = submodule.viewController as? ReviewsAndWishlistContainerViewController { + containerViewController.refreshSubmodules() + } else { + let containerViewController = ReviewsAndWishlistContainerViewController() + + self.registerSubmodule( + .init( + viewController: containerViewController, + view: containerViewController.view, + isLanguageDependent: false, + type: submoduleType + ) + ) + } + case .hidden: + if let submodule = self.getSubmodule(type: submoduleType) { + self.removeSubmodule(submodule) + } + } + + self.currentReviewsAndWishlistState = state + } + // MARK: - Visited courses submodule private enum VisitedCourseListState { @@ -484,9 +530,11 @@ extension HomeViewController: HomeViewControllerProtocol { let shouldDisplayContinueCourse = viewModel.isAuthorized let shouldDisplayAnonymousPlaceholder = !viewModel.isAuthorized + let shouldDisplayReviewsAndWishlist = viewModel.isAuthorized strongSelf.refreshContinueCourse(state: shouldDisplayContinueCourse ? .shown : .hidden) strongSelf.refreshStateForEnrolledCourses(state: shouldDisplayAnonymousPlaceholder ? .anonymous : .normal) + strongSelf.refreshReviewsAndWishlist(state: shouldDisplayReviewsAndWishlist ? .shown : .hidden) strongSelf.refreshStateForVisitedCourses(state: .shown) strongSelf.refreshStateForPopularCourses(state: .normal) } diff --git a/Stepic/Sources/Modules/HomeSubmodules/ReviewsAndWishlistContainerViewController.swift b/Stepic/Sources/Modules/HomeSubmodules/ReviewsAndWishlistContainerViewController.swift new file mode 100644 index 0000000000..349f390c0c --- /dev/null +++ b/Stepic/Sources/Modules/HomeSubmodules/ReviewsAndWishlistContainerViewController.swift @@ -0,0 +1,63 @@ +import SnapKit +import UIKit + +extension ReviewsAndWishlistContainerViewController { + enum Appearance { + static let stackViewSpacing: CGFloat = 12 + static let stackViewHeight: CGFloat = 100 + static let stackViewInsets = UIEdgeInsets(top: 4, left: 20, bottom: 10, right: 20) + } +} + +final class ReviewsAndWishlistContainerViewController: UIViewController { + private let userCoursesReviewsBlockAssembly: UserCoursesReviewsBlockAssembly + + private lazy var stackView: UIStackView = { + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.distribution = .fillEqually + stackView.spacing = Appearance.stackViewSpacing + return stackView + }() + + init() { + self.userCoursesReviewsBlockAssembly = UserCoursesReviewsBlockAssembly() + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + self.setup() + } + + // MARK: Public API + + func refreshSubmodules() { + self.userCoursesReviewsBlockAssembly.moduleInput?.refreshUserCoursesReviews() + } + + // MARK: Private API + + private func setup() { + self.view.addSubview(self.stackView) + self.stackView.translatesAutoresizingMaskIntoConstraints = false + self.stackView.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(Appearance.stackViewInsets) + make.height.equalTo(Appearance.stackViewHeight) + } + + let userCoursesReviewsBlockViewController = self.userCoursesReviewsBlockAssembly.makeModule() + + self.addChild(userCoursesReviewsBlockViewController) + self.stackView.addArrangedSubview(userCoursesReviewsBlockViewController.view) + userCoursesReviewsBlockViewController.didMove(toParent: self) + + let wishlistView = UIView() + self.stackView.addArrangedSubview(wishlistView) + } +} diff --git a/Stepic/Sources/Modules/HomeSubmodules/UserCoursesReviewsBlock/InputOutput/UserCoursesReviewsBlockInputProtocol.swift b/Stepic/Sources/Modules/HomeSubmodules/UserCoursesReviewsBlock/InputOutput/UserCoursesReviewsBlockInputProtocol.swift new file mode 100644 index 0000000000..556b28c4aa --- /dev/null +++ b/Stepic/Sources/Modules/HomeSubmodules/UserCoursesReviewsBlock/InputOutput/UserCoursesReviewsBlockInputProtocol.swift @@ -0,0 +1,5 @@ +import Foundation + +protocol UserCoursesReviewsBlockInputProtocol: AnyObject { + func refreshUserCoursesReviews() +} diff --git a/Stepic/Sources/Modules/HomeSubmodules/UserCoursesReviewsBlock/UserCoursesReviewsBlockAssembly.swift b/Stepic/Sources/Modules/HomeSubmodules/UserCoursesReviewsBlock/UserCoursesReviewsBlockAssembly.swift new file mode 100644 index 0000000000..31b1e86c9b --- /dev/null +++ b/Stepic/Sources/Modules/HomeSubmodules/UserCoursesReviewsBlock/UserCoursesReviewsBlockAssembly.swift @@ -0,0 +1,25 @@ +import UIKit + +final class UserCoursesReviewsBlockAssembly: Assembly { + var moduleInput: UserCoursesReviewsBlockInputProtocol? + + func makeModule() -> UIViewController { + let userCoursesReviewsProvider = UserCoursesReviewsProvider( + userAccountService: UserAccountService(), + courseReviewsNetworkService: CourseReviewsNetworkService(courseReviewsAPI: CourseReviewsAPI()), + courseReviewsPersistenceService: CourseReviewsPersistenceService(), + coursesNetworkService: CoursesNetworkService(coursesAPI: CoursesAPI()), + coursesPersistenceService: CoursesPersistenceService() + ) + + let provider = UserCoursesReviewsBlockProvider(userCoursesReviewsProvider: userCoursesReviewsProvider) + let presenter = UserCoursesReviewsBlockPresenter() + let interactor = UserCoursesReviewsBlockInteractor(presenter: presenter, provider: provider) + let viewController = UserCoursesReviewsBlockViewController(interactor: interactor) + + presenter.viewController = viewController + self.moduleInput = interactor + + return viewController + } +} diff --git a/Stepic/Sources/Modules/HomeSubmodules/UserCoursesReviewsBlock/UserCoursesReviewsBlockDataFlow.swift b/Stepic/Sources/Modules/HomeSubmodules/UserCoursesReviewsBlock/UserCoursesReviewsBlockDataFlow.swift new file mode 100644 index 0000000000..8b51289367 --- /dev/null +++ b/Stepic/Sources/Modules/HomeSubmodules/UserCoursesReviewsBlock/UserCoursesReviewsBlockDataFlow.swift @@ -0,0 +1,32 @@ +import Foundation + +enum UserCoursesReviewsBlock { + /// Show reviews + enum ReviewsLoad { + struct Request {} + + struct Data { + let possibleReviewsCount: Int + let leavedReviewsCount: Int + + var isEmpty: Bool { + self.possibleReviewsCount == 0 && self.leavedReviewsCount == 0 + } + } + + struct Response { + let result: StepikResult + } + + struct ViewModel { + let state: ViewControllerState + } + } + + // MARK: States + + enum ViewControllerState { + case loading + case result(data: UserCoursesReviewsBlockViewModel) + } +} diff --git a/Stepic/Sources/Modules/HomeSubmodules/UserCoursesReviewsBlock/UserCoursesReviewsBlockInteractor.swift b/Stepic/Sources/Modules/HomeSubmodules/UserCoursesReviewsBlock/UserCoursesReviewsBlockInteractor.swift new file mode 100644 index 0000000000..cd84125c39 --- /dev/null +++ b/Stepic/Sources/Modules/HomeSubmodules/UserCoursesReviewsBlock/UserCoursesReviewsBlockInteractor.swift @@ -0,0 +1,105 @@ +import Foundation +import PromiseKit + +protocol UserCoursesReviewsBlockInteractorProtocol { + func doReviewsLoad(request: UserCoursesReviewsBlock.ReviewsLoad.Request) +} + +final class UserCoursesReviewsBlockInteractor: UserCoursesReviewsBlockInteractorProtocol { + private let presenter: UserCoursesReviewsBlockPresenterProtocol + private let provider: UserCoursesReviewsBlockProviderProtocol + + private var didLoadFromCache = false + private var didPresentReviews = false + + init( + presenter: UserCoursesReviewsBlockPresenterProtocol, + provider: UserCoursesReviewsBlockProviderProtocol + ) { + self.presenter = presenter + self.provider = provider + } + + func doReviewsLoad(request: UserCoursesReviewsBlock.ReviewsLoad.Request) { + self.fetchReviewsInAppropriateMode().done { data in + let isCacheEmpty = !self.didLoadFromCache && data.isEmpty + + if !isCacheEmpty { + self.didPresentReviews = true + self.presenter.presentReviews(response: .init(result: .success(data))) + } + + if !self.didLoadFromCache { + self.didLoadFromCache = true + self.doReviewsLoad(request: .init()) + } + }.catch { error in + switch error as? Error { + case .some(.remoteFetchFailed): + if self.didLoadFromCache && !self.didPresentReviews { + self.presenter.presentReviews(response: .init(result: .failure(error))) + } + case .some(.cacheFetchFailed): + break + default: + self.presenter.presentReviews(response: .init(result: .failure(error))) + } + } + } + + // MARK: Private API + + private func fetchReviewsInAppropriateMode() -> Promise { + Promise { seal in + firstly { + self.didLoadFromCache + ? self.provider.fetchLeavedCourseReviewsFromRemote() + : self.provider.fetchLeavedCourseReviewsFromCache() + }.then { leavedCourseReviews -> Promise<([Course], [CourseReview])> in + self.provider.fetchPossibleCoursesFromCache().map { ($0, leavedCourseReviews) } + }.done { possibleCourses, leavedCourseReviews in + let filteredPossibleCourses = possibleCourses.filter { course in + !leavedCourseReviews.contains(where: { $0.courseID == course.id }) + } + + let response = UserCoursesReviewsBlock.ReviewsLoad.Data( + possibleReviewsCount: filteredPossibleCourses.count, + leavedReviewsCount: leavedCourseReviews.count + ) + + seal.fulfill(response) + }.catch { error in + switch error as? UserCoursesReviewsProvider.Error { + case .some(.persistenceFetchFailed): + seal.reject(Error.cacheFetchFailed) + case .some(.networkFetchFailed): + seal.reject(Error.remoteFetchFailed) + default: + seal.reject(Error.fetchFailed) + } + } + } + } + + enum Error: Swift.Error { + case fetchFailed + case cacheFetchFailed + case remoteFetchFailed + } +} + +extension UserCoursesReviewsBlockInteractor: UserCoursesReviewsBlockInputProtocol { + func refreshUserCoursesReviews() { + self.doReviewsLoad(request: .init()) + } +} + +extension UserCoursesReviewsBlockInteractor: UserCoursesReviewsOutputProtocol { + func handleUserCoursesReviewsCountsChanged(possibleReviewsCount: Int, leavedCourseReviewsCount: Int) { + let response = UserCoursesReviewsBlock.ReviewsLoad.Data( + possibleReviewsCount: possibleReviewsCount, + leavedReviewsCount: leavedCourseReviewsCount + ) + self.presenter.presentReviews(response: .init(result: .success(response))) + } +} diff --git a/Stepic/Sources/Modules/HomeSubmodules/UserCoursesReviewsBlock/UserCoursesReviewsBlockPresenter.swift b/Stepic/Sources/Modules/HomeSubmodules/UserCoursesReviewsBlock/UserCoursesReviewsBlockPresenter.swift new file mode 100644 index 0000000000..2197c41095 --- /dev/null +++ b/Stepic/Sources/Modules/HomeSubmodules/UserCoursesReviewsBlock/UserCoursesReviewsBlockPresenter.swift @@ -0,0 +1,33 @@ +import UIKit + +protocol UserCoursesReviewsBlockPresenterProtocol { + func presentReviews(response: UserCoursesReviewsBlock.ReviewsLoad.Response) +} + +final class UserCoursesReviewsBlockPresenter: UserCoursesReviewsBlockPresenterProtocol { + weak var viewController: UserCoursesReviewsBlockViewControllerProtocol? + + func presentReviews(response: UserCoursesReviewsBlock.ReviewsLoad.Response) { + let viewModel: UserCoursesReviewsBlockViewModel + + switch response.result { + case .success(let data): + let formattedLeavedCourseReviewsCount = data.leavedReviewsCount == 0 + ? NSLocalizedString("UserCoursesReviewsPlaceholderEmptyTitle", comment: "") + : FormatterHelper.reviewsCount(data.leavedReviewsCount) + + let formattedPossibleReviewsCount = data.possibleReviewsCount > 0 + ? "(+\(data.possibleReviewsCount))" + : nil + + viewModel = .init( + formattedPossibleReviewsCount: formattedPossibleReviewsCount, + formattedLeavedCourseReviewsCount: formattedLeavedCourseReviewsCount + ) + case .failure: + viewModel = .init(formattedPossibleReviewsCount: nil, formattedLeavedCourseReviewsCount: nil) + } + + self.viewController?.displayReviews(viewModel: .init(state: .result(data: viewModel))) + } +} diff --git a/Stepic/Sources/Modules/HomeSubmodules/UserCoursesReviewsBlock/UserCoursesReviewsBlockProvider.swift b/Stepic/Sources/Modules/HomeSubmodules/UserCoursesReviewsBlock/UserCoursesReviewsBlockProvider.swift new file mode 100644 index 0000000000..fedc8de0c5 --- /dev/null +++ b/Stepic/Sources/Modules/HomeSubmodules/UserCoursesReviewsBlock/UserCoursesReviewsBlockProvider.swift @@ -0,0 +1,28 @@ +import Foundation +import PromiseKit + +protocol UserCoursesReviewsBlockProviderProtocol: UserCoursesReviewsProviderProtocol {} + +final class UserCoursesReviewsBlockProvider: UserCoursesReviewsBlockProviderProtocol { + private let userCoursesReviewsProvider: UserCoursesReviewsProviderProtocol + + init(userCoursesReviewsProvider: UserCoursesReviewsProviderProtocol) { + self.userCoursesReviewsProvider = userCoursesReviewsProvider + } + + func fetchLeavedCourseReviewsFromCache() -> Promise<[CourseReview]> { + self.userCoursesReviewsProvider.fetchLeavedCourseReviewsFromCache() + } + + func fetchLeavedCourseReviewsFromRemote() -> Promise<[CourseReview]> { + self.userCoursesReviewsProvider.fetchLeavedCourseReviewsFromRemote() + } + + func fetchPossibleCoursesFromCache() -> Promise<[Course]> { + self.userCoursesReviewsProvider.fetchPossibleCoursesFromCache() + } + + func deleteCourseReview(id: CourseReview.IdType) -> Promise { + self.userCoursesReviewsProvider.deleteCourseReview(id: id) + } +} diff --git a/Stepic/Sources/Modules/HomeSubmodules/UserCoursesReviewsBlock/UserCoursesReviewsBlockView.swift b/Stepic/Sources/Modules/HomeSubmodules/UserCoursesReviewsBlock/UserCoursesReviewsBlockView.swift new file mode 100644 index 0000000000..c4de0b0814 --- /dev/null +++ b/Stepic/Sources/Modules/HomeSubmodules/UserCoursesReviewsBlock/UserCoursesReviewsBlockView.swift @@ -0,0 +1,225 @@ +import SnapKit +import UIKit + +protocol UserCoursesReviewsBlockViewDelegate: AnyObject { + func userCoursesReviewsBlockViewDidClick(_ view: UserCoursesReviewsBlockView) +} + +extension UserCoursesReviewsBlockView { + struct Appearance { + let backgroundColor = UIColor.dynamic(light: .white, dark: .stepikSecondaryBackground) + let cornerRadius: CGFloat = 13.0 + + let shadowColor = UIColor.black + let shadowOffset = CGSize(width: 0, height: 1) + let shadowRadius: CGFloat = 4.0 + let shadowOpacity: Float = 0.1 + + let imageViewSize = CGSize(width: 24, height: 16) + let imageViewInsets = LayoutInsets(top: 16, left: 16) + + let titleTextColor = UIColor.stepikMaterialPrimaryText + let titleFont = Typography.headlineFont + let titleInsets = LayoutInsets(top: 8, right: 16) + + let subtitleTextColor = UIColor.stepikMaterialSecondaryText + let subtitleFont = Typography.calloutFont + let subtitleInsets = LayoutInsets(top: 8) + + let accentSubtitleTextColor = UIColor.stepikGreenFixed + let accentSubtitleFont = Typography.calloutFont + let accentSubtitleInsets = LayoutInsets(left: 4, right: 16) + + let accentIndicatorViewBackgroundColor = UIColor.stepikGreenFixed + let accentIndicatorViewSize = CGSize(width: 10, height: 10) + let accentIndicatorViewCornerRadius: CGFloat = 5 + let accentIndicatorViewInsets = LayoutInsets(top: 16, right: 16) + + let skeletonViewHeight: CGFloat = 8 + let skeletonViewInsets = LayoutInsets(top: 16) + } +} + +final class UserCoursesReviewsBlockView: UIView { + let appearance: Appearance + + weak var delegate: UserCoursesReviewsBlockViewDelegate? + + private lazy var imageView: UIImageView = { + let image = UIImage(named: "user-courses-reviews-block-stars") + let imageView = UIImageView(image: image) + imageView.contentMode = .scaleAspectFit + return imageView + }() + + private lazy var titleLabel: UILabel = { + let label = UILabel() + label.text = NSLocalizedString("UserCoursesReviewsBlockTitle", comment: "") + label.textColor = self.appearance.titleTextColor + label.font = self.appearance.titleFont + label.numberOfLines = 1 + label.textAlignment = .left + return label + }() + + private lazy var subtitleLabel: UILabel = { + let label = UILabel() + label.textColor = self.appearance.subtitleTextColor + label.font = self.appearance.subtitleFont + label.numberOfLines = 1 + return label + }() + + private lazy var accentSubtitleLabel: UILabel = { + let label = UILabel() + label.textColor = self.appearance.accentSubtitleTextColor + label.font = self.appearance.accentSubtitleFont + label.numberOfLines = 1 + return label + }() + + private lazy var accentIndicatorView: UIView = { + let view = UIView() + view.backgroundColor = self.appearance.accentIndicatorViewBackgroundColor + view.isHidden = true + view.setRoundedCorners(cornerRadius: self.appearance.accentIndicatorViewCornerRadius) + return view + }() + + private lazy var overlayButton: UIButton = { + let button = HighlightFakeButton() + button.addTarget(self, action: #selector(self.overlayButtonClicked), for: .touchUpInside) + return button + }() + + private lazy var skeletonFakeView: UIView = { + let view = UIView() + view.isHidden = true + return view + }() + + init( + frame: CGRect = .zero, + appearance: Appearance = Appearance() + ) { + self.appearance = appearance + super.init(frame: frame) + + self.addSubviews() + self.makeConstraints() + } + + @available(*, unavailable) + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + + self.backgroundColor = self.appearance.backgroundColor + self.layer.cornerRadius = self.appearance.cornerRadius + self.layer.masksToBounds = true + + self.layer.shadowColor = self.appearance.shadowColor.cgColor + self.layer.shadowOffset = self.appearance.shadowOffset + self.layer.shadowRadius = self.appearance.shadowRadius + self.layer.shadowOpacity = self.appearance.shadowOpacity + self.layer.masksToBounds = false + self.layer.shadowPath = UIBezierPath( + roundedRect: self.bounds, + cornerRadius: self.layer.cornerRadius + ).cgPath + } + + func showLoading() { + [self.subtitleLabel, self.accentSubtitleLabel, self.accentIndicatorView].forEach { $0.alpha = 0 } + self.overlayButton.isUserInteractionEnabled = false + + self.skeletonFakeView.isHidden = false + self.skeletonFakeView.skeleton.viewBuilder = { + UserCoursesReviewsBlockSkeletonView() + } + self.skeletonFakeView.skeleton.show() + } + + func hideLoading() { + self.skeletonFakeView.skeleton.hide() + self.skeletonFakeView.isHidden = true + + [self.subtitleLabel, self.accentSubtitleLabel, self.accentIndicatorView].forEach { $0.alpha = 1 } + self.overlayButton.isUserInteractionEnabled = true + } + + func configure(viewModel: UserCoursesReviewsBlockViewModel) { + self.subtitleLabel.text = viewModel.formattedLeavedCourseReviewsCount + self.accentSubtitleLabel.text = viewModel.formattedPossibleReviewsCount + self.accentIndicatorView.isHidden = viewModel.formattedPossibleReviewsCount?.trimmed().isEmpty ?? true + } + + @objc + private func overlayButtonClicked() { + self.delegate?.userCoursesReviewsBlockViewDidClick(self) + } +} + +extension UserCoursesReviewsBlockView: ProgrammaticallyInitializableViewProtocol { + func addSubviews() { + self.addSubview(self.imageView) + self.addSubview(self.titleLabel) + self.addSubview(self.accentIndicatorView) + self.addSubview(self.subtitleLabel) + self.addSubview(self.accentSubtitleLabel) + self.addSubview(self.skeletonFakeView) + self.addSubview(self.overlayButton) + } + + func makeConstraints() { + self.imageView.translatesAutoresizingMaskIntoConstraints = false + self.imageView.snp.makeConstraints { make in + make.top.equalToSuperview().offset(self.appearance.imageViewInsets.top) + make.leading.equalToSuperview().offset(self.appearance.imageViewInsets.left) + make.size.equalTo(self.appearance.imageViewSize) + } + + self.titleLabel.translatesAutoresizingMaskIntoConstraints = false + self.titleLabel.snp.makeConstraints { make in + make.top.equalTo(self.imageView.snp.bottom).offset(self.appearance.titleInsets.top) + make.leading.equalTo(self.imageView.snp.leading) + make.trailing.equalToSuperview().offset(-self.appearance.titleInsets.right) + } + + self.accentIndicatorView.translatesAutoresizingMaskIntoConstraints = false + self.accentIndicatorView.snp.makeConstraints { make in + make.top.equalToSuperview().offset(self.appearance.accentIndicatorViewInsets.top) + make.trailing.equalToSuperview().offset(-self.appearance.accentIndicatorViewInsets.right) + make.size.equalTo(self.appearance.accentIndicatorViewSize) + } + + self.subtitleLabel.translatesAutoresizingMaskIntoConstraints = false + self.subtitleLabel.snp.makeConstraints { make in + make.top.equalTo(self.titleLabel.snp.bottom).offset(self.appearance.subtitleInsets.top) + make.leading.equalTo(self.titleLabel.snp.leading) + } + + self.accentSubtitleLabel.translatesAutoresizingMaskIntoConstraints = false + self.accentSubtitleLabel.snp.makeConstraints { make in + make.top.equalTo(self.subtitleLabel.snp.top) + make.leading.equalTo(self.subtitleLabel.snp.trailing).offset(self.appearance.accentSubtitleInsets.left) + make.trailing.lessThanOrEqualToSuperview().offset(-self.appearance.accentSubtitleInsets.right) + } + + self.skeletonFakeView.translatesAutoresizingMaskIntoConstraints = false + self.skeletonFakeView.snp.makeConstraints { make in + make.top.equalTo(self.titleLabel.snp.bottom).offset(self.appearance.skeletonViewInsets.top) + make.leading.equalTo(self.titleLabel.snp.leading) + make.width.equalToSuperview().multipliedBy(0.33) + make.height.equalTo(self.appearance.skeletonViewHeight) + } + + self.overlayButton.translatesAutoresizingMaskIntoConstraints = false + self.overlayButton.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } +} diff --git a/Stepic/Sources/Modules/HomeSubmodules/UserCoursesReviewsBlock/UserCoursesReviewsBlockViewController.swift b/Stepic/Sources/Modules/HomeSubmodules/UserCoursesReviewsBlock/UserCoursesReviewsBlockViewController.swift new file mode 100644 index 0000000000..38e753d2cb --- /dev/null +++ b/Stepic/Sources/Modules/HomeSubmodules/UserCoursesReviewsBlock/UserCoursesReviewsBlockViewController.swift @@ -0,0 +1,66 @@ +import UIKit + +protocol UserCoursesReviewsBlockViewControllerProtocol: AnyObject { + func displayReviews(viewModel: UserCoursesReviewsBlock.ReviewsLoad.ViewModel) +} + +final class UserCoursesReviewsBlockViewController: UIViewController { + private let interactor: UserCoursesReviewsBlockInteractorProtocol + + var userCoursesReviewsBlockView: UserCoursesReviewsBlockView? { self.view as? UserCoursesReviewsBlockView } + + private var state: UserCoursesReviewsBlock.ViewControllerState + + init( + interactor: UserCoursesReviewsBlockInteractorProtocol, + initialState: UserCoursesReviewsBlock.ViewControllerState = .loading + ) { + self.interactor = interactor + self.state = initialState + + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + let view = UserCoursesReviewsBlockView(frame: UIScreen.main.bounds) + self.view = view + view.delegate = self + } + + override func viewDidLoad() { + super.viewDidLoad() + self.updateState(newState: self.state) + } + + private func updateState(newState: UserCoursesReviewsBlock.ViewControllerState) { + switch newState { + case .loading: + self.userCoursesReviewsBlockView?.showLoading() + case .result(let viewModel): + self.userCoursesReviewsBlockView?.hideLoading() + self.userCoursesReviewsBlockView?.configure(viewModel: viewModel) + } + + self.state = newState + } +} + +extension UserCoursesReviewsBlockViewController: UserCoursesReviewsBlockViewControllerProtocol { + func displayReviews(viewModel: UserCoursesReviewsBlock.ReviewsLoad.ViewModel) { + self.updateState(newState: viewModel.state) + } +} + +extension UserCoursesReviewsBlockViewController: UserCoursesReviewsBlockViewDelegate { + func userCoursesReviewsBlockViewDidClick(_ view: UserCoursesReviewsBlockView) { + let assembly = UserCoursesReviewsAssembly( + output: self.interactor as? UserCoursesReviewsOutputProtocol + ) + self.push(module: assembly.makeModule()) + } +} diff --git a/Stepic/Sources/Modules/HomeSubmodules/UserCoursesReviewsBlock/UserCoursesReviewsBlockViewModel.swift b/Stepic/Sources/Modules/HomeSubmodules/UserCoursesReviewsBlock/UserCoursesReviewsBlockViewModel.swift new file mode 100644 index 0000000000..edfeebb936 --- /dev/null +++ b/Stepic/Sources/Modules/HomeSubmodules/UserCoursesReviewsBlock/UserCoursesReviewsBlockViewModel.swift @@ -0,0 +1,6 @@ +import Foundation + +struct UserCoursesReviewsBlockViewModel { + let formattedPossibleReviewsCount: String? + let formattedLeavedCourseReviewsCount: String? +} diff --git a/Stepic/Sources/Modules/LessonPanModals/LessonFinishedStepsPanModal/LessonFinishedStepsPanModalProvider.swift b/Stepic/Sources/Modules/LessonPanModals/LessonFinishedStepsPanModal/LessonFinishedStepsPanModalProvider.swift index d8d6a93fc2..f7af404013 100644 --- a/Stepic/Sources/Modules/LessonPanModals/LessonFinishedStepsPanModal/LessonFinishedStepsPanModalProvider.swift +++ b/Stepic/Sources/Modules/LessonPanModals/LessonFinishedStepsPanModal/LessonFinishedStepsPanModalProvider.swift @@ -109,7 +109,7 @@ final class LessonFinishedStepsPanModalProvider: LessonFinishedStepsPanModalProv } return Promise { seal in - self.courseReviewsPersistenceService.fetch(by: self.courseID, userID: currentUser.id).done { review in + self.courseReviewsPersistenceService.fetch(courseID: self.courseID, userID: currentUser.id).done { review in review?.user = currentUser CoreDataHelper.shared.save() diff --git a/Stepic/Sources/Modules/UserCoursesReviews/InputOutput/UserCoursesReviewsOutputProtocol.swift b/Stepic/Sources/Modules/UserCoursesReviews/InputOutput/UserCoursesReviewsOutputProtocol.swift new file mode 100644 index 0000000000..58b4de60e1 --- /dev/null +++ b/Stepic/Sources/Modules/UserCoursesReviews/InputOutput/UserCoursesReviewsOutputProtocol.swift @@ -0,0 +1,5 @@ +import Foundation + +protocol UserCoursesReviewsOutputProtocol: AnyObject { + func handleUserCoursesReviewsCountsChanged(possibleReviewsCount: Int, leavedCourseReviewsCount: Int) +} diff --git a/Stepic/Sources/Modules/UserCoursesReviews/UserCoursesReviewsAssembly.swift b/Stepic/Sources/Modules/UserCoursesReviews/UserCoursesReviewsAssembly.swift new file mode 100644 index 0000000000..a66b78a708 --- /dev/null +++ b/Stepic/Sources/Modules/UserCoursesReviews/UserCoursesReviewsAssembly.swift @@ -0,0 +1,31 @@ +import UIKit + +final class UserCoursesReviewsAssembly: Assembly { + private weak var moduleOutput: UserCoursesReviewsOutputProtocol? + + init(output: UserCoursesReviewsOutputProtocol? = nil) { + self.moduleOutput = output + } + + func makeModule() -> UIViewController { + let provider = UserCoursesReviewsProvider( + userAccountService: UserAccountService(), + courseReviewsNetworkService: CourseReviewsNetworkService(courseReviewsAPI: CourseReviewsAPI()), + courseReviewsPersistenceService: CourseReviewsPersistenceService(), + coursesNetworkService: CoursesNetworkService(coursesAPI: CoursesAPI()), + coursesPersistenceService: CoursesPersistenceService() + ) + let presenter = UserCoursesReviewsPresenter() + let interactor = UserCoursesReviewsInteractor( + presenter: presenter, + provider: provider, + adaptiveStorageManager: AdaptiveStorageManager() + ) + let viewController = UserCoursesReviewsViewController(interactor: interactor) + + presenter.viewController = viewController + interactor.moduleOutput = self.moduleOutput + + return viewController + } +} diff --git a/Stepic/Sources/Modules/UserCoursesReviews/UserCoursesReviewsDataFlow.swift b/Stepic/Sources/Modules/UserCoursesReviews/UserCoursesReviewsDataFlow.swift new file mode 100644 index 0000000000..8be93956c1 --- /dev/null +++ b/Stepic/Sources/Modules/UserCoursesReviews/UserCoursesReviewsDataFlow.swift @@ -0,0 +1,146 @@ +import Foundation + +enum UserCoursesReviews { + // MARK: Common structs + + struct ReviewsResult { + let possibleReviews: [UserCoursesReviewsItemViewModel] + let leavedReviews: [UserCoursesReviewsItemViewModel] + } + + /// Show reviews + enum ReviewsLoad { + struct Request {} + + struct Data { + let possibleReviews: [CourseReviewPlainObject] + let leavedReviews: [CourseReviewPlainObject] + let supportedInAdaptiveModeCoursesIDs: [Course.IdType] + + var isEmpty: Bool { + self.possibleReviews.isEmpty && self.leavedReviews.isEmpty + } + } + + struct Response { + let result: StepikResult + } + + struct ViewModel { + let state: ViewControllerState + } + } + + /// Show course info module + enum CourseInfoPresentation { + struct Request { + let viewModelUniqueIdentifier: UniqueIdentifierType + } + + struct Response { + let courseID: Course.IdType + } + + struct ViewModel { + let courseID: Course.IdType + } + } + + /// Do main action (alert, write review, etc) + enum MainReviewAction { + struct Request { + let viewModelUniqueIdentifier: UniqueIdentifierType + } + } + + /// Write possible course review + enum WritePossibleCourseReviewPresentation { + struct Request { + let viewModelUniqueIdentifier: UniqueIdentifierType + } + + struct Response { + let courseReviewPlainObject: CourseReviewPlainObject + } + + struct ViewModel { + let courseReviewPlainObject: CourseReviewPlainObject + } + } + + /// Update possible course review score + enum PossibleCourseReviewScoreUpdate { + struct Request { + let viewModelUniqueIdentifier: UniqueIdentifierType + let score: Int + } + } + + /// Show leaved course review action sheet + enum LeavedCourseReviewActionSheetPresentation { + struct Request { + let viewModelUniqueIdentifier: UniqueIdentifierType + } + + struct Response { + let viewModelUniqueIdentifier: UniqueIdentifierType + } + + struct ViewModel { + let viewModelUniqueIdentifier: UniqueIdentifierType + } + } + + /// Show edit leaved course review + enum EditLeavedCourseReviewPresentation { + struct Request { + let viewModelUniqueIdentifier: UniqueIdentifierType + } + + struct Response { + let courseReview: CourseReview + } + + struct ViewModel { + let courseReview: CourseReview + } + } + + /// Delete leaved course review + enum DeleteLeavedCourseReview { + struct Request { + let viewModelUniqueIdentifier: UniqueIdentifierType + } + } + + /// Handle HUD + enum BlockingWaitingIndicatorUpdate { + struct Response { + let shouldDismiss: Bool + } + + struct ViewModel { + let shouldDismiss: Bool + } + } + + /// Handle HUD + enum BlockingWaitingIndicatorStatusUpdate { + struct Response { + let success: Bool + } + + struct ViewModel { + let success: Bool + } + } + + // MARK: States + + enum ViewControllerState { + case loading + case error + case empty + case result(data: ReviewsResult) + } +} diff --git a/Stepic/Sources/Modules/UserCoursesReviews/UserCoursesReviewsInteractor.swift b/Stepic/Sources/Modules/UserCoursesReviews/UserCoursesReviewsInteractor.swift new file mode 100644 index 0000000000..51e19cc678 --- /dev/null +++ b/Stepic/Sources/Modules/UserCoursesReviews/UserCoursesReviewsInteractor.swift @@ -0,0 +1,324 @@ +import Foundation +import PromiseKit + +protocol UserCoursesReviewsInteractorProtocol { + func doReviewsLoad(request: UserCoursesReviews.ReviewsLoad.Request) + func doCourseInfoPresentation(request: UserCoursesReviews.CourseInfoPresentation.Request) + func doMainReviewAction(request: UserCoursesReviews.MainReviewAction.Request) + func doPossibleCourseReviewScoreUpdate(request: UserCoursesReviews.PossibleCourseReviewScoreUpdate.Request) + func doEditLeavedCourseReviewPresentation(request: UserCoursesReviews.EditLeavedCourseReviewPresentation.Request) + func doDeleteLeavedCourseReview(request: UserCoursesReviews.DeleteLeavedCourseReview.Request) + func doWritePossibleCourseReviewPresentation( + request: UserCoursesReviews.WritePossibleCourseReviewPresentation.Request + ) + func doLeavedCourseReviewActionSheetPresentation( + request: UserCoursesReviews.LeavedCourseReviewActionSheetPresentation.Request + ) +} + +final class UserCoursesReviewsInteractor: UserCoursesReviewsInteractorProtocol { + private static let outputDebounceInterval: TimeInterval = 0.1 + + weak var moduleOutput: UserCoursesReviewsOutputProtocol? + + private let presenter: UserCoursesReviewsPresenterProtocol + private let provider: UserCoursesReviewsProviderProtocol + private let adaptiveStorageManager: AdaptiveStorageManagerProtocol + + private let outputDebouncer = Debouncer(delay: UserCoursesReviewsInteractor.outputDebounceInterval) + + private var currentLeavedCourseReviews: [CourseReview]? { + didSet { + self.outputReviewsCounts() + } + } + private var currentPossibleCourses: [Course]? { + didSet { + self.outputReviewsCounts() + } + } + + private var currentPossibleReviews: [CourseReviewPlainObject]? + + private var didLoadFromCache = false + private var didPresentReviews = false + + init( + presenter: UserCoursesReviewsPresenterProtocol, + provider: UserCoursesReviewsProviderProtocol, + adaptiveStorageManager: AdaptiveStorageManagerProtocol + ) { + self.presenter = presenter + self.provider = provider + self.adaptiveStorageManager = adaptiveStorageManager + } + + func doReviewsLoad(request: UserCoursesReviews.ReviewsLoad.Request) { + self.fetchReviewsInAppropriateMode().done { data in + let isCacheEmpty = !self.didLoadFromCache && data.isEmpty + + if !isCacheEmpty { + self.didPresentReviews = true + self.presenter.presentReviews(response: .init(result: .success(data))) + } + + if !self.didLoadFromCache { + self.didLoadFromCache = true + self.doReviewsLoad(request: .init()) + } + }.catch { error in + switch error as? Error { + case .some(.remoteFetchFailed): + if self.didLoadFromCache && !self.didPresentReviews { + self.presenter.presentReviews(response: .init(result: .failure(error))) + } + case .some(.cacheFetchFailed): + break + default: + self.presenter.presentReviews(response: .init(result: .failure(error))) + } + } + } + + func doCourseInfoPresentation(request: UserCoursesReviews.CourseInfoPresentation.Request) { + let (_, courseID) = UserCoursesReviewsUniqueIdentifierMapper.toParts( + uniqueIdentifier: request.viewModelUniqueIdentifier + ) + + if courseID != UserCoursesReviewsUniqueIdentifierMapper.notAIdentifier { + self.presenter.presentCourseInfo(response: .init(courseID: courseID)) + } + } + + func doMainReviewAction(request: UserCoursesReviews.MainReviewAction.Request) { + let (reviewID, courseID) = UserCoursesReviewsUniqueIdentifierMapper.toParts( + uniqueIdentifier: request.viewModelUniqueIdentifier + ) + + guard courseID != UserCoursesReviewsUniqueIdentifierMapper.notAIdentifier else { + return + } + + if reviewID == UserCoursesReviewsUniqueIdentifierMapper.notAIdentifier { + self.doWritePossibleCourseReviewPresentation( + request: .init(viewModelUniqueIdentifier: request.viewModelUniqueIdentifier) + ) + } else { + self.doLeavedCourseReviewActionSheetPresentation( + request: .init(viewModelUniqueIdentifier: request.viewModelUniqueIdentifier) + ) + } + } + + func doWritePossibleCourseReviewPresentation( + request: UserCoursesReviews.WritePossibleCourseReviewPresentation.Request + ) { + let (reviewID, courseID) = UserCoursesReviewsUniqueIdentifierMapper.toParts( + uniqueIdentifier: request.viewModelUniqueIdentifier + ) + + guard reviewID == UserCoursesReviewsUniqueIdentifierMapper.notAIdentifier, + let possibleReview = self.currentPossibleReviews?.first(where: { $0.courseID == courseID }) else { + return + } + + self.presenter.presentWritePossibleCourseReview(response: .init(courseReviewPlainObject: possibleReview)) + } + + func doPossibleCourseReviewScoreUpdate(request: UserCoursesReviews.PossibleCourseReviewScoreUpdate.Request) { + let (reviewID, courseID) = UserCoursesReviewsUniqueIdentifierMapper.toParts( + uniqueIdentifier: request.viewModelUniqueIdentifier + ) + + guard reviewID == UserCoursesReviewsUniqueIdentifierMapper.notAIdentifier, + let targetIndex = self.currentPossibleReviews?.firstIndex(where: { $0.courseID == courseID }), + let currentReview = self.currentPossibleReviews?[targetIndex] else { + return + } + + let newReview = CourseReviewPlainObject( + id: currentReview.id, + courseID: currentReview.courseID, + userID: currentReview.userID, + score: request.score, + text: currentReview.text, + creationDate: currentReview.creationDate, + course: currentReview.course + ) + + self.currentPossibleReviews?[targetIndex] = newReview + } + + func doLeavedCourseReviewActionSheetPresentation( + request: UserCoursesReviews.LeavedCourseReviewActionSheetPresentation.Request + ) { + self.presenter.presentLeavedCourseReviewActionSheet( + response: .init(viewModelUniqueIdentifier: request.viewModelUniqueIdentifier) + ) + } + + func doEditLeavedCourseReviewPresentation(request: UserCoursesReviews.EditLeavedCourseReviewPresentation.Request) { + let (reviewID, courseID) = UserCoursesReviewsUniqueIdentifierMapper.toParts( + uniqueIdentifier: request.viewModelUniqueIdentifier + ) + + guard let courseReview = self.currentLeavedCourseReviews?.first( + where: { $0.id == reviewID && $0.courseID == courseID } + ) else { + return + } + + self.presenter.presentEditLeavedCourseReview(response: .init(courseReview: courseReview)) + } + + func doDeleteLeavedCourseReview(request: UserCoursesReviews.DeleteLeavedCourseReview.Request) { + let (reviewID, courseID) = UserCoursesReviewsUniqueIdentifierMapper.toParts( + uniqueIdentifier: request.viewModelUniqueIdentifier + ) + + guard let courseReview = self.currentLeavedCourseReviews?.first( + where: { $0.id == reviewID && $0.courseID == courseID } + ) else { + return + } + + self.presenter.presentWaitingState(response: .init(shouldDismiss: false)) + + self.provider.deleteCourseReview(id: courseReview.id).then { + self.fetchReviewsInAppropriateMode() + }.done { data in + self.presenter.presentWaitingStatus(response: .init(success: true)) + self.presenter.presentReviews(response: .init(result: .success(data))) + }.catch { _ in + self.presenter.presentWaitingStatus(response: .init(success: false)) + } + } + + // MARK: Private API + + private func fetchReviewsInAppropriateMode() -> Promise { + Promise { seal in + firstly { + self.didLoadFromCache + ? self.provider.fetchLeavedCourseReviewsFromRemote() + : self.provider.fetchLeavedCourseReviewsFromCache() + }.then { leavedCourseReviews -> Promise<([Course], [CourseReview])> in + self.provider.fetchPossibleCoursesFromCache().map { ($0, leavedCourseReviews) } + }.done { possibleCourses, leavedCourseReviews in + self.currentLeavedCourseReviews = leavedCourseReviews + + let filteredPossibleCourses = possibleCourses.filter { course in + !leavedCourseReviews.contains(where: { $0.courseID == course.id }) + } + .sorted { $0.id > $1.id } + .sorted { ($0.progress?.lastViewed ?? 0) > ($1.progress?.lastViewed ?? 0) } + + self.currentPossibleCourses = filteredPossibleCourses + self.currentPossibleReviews = filteredPossibleCourses.map { course in + let currentPossibleReview = self.currentPossibleReviews?.first(where: { $0.courseID == course.id }) + return CourseReviewPlainObject( + id: UserCoursesReviewsUniqueIdentifierMapper.notAIdentifier, + courseID: course.id, + userID: UserCoursesReviewsUniqueIdentifierMapper.notAIdentifier, + score: currentPossibleReview?.score ?? 0, + text: "", + creationDate: Date(), + course: CoursePlainObject(course: course, withSections: false) + ) + } + + let response = self.makeReviewsDataFromCurrentData() + + seal.fulfill(response) + }.catch { error in + switch error as? UserCoursesReviewsProvider.Error { + case .some(.persistenceFetchFailed): + seal.reject(Error.cacheFetchFailed) + case .some(.networkFetchFailed): + seal.reject(Error.remoteFetchFailed) + default: + seal.reject(Error.fetchFailed) + } + } + } + } + + private func makeReviewsDataFromCurrentData() -> UserCoursesReviews.ReviewsLoad.Data { + UserCoursesReviews.ReviewsLoad.Data( + possibleReviews: self.currentPossibleReviews ?? [], + leavedReviews: (self.currentLeavedCourseReviews ?? []).map(CourseReviewPlainObject.init), + supportedInAdaptiveModeCoursesIDs: self.adaptiveStorageManager.supportedInAdaptiveModeCoursesIDs + ) + } + + private func outputReviewsCounts() { + self.outputDebouncer.action = { [weak self] in + guard let strongSelf = self else { + return + } + + strongSelf.moduleOutput?.handleUserCoursesReviewsCountsChanged( + possibleReviewsCount: strongSelf.currentPossibleReviews?.count ?? 0, + leavedCourseReviewsCount: strongSelf.currentLeavedCourseReviews?.count ?? 0 + ) + } + } + + enum Error: Swift.Error { + case fetchFailed + case cacheFetchFailed + case remoteFetchFailed + } +} + +// MARK: - UserCoursesReviewsInteractor: WriteCourseReviewOutputProtocol - + +extension UserCoursesReviewsInteractor: WriteCourseReviewOutputProtocol { + func handleCourseReviewCreated(_ courseReview: CourseReview) { + self.currentPossibleCourses = self.currentPossibleCourses?.filter { $0.id != courseReview.courseID } + self.currentPossibleReviews = self.currentPossibleReviews?.filter { $0.courseID != courseReview.courseID } + + self.currentLeavedCourseReviews?.insert(courseReview, at: 0) + + let newReviewsData = self.makeReviewsDataFromCurrentData() + self.presenter.presentReviews(response: .init(result: .success(newReviewsData))) + } + + func handleCourseReviewUpdated(_ courseReview: CourseReview) { + guard let targetIndex = self.currentLeavedCourseReviews?.firstIndex(where: { $0.id == courseReview.id }) else { + return + } + + self.currentLeavedCourseReviews?[targetIndex] = courseReview + + let newReviewsData = self.makeReviewsDataFromCurrentData() + self.presenter.presentReviews(response: .init(result: .success(newReviewsData))) + } +} + +// MARK: - UserCoursesReviewsUniqueIdentifierMapper - + +enum UserCoursesReviewsUniqueIdentifierMapper { + static let notAIdentifier = -1 + + static func toUniqueIdentifier(courseReviewPlainObject: CourseReviewPlainObject) -> UniqueIdentifierType { + "\(courseReviewPlainObject.id)_\(courseReviewPlainObject.courseID)" + } + + static func toParts(uniqueIdentifier: UniqueIdentifierType) -> (id: Int, courseID: Int) { + let substringToInt = { (substringOrNil: Substring?) -> Int in + if let substring = substringOrNil, + let intValue = Int(substring) { + return intValue + } + return self.notAIdentifier + } + + let splits = uniqueIdentifier.split(separator: "_") + + let courseReviewID = substringToInt(splits.first) + let courseID = substringToInt(splits.last) + + return (courseReviewID, courseID) + } +} diff --git a/Stepic/Sources/Modules/UserCoursesReviews/UserCoursesReviewsPresenter.swift b/Stepic/Sources/Modules/UserCoursesReviews/UserCoursesReviewsPresenter.swift new file mode 100644 index 0000000000..41c0c6148f --- /dev/null +++ b/Stepic/Sources/Modules/UserCoursesReviews/UserCoursesReviewsPresenter.swift @@ -0,0 +1,114 @@ +import UIKit + +protocol UserCoursesReviewsPresenterProtocol { + func presentReviews(response: UserCoursesReviews.ReviewsLoad.Response) + func presentCourseInfo(response: UserCoursesReviews.CourseInfoPresentation.Response) + func presentWritePossibleCourseReview(response: UserCoursesReviews.WritePossibleCourseReviewPresentation.Response) + func presentEditLeavedCourseReview(response: UserCoursesReviews.EditLeavedCourseReviewPresentation.Response) + func presentLeavedCourseReviewActionSheet( + response: UserCoursesReviews.LeavedCourseReviewActionSheetPresentation.Response + ) + + func presentWaitingState(response: UserCoursesReviews.BlockingWaitingIndicatorUpdate.Response) + func presentWaitingStatus(response: UserCoursesReviews.BlockingWaitingIndicatorStatusUpdate.Response) +} + +final class UserCoursesReviewsPresenter: UserCoursesReviewsPresenterProtocol { + weak var viewController: UserCoursesReviewsViewControllerProtocol? + + func presentReviews(response: UserCoursesReviews.ReviewsLoad.Response) { + guard let viewController = self.viewController else { + return + } + + switch response.result { + case .success(let data): + if data.isEmpty { + return viewController.displayReviews(viewModel: .init(state: .empty)) + } + + let result = UserCoursesReviews.ReviewsResult( + possibleReviews: data.possibleReviews.map { courseReview in + self.makeUserCoursesReviewItemViewModel( + plainObject: courseReview, + isPossibleReview: true, + isCourseAdaptive: data.supportedInAdaptiveModeCoursesIDs.contains(courseReview.courseID) + ) + }, + leavedReviews: data.leavedReviews.map { courseReview in + self.makeUserCoursesReviewItemViewModel( + plainObject: courseReview, + isPossibleReview: false, + isCourseAdaptive: data.supportedInAdaptiveModeCoursesIDs.contains(courseReview.courseID) + ) + } + ) + + viewController.displayReviews(viewModel: .init(state: .result(data: result))) + case .failure: + viewController.displayReviews(viewModel: .init(state: .error)) + } + } + + func presentCourseInfo(response: UserCoursesReviews.CourseInfoPresentation.Response) { + self.viewController?.displayCourseInfo(viewModel: .init(courseID: response.courseID)) + } + + func presentWritePossibleCourseReview(response: UserCoursesReviews.WritePossibleCourseReviewPresentation.Response) { + self.viewController?.displayWritePossibleCourseReview( + viewModel: .init(courseReviewPlainObject: response.courseReviewPlainObject) + ) + } + + func presentLeavedCourseReviewActionSheet( + response: UserCoursesReviews.LeavedCourseReviewActionSheetPresentation.Response + ) { + self.viewController?.displayLeavedCourseReviewActionSheet( + viewModel: .init(viewModelUniqueIdentifier: response.viewModelUniqueIdentifier) + ) + } + + func presentEditLeavedCourseReview(response: UserCoursesReviews.EditLeavedCourseReviewPresentation.Response) { + self.viewController?.displayEditLeavedCourseReview(viewModel: .init(courseReview: response.courseReview)) + } + + func presentWaitingState(response: UserCoursesReviews.BlockingWaitingIndicatorUpdate.Response) { + self.viewController?.displayBlockingLoadingIndicator(viewModel: .init(shouldDismiss: response.shouldDismiss)) + } + + func presentWaitingStatus(response: UserCoursesReviews.BlockingWaitingIndicatorStatusUpdate.Response) { + self.viewController?.displayBlockingLoadingIndicatorWithStatus(viewModel: .init(success: response.success)) + } + + // MARK: Private API + + private func makeUserCoursesReviewItemViewModel( + plainObject: CourseReviewPlainObject, + isPossibleReview: Bool, + isCourseAdaptive: Bool + ) -> UserCoursesReviewsItemViewModel { + let uniqueIdentifier = UserCoursesReviewsUniqueIdentifierMapper.toUniqueIdentifier( + courseReviewPlainObject: plainObject + ) + + let text = isPossibleReview ? nil : plainObject.text + let dateRepresentation = isPossibleReview + ? nil + : FormatterHelper.dateToRelativeString(plainObject.creationDate) + + var coverImageURL: URL? + if let coverURLString = plainObject.course?.coverURLString { + coverImageURL = URL(string: coverURLString) + } + + return UserCoursesReviewsItemViewModel( + uniqueIdentifier: uniqueIdentifier, + title: plainObject.course?.title ?? "", + text: text, + dateRepresentation: dateRepresentation, + score: plainObject.score, + coverImageURL: coverImageURL, + shouldShowAdaptiveMark: isCourseAdaptive + ) + } +} diff --git a/Stepic/Sources/Modules/UserCoursesReviews/UserCoursesReviewsProvider.swift b/Stepic/Sources/Modules/UserCoursesReviews/UserCoursesReviewsProvider.swift new file mode 100644 index 0000000000..b392336529 --- /dev/null +++ b/Stepic/Sources/Modules/UserCoursesReviews/UserCoursesReviewsProvider.swift @@ -0,0 +1,131 @@ +import Foundation +import PromiseKit + +protocol UserCoursesReviewsProviderProtocol { + func fetchLeavedCourseReviewsFromCache() -> Promise<[CourseReview]> + func fetchLeavedCourseReviewsFromRemote() -> Promise<[CourseReview]> + + func fetchPossibleCoursesFromCache() -> Promise<[Course]> + + func deleteCourseReview(id: CourseReview.IdType) -> Promise +} + +final class UserCoursesReviewsProvider: UserCoursesReviewsProviderProtocol { + private let userAccountService: UserAccountServiceProtocol + + private let courseReviewsNetworkService: CourseReviewsNetworkServiceProtocol + private let courseReviewsPersistenceService: CourseReviewsPersistenceServiceProtocol + + private let coursesNetworkService: CoursesNetworkServiceProtocol + private let coursesPersistenceService: CoursesPersistenceServiceProtocol + + private var currentUserID: User.IdType? { self.userAccountService.currentUserID } + + init( + userAccountService: UserAccountServiceProtocol, + courseReviewsNetworkService: CourseReviewsNetworkServiceProtocol, + courseReviewsPersistenceService: CourseReviewsPersistenceServiceProtocol, + coursesNetworkService: CoursesNetworkServiceProtocol, + coursesPersistenceService: CoursesPersistenceServiceProtocol + ) { + self.userAccountService = userAccountService + self.courseReviewsNetworkService = courseReviewsNetworkService + self.courseReviewsPersistenceService = courseReviewsPersistenceService + self.coursesNetworkService = coursesNetworkService + self.coursesPersistenceService = coursesPersistenceService + } + + func fetchLeavedCourseReviewsFromCache() -> Promise<[CourseReview]> { + Promise { seal in + guard let currentUserID = self.currentUserID else { + throw Error.persistenceFetchFailed + } + + self.fetchAndMergeCourseReviews( + courseReviewsFetchMethod: { self.courseReviewsPersistenceService.fetch(userID: currentUserID) }, + coursesFetchMethod: self.coursesPersistenceService.fetch(ids:) + ).done { reviews in + seal.fulfill(reviews) + }.catch { _ in + seal.reject(Error.persistenceFetchFailed) + } + } + } + + func fetchLeavedCourseReviewsFromRemote() -> Promise<[CourseReview]> { + Promise { seal in + guard let currentUserID = self.currentUserID else { + throw Error.networkFetchFailed + } + + self.fetchAndMergeCourseReviews( + courseReviewsFetchMethod: { self.courseReviewsNetworkService.fetchAll(userID: currentUserID) }, + coursesFetchMethod: self.coursesNetworkService.fetch(ids:) + ).done { reviews in + seal.fulfill(reviews) + }.catch { _ in + seal.reject(Error.networkFetchFailed) + } + } + } + + func fetchPossibleCoursesFromCache() -> Promise<[Course]> { + Promise { seal in + self.coursesPersistenceService.fetchEnrolled().done { courses in + let filteredCourses = courses.filter(\.canWriteReview) + + var uniqueCourses = [Course]() + for course in filteredCourses { + if !uniqueCourses.contains(where: { $0.id == course.id }) { + uniqueCourses.append(course) + } + } + + let result = uniqueCourses.reordered(order: courses.map(\.id), transform: { $0.id }) + + seal.fulfill(result) + } + } + } + + func deleteCourseReview(id: CourseReview.IdType) -> Promise { + Promise { seal in + self.courseReviewsNetworkService.delete(id: id).then { _ in + self.courseReviewsPersistenceService.fetch(ids: [id]) + }.then { cachedReviews in + when(resolved: cachedReviews.map({ self.courseReviewsPersistenceService.delete(by: $0.id) })) + }.done { _ in + CoreDataHelper.shared.save() + seal.fulfill(()) + }.catch { _ in + seal.reject(Error.deleteFailed) + } + } + } + + // MARK: Private API + + private func fetchAndMergeCourseReviews( + courseReviewsFetchMethod: @escaping () -> Promise<[CourseReview]>, + coursesFetchMethod: @escaping ([Course.IdType]) -> Promise<[Course]> + ) -> Promise<[CourseReview]> { + courseReviewsFetchMethod().then { reviews -> Promise<([Course], [CourseReview])> in + let coursesIDsToFetch = Array(Set(reviews.map(\.courseID))) + return coursesFetchMethod(coursesIDsToFetch).map { ($0, reviews) } + }.then { courses, reviews -> Promise<[CourseReview]> in + for review in reviews { + review.course = courses.first(where: { $0.id == review.courseID }) + } + + CoreDataHelper.shared.save() + + return .value(reviews) + } + } + + enum Error: Swift.Error { + case persistenceFetchFailed + case networkFetchFailed + case deleteFailed + } +} diff --git a/Stepic/Sources/Modules/UserCoursesReviews/UserCoursesReviewsViewController.swift b/Stepic/Sources/Modules/UserCoursesReviews/UserCoursesReviewsViewController.swift new file mode 100644 index 0000000000..11e0f1456b --- /dev/null +++ b/Stepic/Sources/Modules/UserCoursesReviews/UserCoursesReviewsViewController.swift @@ -0,0 +1,264 @@ +import SVProgressHUD +import UIKit + +protocol UserCoursesReviewsViewControllerProtocol: AnyObject { + func displayReviews(viewModel: UserCoursesReviews.ReviewsLoad.ViewModel) + func displayCourseInfo(viewModel: UserCoursesReviews.CourseInfoPresentation.ViewModel) + func displayWritePossibleCourseReview(viewModel: UserCoursesReviews.WritePossibleCourseReviewPresentation.ViewModel) + func displayEditLeavedCourseReview(viewModel: UserCoursesReviews.EditLeavedCourseReviewPresentation.ViewModel) + func displayLeavedCourseReviewActionSheet( + viewModel: UserCoursesReviews.LeavedCourseReviewActionSheetPresentation.ViewModel + ) + + func displayBlockingLoadingIndicator(viewModel: UserCoursesReviews.BlockingWaitingIndicatorUpdate.ViewModel) + func displayBlockingLoadingIndicatorWithStatus( + viewModel: UserCoursesReviews.BlockingWaitingIndicatorStatusUpdate.ViewModel + ) +} + +protocol UserCoursesReviewsViewControllerDelegate: AnyObject { + func cellDidSelect(_ cell: UserCoursesReviewsItemViewModel, anchorView: UIView?) + func coverDidClick(_ cell: UserCoursesReviewsItemViewModel) + func moreButtonDidClick(_ cell: UserCoursesReviewsItemViewModel, anchorView: UIView) + func possibleReviewScoreDidChange(_ score: Int, cell: UserCoursesReviewsItemViewModel) + func sharePossibleReviewButtonDidClick(_ cell: UserCoursesReviewsItemViewModel) +} + +extension UserCoursesReviewsViewController { + enum Animation { + static let startRefreshDelay: TimeInterval = 1.0 + static let possibleReviewScoreUpdateDelay: TimeInterval = 0.33 + } +} + +final class UserCoursesReviewsViewController: UIViewController, ControllerWithStepikPlaceholder { + private let interactor: UserCoursesReviewsInteractorProtocol + private let reviewsTableDataSource: UserCoursesReviewsTableViewDataSource + + var placeholderContainer = StepikPlaceholderControllerContainer() + + var userCoursesReviewsView: UserCoursesReviewsView? { self.view as? UserCoursesReviewsView } + + private var state: UserCoursesReviews.ViewControllerState + + private weak var currentAnchorView: UIView? + + init( + interactor: UserCoursesReviewsInteractorProtocol, + initialState: UserCoursesReviews.ViewControllerState = .loading + ) { + self.interactor = interactor + self.reviewsTableDataSource = UserCoursesReviewsTableViewDataSource() + self.state = initialState + + super.init(nibName: nil, bundle: nil) + + self.reviewsTableDataSource.delegate = self + } + + @available(*, unavailable) + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func loadView() { + let view = UserCoursesReviewsView(frame: UIScreen.main.bounds) + self.view = view + view.delegate = self + } + + override func viewDidLoad() { + super.viewDidLoad() + + self.setup() + self.updateState(newState: self.state) + + self.interactor.doReviewsLoad(request: .init()) + } + + private func setup() { + self.title = NSLocalizedString("UserCoursesReviewsTitle", comment: "") + + self.registerPlaceholder( + placeholder: StepikPlaceholder( + .noConnection, + action: { [weak self] in + guard let strongSelf = self else { + return + } + + strongSelf.updateState(newState: .loading) + strongSelf.interactor.doReviewsLoad(request: .init()) + } + ), + for: .connectionError + ) + self.registerPlaceholder(placeholder: StepikPlaceholder(.emptyReviews, action: nil), for: .empty) + } + + private func updateState(newState: UserCoursesReviews.ViewControllerState) { + switch newState { + case .loading: + self.isPlaceholderShown = false + self.userCoursesReviewsView?.showLoading() + case .error: + self.showPlaceholder(for: .connectionError) + self.userCoursesReviewsView?.hideLoading() + case .empty: + self.showPlaceholder(for: .empty) + self.userCoursesReviewsView?.hideLoading() + case .result(let data): + self.isPlaceholderShown = false + self.userCoursesReviewsView?.hideLoading() + + self.reviewsTableDataSource.update(data: data) + self.userCoursesReviewsView?.updateTableViewData(delegate: self.reviewsTableDataSource) + } + + self.state = newState + } +} + +extension UserCoursesReviewsViewController: UserCoursesReviewsViewControllerProtocol { + func displayReviews(viewModel: UserCoursesReviews.ReviewsLoad.ViewModel) { + self.updateState(newState: viewModel.state) + } + + func displayCourseInfo(viewModel: UserCoursesReviews.CourseInfoPresentation.ViewModel) { + let assembly = CourseInfoAssembly( + courseID: viewModel.courseID, + initialTab: .reviews, + courseViewSource: .userCoursesReviews + ) + self.push(module: assembly.makeModule()) + } + + func displayWritePossibleCourseReview( + viewModel: UserCoursesReviews.WritePossibleCourseReviewPresentation.ViewModel + ) { + let modalPresentationStyle = UIModalPresentationStyle.stepikAutomatic + + let assembly = WriteCourseReviewAssembly( + courseID: viewModel.courseReviewPlainObject.courseID, + presentationContext: .create(viewModel.courseReviewPlainObject), + navigationBarAppearance: modalPresentationStyle.isSheetStyle ? .pageSheetAppearance() : .init(), + output: self.interactor as? WriteCourseReviewOutputProtocol + ) + let controller = StyledNavigationController(rootViewController: assembly.makeModule()) + + self.present(module: controller, modalPresentationStyle: modalPresentationStyle) + } + + func displayEditLeavedCourseReview(viewModel: UserCoursesReviews.EditLeavedCourseReviewPresentation.ViewModel) { + let modalPresentationStyle = UIModalPresentationStyle.stepikAutomatic + + let assembly = WriteCourseReviewAssembly( + courseID: viewModel.courseReview.courseID, + presentationContext: .update(viewModel.courseReview), + navigationBarAppearance: modalPresentationStyle.isSheetStyle ? .pageSheetAppearance() : .init(), + output: self.interactor as? WriteCourseReviewOutputProtocol + ) + let controller = StyledNavigationController(rootViewController: assembly.makeModule()) + + self.present(module: controller, modalPresentationStyle: modalPresentationStyle) + } + + func displayLeavedCourseReviewActionSheet( + viewModel: UserCoursesReviews.LeavedCourseReviewActionSheetPresentation.ViewModel + ) { + let alert = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) + alert.addAction( + UIAlertAction( + title: NSLocalizedString("WriteCourseReviewActionEdit", comment: ""), + style: .default, + handler: { [weak self] _ in + self?.interactor.doEditLeavedCourseReviewPresentation( + request: .init(viewModelUniqueIdentifier: viewModel.viewModelUniqueIdentifier) + ) + } + ) + ) + alert.addAction( + UIAlertAction( + title: NSLocalizedString("WriteCourseReviewActionDelete", comment: ""), + style: .destructive, + handler: { [weak self] _ in + self?.interactor.doDeleteLeavedCourseReview( + request: .init(viewModelUniqueIdentifier: viewModel.viewModelUniqueIdentifier) + ) + } + ) + ) + alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: ""), style: .cancel)) + + if let popoverPresentationController = alert.popoverPresentationController { + let sourceView: UIView = self.currentAnchorView ?? self.view + popoverPresentationController.sourceView = sourceView + popoverPresentationController.sourceRect = sourceView.bounds + } + + self.present(alert, animated: true) + } + + func displayBlockingLoadingIndicator(viewModel: UserCoursesReviews.BlockingWaitingIndicatorUpdate.ViewModel) { + if viewModel.shouldDismiss { + SVProgressHUD.dismiss() + } else { + SVProgressHUD.show() + } + } + + func displayBlockingLoadingIndicatorWithStatus( + viewModel: UserCoursesReviews.BlockingWaitingIndicatorStatusUpdate.ViewModel + ) { + if viewModel.success { + SVProgressHUD.showSuccess(withStatus: nil) + } else { + SVProgressHUD.showError(withStatus: nil) + } + } +} + +extension UserCoursesReviewsViewController: UserCoursesReviewsViewDelegate { + func userCoursesReviewsViewRefreshControlDidRefresh(_ view: UserCoursesReviewsView) { + DispatchQueue.main.asyncAfter(deadline: .now() + Animation.startRefreshDelay) { [weak self] in + self?.interactor.doReviewsLoad(request: .init()) + } + } +} + +extension UserCoursesReviewsViewController: UserCoursesReviewsViewControllerDelegate { + func cellDidSelect(_ cell: UserCoursesReviewsItemViewModel, anchorView: UIView?) { + self.currentAnchorView = anchorView + self.interactor.doMainReviewAction(request: .init(viewModelUniqueIdentifier: cell.uniqueIdentifier)) + } + + func coverDidClick(_ cell: UserCoursesReviewsItemViewModel) { + self.interactor.doCourseInfoPresentation(request: .init(viewModelUniqueIdentifier: cell.uniqueIdentifier)) + } + + func moreButtonDidClick(_ cell: UserCoursesReviewsItemViewModel, anchorView: UIView) { + self.currentAnchorView = anchorView + self.interactor.doLeavedCourseReviewActionSheetPresentation( + request: .init(viewModelUniqueIdentifier: cell.uniqueIdentifier) + ) + } + + func possibleReviewScoreDidChange(_ score: Int, cell: UserCoursesReviewsItemViewModel) { + self.interactor.doPossibleCourseReviewScoreUpdate( + request: .init(viewModelUniqueIdentifier: cell.uniqueIdentifier, score: score) + ) + + DispatchQueue.main.asyncAfter(deadline: .now() + Animation.possibleReviewScoreUpdateDelay) { [weak self] in + self?.interactor.doWritePossibleCourseReviewPresentation( + request: .init(viewModelUniqueIdentifier: cell.uniqueIdentifier) + ) + } + } + + func sharePossibleReviewButtonDidClick(_ cell: UserCoursesReviewsItemViewModel) { + self.interactor.doWritePossibleCourseReviewPresentation( + request: .init(viewModelUniqueIdentifier: cell.uniqueIdentifier) + ) + } +} diff --git a/Stepic/Sources/Modules/UserCoursesReviews/UserCoursesReviewsViewModel.swift b/Stepic/Sources/Modules/UserCoursesReviews/UserCoursesReviewsViewModel.swift new file mode 100644 index 0000000000..5130292d29 --- /dev/null +++ b/Stepic/Sources/Modules/UserCoursesReviews/UserCoursesReviewsViewModel.swift @@ -0,0 +1,12 @@ +import Foundation + +struct UserCoursesReviewsItemViewModel: UniqueIdentifiable { + let uniqueIdentifier: UniqueIdentifierType + + let title: String + let text: String? + let dateRepresentation: String? + let score: Int + let coverImageURL: URL? + let shouldShowAdaptiveMark: Bool +} diff --git a/Stepic/Sources/Modules/UserCoursesReviews/View/Cell/Leaved/UserCoursesReviewsLeavedReviewCellView.swift b/Stepic/Sources/Modules/UserCoursesReviews/View/Cell/Leaved/UserCoursesReviewsLeavedReviewCellView.swift new file mode 100644 index 0000000000..40fc1cc17f --- /dev/null +++ b/Stepic/Sources/Modules/UserCoursesReviews/View/Cell/Leaved/UserCoursesReviewsLeavedReviewCellView.swift @@ -0,0 +1,184 @@ +import SnapKit +import UIKit + +extension UserCoursesReviewsLeavedReviewCellView { + struct Appearance { + let coverViewSize = CGSize(width: 36, height: 36) + let coverCornerRadius: CGFloat = 6 + let coverInsets = LayoutInsets.default + + let titleFont = UIFont.systemFont(ofSize: 15, weight: .semibold) + let titleTextColor = UIColor.stepikMaterialPrimaryText + let titleInsets = LayoutInsets.default + + let moreButtonSize = CGSize(width: 26, height: 26) + let moreButtonTintColor = UIColor.stepikMaterialSecondaryText + let moreButtonInsets = LayoutInsets.default + + let textLabelFont = UIFont.systemFont(ofSize: 15, weight: .regular) + let textLabelTextColor = UIColor.stepikMaterialSecondaryText + let textLabelInsets = LayoutInsets(top: 8) + + let dateLabelFont = Typography.caption1Font + let dateLabelTextColor = UIColor.stepikMaterialSecondaryText + let dateLabelInsets = LayoutInsets(top: 8) + + let scoreClearStarsColor = UIColor.onSurface.withAlphaComponent(0.12) + let scoreInsets = LayoutInsets(top: 8, bottom: 16) + } +} + +final class UserCoursesReviewsLeavedReviewCellView: UIView { + let appearance: Appearance + + private lazy var coverView = CourseWidgetCoverView( + appearance: .init(cornerRadius: self.appearance.coverCornerRadius) + ) + + private lazy var coverOverlayButton: UIButton = { + let button = HighlightFakeButton() + button.addTarget(self, action: #selector(self.coverButtonClicked), for: .touchUpInside) + return button + }() + + private lazy var titleLabel: UILabel = { + let label = UILabel() + label.textColor = self.appearance.titleTextColor + label.font = self.appearance.titleFont + label.numberOfLines = 3 + return label + }() + + private lazy var moreButton: UIButton = { + let image = UIImage(named: "horizontal-dots-icon")?.withRenderingMode(.alwaysTemplate) + let button = UIButton(type: .system) + button.setImage(image, for: .normal) + button.imageView?.contentMode = .scaleAspectFit + button.tintColor = self.appearance.moreButtonTintColor + button.addTarget(self, action: #selector(self.moreButtonClicked), for: .touchUpInside) + return button + }() + + private lazy var textLabel: UILabel = { + let label = UILabel() + label.textColor = self.appearance.textLabelTextColor + label.font = self.appearance.textLabelFont + label.numberOfLines = 0 + return label + }() + + private lazy var dateLabel: UILabel = { + let label = UILabel() + label.font = self.appearance.dateLabelFont + label.textColor = self.appearance.dateLabelTextColor + label.numberOfLines = 1 + return label + }() + + private lazy var scoreView: CourseRatingView = { + var appearance = CourseRatingView.Appearance() + appearance.starClearColor = self.appearance.scoreClearStarsColor + let view = CourseRatingView(appearance: appearance) + return view + }() + + var moreActionAnchorView: UIView { self.moreButton } + + var onCoverClick: (() -> Void)? + var onMoreClick: (() -> Void)? + + init( + frame: CGRect = .zero, + appearance: Appearance = Appearance() + ) { + self.appearance = appearance + super.init(frame: frame) + + self.addSubviews() + self.makeConstraints() + } + + @available(*, unavailable) + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(viewModel: UserCoursesReviewsItemViewModel?) { + self.coverView.coverImageURL = viewModel?.coverImageURL + self.titleLabel.text = viewModel?.title + self.textLabel.text = viewModel?.text + self.dateLabel.text = viewModel?.dateRepresentation + self.scoreView.starsCount = viewModel?.score ?? 0 + self.coverView.shouldShowAdaptiveMark = viewModel?.shouldShowAdaptiveMark ?? false + } + + @objc + private func coverButtonClicked() { + self.onCoverClick?() + } + + @objc + private func moreButtonClicked() { + self.onMoreClick?() + } +} + +extension UserCoursesReviewsLeavedReviewCellView: ProgrammaticallyInitializableViewProtocol { + func addSubviews() { + self.addSubview(self.coverView) + self.addSubview(self.coverOverlayButton) + self.addSubview(self.titleLabel) + self.addSubview(self.moreButton) + self.addSubview(self.textLabel) + self.addSubview(self.dateLabel) + self.addSubview(self.scoreView) + } + + func makeConstraints() { + self.coverView.translatesAutoresizingMaskIntoConstraints = false + self.coverView.snp.makeConstraints { make in + make.top.equalToSuperview().offset(self.appearance.coverInsets.top) + make.leading.equalToSuperview().offset(self.appearance.coverInsets.left) + make.size.equalTo(self.appearance.coverViewSize) + } + + self.coverOverlayButton.translatesAutoresizingMaskIntoConstraints = false + self.coverOverlayButton.snp.makeConstraints { $0.edges.equalTo(self.coverView) } + + self.titleLabel.translatesAutoresizingMaskIntoConstraints = false + self.titleLabel.snp.makeConstraints { make in + make.top.equalTo(self.coverView.snp.top) + make.leading.equalTo(self.coverView.snp.trailing).offset(self.appearance.titleInsets.left) + make.trailing.equalTo(self.moreButton.snp.leading).offset(-self.appearance.titleInsets.right) + } + + self.moreButton.translatesAutoresizingMaskIntoConstraints = false + self.moreButton.snp.makeConstraints { make in + make.centerY.equalTo(self.titleLabel.snp.centerY) + make.trailing.equalToSuperview().offset(-self.appearance.moreButtonInsets.right) + make.size.equalTo(self.appearance.moreButtonSize) + } + + self.textLabel.translatesAutoresizingMaskIntoConstraints = false + self.textLabel.snp.makeConstraints { make in + make.top.equalTo(self.titleLabel.snp.bottom).offset(self.appearance.textLabelInsets.top) + make.leading.equalTo(self.titleLabel.snp.leading) + make.trailing.equalTo(self.titleLabel.snp.trailing) + } + + self.dateLabel.translatesAutoresizingMaskIntoConstraints = false + self.dateLabel.snp.makeConstraints { make in + make.top.equalTo(self.textLabel.snp.bottom).offset(self.appearance.dateLabelInsets.top) + make.leading.equalTo(self.textLabel.snp.leading) + make.trailing.equalTo(self.textLabel.snp.trailing) + } + + self.scoreView.translatesAutoresizingMaskIntoConstraints = false + self.scoreView.snp.makeConstraints { make in + make.top.equalTo(self.dateLabel.snp.bottom).offset(self.appearance.scoreInsets.top) + make.leading.equalTo(self.dateLabel.snp.leading) + make.bottom.equalToSuperview().offset(-self.appearance.scoreInsets.bottom) + make.trailing.lessThanOrEqualTo(self.dateLabel.snp.trailing) + } + } +} diff --git a/Stepic/Sources/Modules/UserCoursesReviews/View/Cell/Leaved/UserCoursesReviewsLeavedReviewTableViewCell.swift b/Stepic/Sources/Modules/UserCoursesReviews/View/Cell/Leaved/UserCoursesReviewsLeavedReviewTableViewCell.swift new file mode 100644 index 0000000000..8bc158b1e4 --- /dev/null +++ b/Stepic/Sources/Modules/UserCoursesReviews/View/Cell/Leaved/UserCoursesReviewsLeavedReviewTableViewCell.swift @@ -0,0 +1,77 @@ +import SnapKit +import UIKit + +extension UserCoursesReviewsLeavedReviewTableViewCell { + enum Appearance { + static let separatorHeight: CGFloat = 4 + static let separatorBackgroundColor = UIColor.dynamic( + light: .onSurface.withAlphaComponent(0.04), + dark: .stepikSeparator + ) + } +} + +final class UserCoursesReviewsLeavedReviewTableViewCell: UITableViewCell, Reusable { + private lazy var cellView = UserCoursesReviewsLeavedReviewCellView() + + private lazy var separatorView: UIView = { + let view = UIView() + view.backgroundColor = Appearance.separatorBackgroundColor + return view + }() + + var onCoverClick: (() -> Void)? { + get { + self.cellView.onCoverClick + } + set { + self.cellView.onCoverClick = newValue + } + } + + var onMoreClick: (() -> Void)? { + get { + self.cellView.onMoreClick + } + set { + self.cellView.onMoreClick = newValue + } + } + + var moreActionAnchorView: UIView { self.cellView.moreActionAnchorView } + + override func updateConstraintsIfNeeded() { + super.updateConstraintsIfNeeded() + + if self.cellView.superview == nil { + self.setupSubview() + } + } + + override func prepareForReuse() { + super.prepareForReuse() + self.cellView.configure(viewModel: nil) + } + + func configure(viewModel: UserCoursesReviewsItemViewModel) { + self.cellView.configure(viewModel: viewModel) + } + + private func setupSubview() { + self.contentView.addSubview(self.cellView) + self.contentView.addSubview(self.separatorView) + + self.cellView.translatesAutoresizingMaskIntoConstraints = false + self.cellView.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + } + + self.separatorView.translatesAutoresizingMaskIntoConstraints = false + self.separatorView.snp.makeConstraints { make in + make.top.equalTo(self.cellView.snp.bottom) + make.leading.trailing.equalToSuperview() + make.bottom.equalToSuperview().priority(999) + make.height.equalTo(Appearance.separatorHeight) + } + } +} diff --git a/Stepic/Sources/Modules/UserCoursesReviews/View/Cell/Possible/UserCoursesReviewsPossibleReviewCellView.swift b/Stepic/Sources/Modules/UserCoursesReviews/View/Cell/Possible/UserCoursesReviewsPossibleReviewCellView.swift new file mode 100644 index 0000000000..2a6e050125 --- /dev/null +++ b/Stepic/Sources/Modules/UserCoursesReviews/View/Cell/Possible/UserCoursesReviewsPossibleReviewCellView.swift @@ -0,0 +1,152 @@ +import SnapKit +import UIKit + +extension UserCoursesReviewsPossibleReviewCellView { + struct Appearance { + let coverViewSize = CGSize(width: 36, height: 36) + let coverCornerRadius: CGFloat = 6 + let coverInsets = LayoutInsets.default + + let titleFont = UIFont.systemFont(ofSize: 15, weight: .semibold) + let titleTextColor = UIColor.stepikMaterialPrimaryText + let titleInsets = LayoutInsets.default + + let scoreInsets = LayoutInsets.default + let scoreClearColor = UIColor.stepikGreenFixed + let scoreSpacing: CGFloat = 8 + let scoreSize = CGSize(width: 24, height: 24) + + let actionButtonFont = Typography.bodyFont + let actionButtonTintColor = UIColor.stepikGreenFixed + let actionButtonInsets = LayoutInsets(top: 10, bottom: 10, right: 16) + } +} + +final class UserCoursesReviewsPossibleReviewCellView: UIView { + let appearance: Appearance + + private lazy var coverView = CourseWidgetCoverView( + appearance: .init(cornerRadius: self.appearance.coverCornerRadius) + ) + + private lazy var coverOverlayButton: UIButton = { + let button = HighlightFakeButton() + button.addTarget(self, action: #selector(self.coverButtonClicked), for: .touchUpInside) + return button + }() + + private lazy var titleLabel: UILabel = { + let label = UILabel() + label.textColor = self.appearance.titleTextColor + label.font = self.appearance.titleFont + label.numberOfLines = 3 + return label + }() + + private lazy var scoreView: CourseRatingView = { + var appearance = CourseRatingView.Appearance() + appearance.starClearColor = self.appearance.scoreClearColor + appearance.starsSpacing = self.appearance.scoreSpacing + appearance.starsSize = self.appearance.scoreSize + let view = CourseRatingView(appearance: appearance) + view.delegate = self + return view + }() + + private lazy var actionButton: UIButton = { + let button = UIButton(type: .system) + button.titleLabel?.font = self.appearance.actionButtonFont + button.setTitleColor(self.appearance.actionButtonTintColor, for: .normal) + button.setTitle(NSLocalizedString("UserCoursesReviewsLeaveReview", comment: ""), for: .normal) + button.addTarget(self, action: #selector(self.actionButtonClicked), for: .touchUpInside) + return button + }() + + var onCoverClick: (() -> Void)? + var onActionButtonClick: (() -> Void)? + var onScoreDidChange: ((Int) -> Void)? + + init( + frame: CGRect = .zero, + appearance: Appearance = Appearance() + ) { + self.appearance = appearance + super.init(frame: frame) + + self.addSubviews() + self.makeConstraints() + } + + @available(*, unavailable) + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configure(viewModel: UserCoursesReviewsItemViewModel?) { + self.coverView.coverImageURL = viewModel?.coverImageURL + self.coverView.shouldShowAdaptiveMark = viewModel?.shouldShowAdaptiveMark ?? false + self.titleLabel.text = viewModel?.title + self.scoreView.starsCount = viewModel?.score ?? 0 + } + + @objc + private func coverButtonClicked() { + self.onCoverClick?() + } + + @objc + private func actionButtonClicked() { + self.onActionButtonClick?() + } +} + +extension UserCoursesReviewsPossibleReviewCellView: ProgrammaticallyInitializableViewProtocol { + func addSubviews() { + self.addSubview(self.coverView) + self.addSubview(self.coverOverlayButton) + self.addSubview(self.titleLabel) + self.addSubview(self.scoreView) + self.addSubview(self.actionButton) + } + + func makeConstraints() { + self.coverView.translatesAutoresizingMaskIntoConstraints = false + self.coverView.snp.makeConstraints { make in + make.top.equalToSuperview().offset(self.appearance.coverInsets.top) + make.leading.equalToSuperview().offset(self.appearance.coverInsets.left) + make.size.equalTo(self.appearance.coverViewSize) + } + + self.coverOverlayButton.translatesAutoresizingMaskIntoConstraints = false + self.coverOverlayButton.snp.makeConstraints { $0.edges.equalTo(self.coverView) } + + self.titleLabel.translatesAutoresizingMaskIntoConstraints = false + self.titleLabel.snp.makeConstraints { make in + make.top.equalTo(self.coverView.snp.top) + make.leading.equalTo(self.coverView.snp.trailing).offset(self.appearance.titleInsets.left) + make.trailing.equalToSuperview().offset(-self.appearance.titleInsets.right) + } + + self.scoreView.translatesAutoresizingMaskIntoConstraints = false + self.scoreView.snp.makeConstraints { make in + make.top.equalTo(self.titleLabel.snp.bottom).offset(self.appearance.scoreInsets.top) + make.leading.equalTo(self.titleLabel.snp.leading) + make.trailing.lessThanOrEqualTo(self.titleLabel.snp.trailing) + } + + self.actionButton.translatesAutoresizingMaskIntoConstraints = false + self.actionButton.snp.makeConstraints { make in + make.top.equalTo(self.scoreView.snp.bottom).offset(self.appearance.actionButtonInsets.top) + make.leading.equalTo(self.titleLabel.snp.leading) + make.bottom.equalToSuperview().offset(-self.appearance.actionButtonInsets.bottom) + make.trailing.lessThanOrEqualToSuperview().offset(-self.appearance.actionButtonInsets.right) + } + } +} + +extension UserCoursesReviewsPossibleReviewCellView: CourseRatingViewDelegate { + func courseRatingView(_ view: CourseRatingView, didSelectStarAtIndex index: Int) { + self.scoreView.starsCount = index + 1 + self.onScoreDidChange?(self.scoreView.starsCount) + } +} diff --git a/Stepic/Sources/Modules/UserCoursesReviews/View/Cell/Possible/UserCoursesReviewsPossibleReviewTableViewCell.swift b/Stepic/Sources/Modules/UserCoursesReviews/View/Cell/Possible/UserCoursesReviewsPossibleReviewTableViewCell.swift new file mode 100644 index 0000000000..bd7b35ce7c --- /dev/null +++ b/Stepic/Sources/Modules/UserCoursesReviews/View/Cell/Possible/UserCoursesReviewsPossibleReviewTableViewCell.swift @@ -0,0 +1,96 @@ +import SnapKit +import UIKit + +extension UserCoursesReviewsPossibleReviewTableViewCell { + enum Appearance { + static let separatorHeight: CGFloat = 4 + static let separatorBackgroundColor = UIColor.dynamic( + light: .onSurface.withAlphaComponent(0.04), + dark: .stepikSeparator + ) + } +} + +final class UserCoursesReviewsPossibleReviewTableViewCell: UITableViewCell, Reusable { + private lazy var cellView = UserCoursesReviewsPossibleReviewCellView() + + private lazy var separatorView: UIView = { + let view = UIView() + view.backgroundColor = Appearance.separatorBackgroundColor + return view + }() + + private var separatorHeightConstraint: Constraint? + + var shouldShowSeparator = true { + didSet { + if self.shouldShowSeparator != oldValue { + self.separatorHeightConstraint?.update( + offset: self.shouldShowSeparator ? Appearance.separatorHeight : 0 + ) + } + } + } + + var onCoverClick: (() -> Void)? { + get { + self.cellView.onCoverClick + } + set { + self.cellView.onCoverClick = newValue + } + } + + var onScoreDidChange: ((Int) -> Void)? { + get { + self.cellView.onScoreDidChange + } + set { + self.cellView.onScoreDidChange = newValue + } + } + + var onActionButtonClick: (() -> Void)? { + get { + self.cellView.onActionButtonClick + } + set { + self.cellView.onActionButtonClick = newValue + } + } + + override func updateConstraintsIfNeeded() { + super.updateConstraintsIfNeeded() + + if self.cellView.superview == nil { + self.setupSubview() + } + } + + override func prepareForReuse() { + super.prepareForReuse() + self.cellView.configure(viewModel: nil) + } + + func configure(viewModel: UserCoursesReviewsItemViewModel) { + self.cellView.configure(viewModel: viewModel) + } + + private func setupSubview() { + self.contentView.addSubview(self.cellView) + self.contentView.addSubview(self.separatorView) + + self.cellView.translatesAutoresizingMaskIntoConstraints = false + self.cellView.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + } + + self.separatorView.translatesAutoresizingMaskIntoConstraints = false + self.separatorView.snp.makeConstraints { make in + make.top.equalTo(self.cellView.snp.bottom) + make.leading.trailing.equalToSuperview() + make.bottom.equalToSuperview().priority(999) + self.separatorHeightConstraint = make.height.equalTo(Appearance.separatorHeight).constraint + } + } +} diff --git a/Stepic/Sources/Modules/UserCoursesReviews/View/Section/UserCoursesReviewsTableSectionView.swift b/Stepic/Sources/Modules/UserCoursesReviews/View/Section/UserCoursesReviewsTableSectionView.swift new file mode 100644 index 0000000000..1a82d82201 --- /dev/null +++ b/Stepic/Sources/Modules/UserCoursesReviews/View/Section/UserCoursesReviewsTableSectionView.swift @@ -0,0 +1,95 @@ +import SnapKit +import UIKit + +extension UserCoursesReviewsTableSectionView { + struct Appearance { + let titleFont = Typography.caption1Font + let titleInsets = UIEdgeInsets(top: 10, left: 16, bottom: 10, right: 16) + } +} + +final class UserCoursesReviewsTableSectionView: UIView { + let appearance: Appearance + + private lazy var titleLabel: UILabel = { + let label = UILabel() + label.font = self.appearance.titleFont + label.textAlignment = .left + label.numberOfLines = 1 + return label + }() + + var style: Style = .normal { + didSet { + self.titleLabel.textColor = self.style.textColor + self.backgroundColor = self.style.backgroundColor + } + } + + var title: String? { + didSet { + self.titleLabel.text = self.title + } + } + + init( + frame: CGRect = .zero, + appearance: Appearance = Appearance() + ) { + self.appearance = appearance + super.init(frame: frame) + + self.setupView() + self.addSubviews() + self.makeConstraints() + } + + @available(*, unavailable) + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + enum Style { + case normal + case accent + + fileprivate var textColor: UIColor { + switch self { + case .normal: + return .stepikMaterialSecondaryText + case .accent: + return .stepikVioletFixed + } + } + + fileprivate var backgroundColor: UIColor { + switch self { + case .normal: + return UIColor.dynamic( + light: UIColor(red: 245 / 255, green: 245 / 255, blue: 245 / 255, alpha: 1), + dark: .stepikSecondaryBackground + ) + case .accent: + return UIColor.dynamic( + light: UIColor(red: 237 / 255, green: 239 / 255, blue: 250 / 255, alpha: 1), + dark: .stepikSecondaryBackground + ) + } + } + } +} + +extension UserCoursesReviewsTableSectionView: ProgrammaticallyInitializableViewProtocol { + func setupView() { + self.style = .normal + } + + func addSubviews() { + self.addSubview(self.titleLabel) + } + + func makeConstraints() { + self.titleLabel.translatesAutoresizingMaskIntoConstraints = false + self.titleLabel.snp.makeConstraints { $0.edges.equalToSuperview().inset(self.appearance.titleInsets) } + } +} diff --git a/Stepic/Sources/Modules/UserCoursesReviews/View/UserCoursesReviewsTableViewDataSource.swift b/Stepic/Sources/Modules/UserCoursesReviews/View/UserCoursesReviewsTableViewDataSource.swift new file mode 100644 index 0000000000..f7bde10f8f --- /dev/null +++ b/Stepic/Sources/Modules/UserCoursesReviews/View/UserCoursesReviewsTableViewDataSource.swift @@ -0,0 +1,185 @@ +import UIKit + +final class UserCoursesReviewsTableViewDataSource: NSObject, UITableViewDelegate, UITableViewDataSource { + weak var delegate: UserCoursesReviewsViewControllerDelegate? + + private var possibleCourseReviewViewModels: [UserCoursesReviewsItemViewModel] + private var leavedCourseReviewViewModels: [UserCoursesReviewsItemViewModel] + + private var sectionsCount: Int { + var count = 0 + count += self.possibleCourseReviewViewModels.isEmpty ? 0 : 1 + count += self.leavedCourseReviewViewModels.isEmpty ? 0 : 1 + return count + } + + init( + possibleCourseReviewViewModels: [UserCoursesReviewsItemViewModel] = [], + leavedCourseReviewViewModels: [UserCoursesReviewsItemViewModel] = [] + ) { + self.possibleCourseReviewViewModels = possibleCourseReviewViewModels + self.leavedCourseReviewViewModels = leavedCourseReviewViewModels + + super.init() + } + + // MARK: Public API + + func update(data: UserCoursesReviews.ReviewsResult) { + self.possibleCourseReviewViewModels = data.possibleReviews + self.leavedCourseReviewViewModels = data.leavedReviews + } + + // MARK: Delegate & data source + + func numberOfSections(in tableView: UITableView) -> Int { self.sectionsCount } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + guard let sectionType = self.sectionType(at: section) else { + return 0 + } + + switch sectionType { + case .possible: + return self.possibleCourseReviewViewModels.count + case .leaved: + return self.leavedCourseReviewViewModels.count + } + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + guard let sectionType = self.sectionType(at: indexPath) else { + return UITableViewCell() + } + + switch sectionType { + case .possible: + let cell: UserCoursesReviewsPossibleReviewTableViewCell = tableView.dequeueReusableCell(for: indexPath) + cell.updateConstraintsIfNeeded() + + if let viewModel = self.possibleCourseReviewViewModels[safe: indexPath.row] { + cell.configure(viewModel: viewModel) + cell.onCoverClick = { [weak self] in + self?.delegate?.coverDidClick(viewModel) + } + cell.onScoreDidChange = { [weak self] score in + self?.handlePossibleReviewScoreChanged(score, at: indexPath) + } + cell.onActionButtonClick = { [weak self] in + self?.delegate?.sharePossibleReviewButtonDidClick(viewModel) + } + + let shouldHideSeparator = self.sectionsCount > 1 + && indexPath.row == self.possibleCourseReviewViewModels.count - 1 + cell.shouldShowSeparator = !shouldHideSeparator + } + + cell.layoutIfNeeded() + + return cell + case .leaved: + let cell: UserCoursesReviewsLeavedReviewTableViewCell = tableView.dequeueReusableCell(for: indexPath) + cell.updateConstraintsIfNeeded() + + if let viewModel = self.leavedCourseReviewViewModels[safe: indexPath.row] { + cell.configure(viewModel: viewModel) + cell.onCoverClick = { [weak self] in + self?.delegate?.coverDidClick(viewModel) + } + cell.onMoreClick = { [weak self, weak cell] in + guard let strongSelf = self, + let strongCell = cell else { + return + } + + strongSelf.delegate?.moreButtonDidClick(viewModel, anchorView: strongCell.moreActionAnchorView) + } + } + + cell.layoutIfNeeded() + + return cell + } + } + + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + guard let sectionType = self.sectionType(at: section) else { + return nil + } + + let sectionView = UserCoursesReviewsTableSectionView() + + switch sectionType { + case .possible: + sectionView.style = .accent + sectionView.title = FormatterHelper.userCoursesReviewsPossibleReviewsCount( + self.possibleCourseReviewViewModels.count + ) + case .leaved: + sectionView.style = .normal + sectionView.title = FormatterHelper.reviewsCount(self.leavedCourseReviewViewModels.count) + } + + return sectionView + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + + guard let sectionType = self.sectionType(at: indexPath) else { + return + } + + switch sectionType { + case .possible: + if let viewModel = self.possibleCourseReviewViewModels[safe: indexPath.row] { + self.delegate?.cellDidSelect(viewModel, anchorView: nil) + } + case .leaved: + if let viewModel = self.leavedCourseReviewViewModels[safe: indexPath.row] { + self.delegate?.cellDidSelect(viewModel, anchorView: tableView.cellForRow(at: indexPath)) + } + } + } + + // MARK: Private API + + private func sectionType(at indexPath: IndexPath) -> SectionType? { + self.sectionType(at: indexPath.section) + } + + private func sectionType(at section: Int) -> SectionType? { + switch self.sectionsCount { + case 1: + return self.possibleCourseReviewViewModels.isEmpty ? .leaved : .possible + case 2: + return section == 0 ? .possible : .leaved + default: + return nil + } + } + + private func handlePossibleReviewScoreChanged(_ score: Int, at indexPath: IndexPath) { + guard let oldViewModel = self.possibleCourseReviewViewModels[safe: indexPath.row] else { + return + } + + let newViewModel = UserCoursesReviewsItemViewModel( + uniqueIdentifier: oldViewModel.uniqueIdentifier, + title: oldViewModel.title, + text: oldViewModel.text, + dateRepresentation: oldViewModel.dateRepresentation, + score: score, + coverImageURL: oldViewModel.coverImageURL, + shouldShowAdaptiveMark: oldViewModel.shouldShowAdaptiveMark + ) + self.possibleCourseReviewViewModels[indexPath.row] = newViewModel + + self.delegate?.possibleReviewScoreDidChange(score, cell: newViewModel) + } + + private enum SectionType { + case possible + case leaved + } +} diff --git a/Stepic/Sources/Modules/UserCoursesReviews/View/UserCoursesReviewsView.swift b/Stepic/Sources/Modules/UserCoursesReviews/View/UserCoursesReviewsView.swift new file mode 100644 index 0000000000..833dc0e014 --- /dev/null +++ b/Stepic/Sources/Modules/UserCoursesReviews/View/UserCoursesReviewsView.swift @@ -0,0 +1,89 @@ +import SnapKit +import UIKit + +protocol UserCoursesReviewsViewDelegate: AnyObject { + func userCoursesReviewsViewRefreshControlDidRefresh(_ view: UserCoursesReviewsView) +} + +extension UserCoursesReviewsView { + struct Appearance { + let estimatedRowHeight: CGFloat = 158 + } +} + +final class UserCoursesReviewsView: UIView { + let appearance: Appearance + + weak var delegate: UserCoursesReviewsViewDelegate? + + private lazy var refreshControl = UIRefreshControl() + + private lazy var tableView: UITableView = { + let tableView = UITableView() + tableView.rowHeight = UITableView.automaticDimension + tableView.estimatedRowHeight = self.appearance.estimatedRowHeight + tableView.separatorStyle = .none + tableView.keyboardDismissMode = .interactive + + tableView.refreshControl = self.refreshControl + self.refreshControl.addTarget(self, action: #selector(self.refreshControlDidChangeValue), for: .valueChanged) + + tableView.register(cellClass: UserCoursesReviewsPossibleReviewTableViewCell.self) + tableView.register(cellClass: UserCoursesReviewsLeavedReviewTableViewCell.self) + + return tableView + }() + + init( + frame: CGRect = .zero, + appearance: Appearance = Appearance() + ) { + self.appearance = appearance + super.init(frame: frame) + + self.addSubviews() + self.makeConstraints() + } + + @available(*, unavailable) + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: Public API + + func updateTableViewData(delegate: UITableViewDelegate & UITableViewDataSource) { + self.refreshControl.endRefreshing() + + self.tableView.delegate = delegate + self.tableView.dataSource = delegate + self.tableView.reloadData() + } + + func showLoading() { + self.tableView.skeleton.viewBuilder = { SubmissionsSkeletonView() } + self.tableView.skeleton.show() + } + + func hideLoading() { + self.tableView.skeleton.hide() + } + + // MARK: Private API + + @objc + private func refreshControlDidChangeValue() { + self.delegate?.userCoursesReviewsViewRefreshControlDidRefresh(self) + } +} + +extension UserCoursesReviewsView: ProgrammaticallyInitializableViewProtocol { + func addSubviews() { + self.addSubview(self.tableView) + } + + func makeConstraints() { + self.tableView.translatesAutoresizingMaskIntoConstraints = false + self.tableView.snp.makeConstraints { $0.edges.equalToSuperview() } + } +} diff --git a/Stepic/Sources/Modules/WriteCourseReview/WriteCourseReviewAssembly.swift b/Stepic/Sources/Modules/WriteCourseReview/WriteCourseReviewAssembly.swift index 80c1b46b04..575ef76234 100644 --- a/Stepic/Sources/Modules/WriteCourseReview/WriteCourseReviewAssembly.swift +++ b/Stepic/Sources/Modules/WriteCourseReview/WriteCourseReviewAssembly.swift @@ -2,19 +2,19 @@ import UIKit final class WriteCourseReviewAssembly: Assembly { private let courseID: Course.IdType - private var courseReview: CourseReview? + private let presentationContext: WriteCourseReview.PresentationContext private let navigationBarAppearance: StyledNavigationController.NavigationBarAppearanceState private weak var moduleOutput: WriteCourseReviewOutputProtocol? init( courseID: Course.IdType, - courseReview: CourseReview? = nil, + presentationContext: WriteCourseReview.PresentationContext, navigationBarAppearance: StyledNavigationController.NavigationBarAppearanceState = .init(), output: WriteCourseReviewOutputProtocol? = nil ) { self.courseID = courseID - self.courseReview = courseReview + self.presentationContext = presentationContext self.navigationBarAppearance = navigationBarAppearance self.moduleOutput = output } @@ -30,7 +30,7 @@ final class WriteCourseReviewAssembly: Assembly { let presenter = WriteCourseReviewPresenter() let interactor = WriteCourseReviewInteractor( courseID: self.courseID, - courseReview: self.courseReview, + presentationContext: self.presentationContext, presenter: presenter, provider: provider ) diff --git a/Stepic/Sources/Modules/WriteCourseReview/WriteCourseReviewDataFlow.swift b/Stepic/Sources/Modules/WriteCourseReview/WriteCourseReviewDataFlow.swift index 533c07d3d4..61d7169d2f 100644 --- a/Stepic/Sources/Modules/WriteCourseReview/WriteCourseReviewDataFlow.swift +++ b/Stepic/Sources/Modules/WriteCourseReview/WriteCourseReviewDataFlow.swift @@ -1,7 +1,14 @@ import Foundation enum WriteCourseReview { - // MARK: Common structs + // MARK: Common data types + + enum PresentationContext { + case create(CourseReviewPlainObject?) + case update(CourseReview) + + static var create: PresentationContext { .create(nil) } + } struct CourseReviewInfo { let text: String diff --git a/Stepic/Sources/Modules/WriteCourseReview/WriteCourseReviewInteractor.swift b/Stepic/Sources/Modules/WriteCourseReview/WriteCourseReviewInteractor.swift index cba6fc585a..6d05cee001 100644 --- a/Stepic/Sources/Modules/WriteCourseReview/WriteCourseReviewInteractor.swift +++ b/Stepic/Sources/Modules/WriteCourseReview/WriteCourseReviewInteractor.swift @@ -15,25 +15,32 @@ final class WriteCourseReviewInteractor: WriteCourseReviewInteractorProtocol { private let provider: WriteCourseReviewProviderProtocol private let courseID: Course.IdType - private let context: Context + private let presentationContext: WriteCourseReview.PresentationContext - private var courseReview: CourseReview? + private var currentCourseReview: CourseReview? private var currentText: String private var currentScore: Int init( courseID: Course.IdType, - courseReview: CourseReview?, + presentationContext: WriteCourseReview.PresentationContext, presenter: WriteCourseReviewPresenterProtocol, provider: WriteCourseReviewProviderProtocol ) { self.courseID = courseID - self.context = courseReview == nil ? .create : .update - self.courseReview = courseReview - self.currentText = courseReview?.text ?? "" - self.currentScore = courseReview?.score ?? 0 self.presenter = presenter self.provider = provider + self.presentationContext = presentationContext + + switch presentationContext { + case .create(let courseReviewPlainObject): + self.currentText = courseReviewPlainObject?.text ?? "" + self.currentScore = courseReviewPlainObject?.score ?? 0 + case .update(let courseReview): + self.currentCourseReview = courseReview + self.currentText = courseReview.text + self.currentScore = courseReview.score + } } func doCourseReviewLoad(request: WriteCourseReview.CourseReviewLoad.Request) { @@ -78,11 +85,11 @@ final class WriteCourseReviewInteractor: WriteCourseReviewInteractorProtocol { self.presenter.presentWaitingState(response: .init(shouldDismiss: false)) - switch self.context { + switch self.presentationContext { case .create: self.createCourseReview(score: self.currentScore, text: trimmedText) case .update: - self.updateCourseReview(self.courseReview.require(), score: self.currentScore, text: trimmedText) + self.updateCourseReview(self.currentCourseReview.require(), score: self.currentScore, text: trimmedText) } } @@ -90,7 +97,7 @@ final class WriteCourseReviewInteractor: WriteCourseReviewInteractorProtocol { private func createCourseReview(score: Int, text: String) { self.provider.create(courseID: self.courseID, score: score, text: text).done { createdCourseReview in - self.courseReview = createdCourseReview + self.currentCourseReview = createdCourseReview self.presenter.presentCourseReviewMainActionResult( response: WriteCourseReview.CourseReviewMainAction.Response(isSuccessful: true) @@ -108,7 +115,7 @@ final class WriteCourseReviewInteractor: WriteCourseReviewInteractorProtocol { courseReview.text = text self.provider.update(courseReview: courseReview).done { updatedCourseReview in - self.courseReview = updatedCourseReview + self.currentCourseReview = updatedCourseReview self.presenter.presentCourseReviewMainActionResult( response: WriteCourseReview.CourseReviewMainAction.Response(isSuccessful: true) @@ -120,11 +127,4 @@ final class WriteCourseReviewInteractor: WriteCourseReviewInteractorProtocol { ) } } - - // MARK: - Inner Types - - private enum Context { - case create - case update - } } diff --git a/Stepic/Sources/Services/Models/Network/CourseReviewsNetworkService.swift b/Stepic/Sources/Services/Models/Network/CourseReviewsNetworkService.swift index 3d92be0b3c..0e9b0a77af 100644 --- a/Stepic/Sources/Services/Models/Network/CourseReviewsNetworkService.swift +++ b/Stepic/Sources/Services/Models/Network/CourseReviewsNetworkService.swift @@ -4,6 +4,8 @@ import PromiseKit protocol CourseReviewsNetworkServiceProtocol: AnyObject { func fetch(by courseID: Course.IdType, page: Int) -> Promise<([CourseReview], Meta)> func fetch(courseID: Course.IdType, userID: User.IdType) -> Promise<([CourseReview], Meta)> + func fetch(userID: User.IdType, page: Int) -> Promise<([CourseReview], Meta)> + func fetchAll(userID: User.IdType) -> Promise<[CourseReview]> func create(courseID: Course.IdType, userID: User.IdType, score: Int, text: String) -> Promise func update(courseReview: CourseReview) -> Promise func delete(id: CourseReview.IdType) -> Promise @@ -36,6 +38,26 @@ final class CourseReviewsNetworkService: CourseReviewsNetworkServiceProtocol { } } + func fetch(userID: User.IdType, page: Int) -> Promise<([CourseReview], Meta)> { + Promise { seal in + self.courseReviewsAPI.retrieve(userID: userID, page: page).done { results, meta in + seal.fulfill((results, meta)) + }.catch { _ in + seal.reject(Error.fetchFailed) + } + } + } + + func fetchAll(userID: User.IdType) -> Promise<[CourseReview]> { + Promise { seal in + self.courseReviewsAPI.retrieveAll(userID: userID).done { results in + seal.fulfill(results) + }.catch { _ in + seal.reject(Error.fetchFailed) + } + } + } + func create(courseID: Course.IdType, userID: User.IdType, score: Int, text: String) -> Promise { Promise { seal in self.courseReviewsAPI.create(courseID: courseID, userID: userID, score: score, text: text).done { result in diff --git a/Stepic/Sources/Services/Models/Persistence/CourseReviewsPersistenceService.swift b/Stepic/Sources/Services/Models/Persistence/CourseReviewsPersistenceService.swift index 78a871d03f..8f511d9125 100644 --- a/Stepic/Sources/Services/Models/Persistence/CourseReviewsPersistenceService.swift +++ b/Stepic/Sources/Services/Models/Persistence/CourseReviewsPersistenceService.swift @@ -4,8 +4,9 @@ import PromiseKit protocol CourseReviewsPersistenceServiceProtocol: AnyObject { func fetch(ids: [CourseReview.IdType]) -> Guarantee<[CourseReview]> - func fetch(by courseID: Course.IdType) -> Promise<[CourseReview]> - func fetch(by courseID: Course.IdType, userID: User.IdType) -> Promise + func fetch(courseID: Course.IdType) -> Promise<[CourseReview]> + func fetch(courseID: Course.IdType, userID: User.IdType) -> Promise + func fetch(userID: User.IdType) -> Promise<[CourseReview]> func delete(by courseReviewID: CourseReview.IdType) -> Promise func deleteAll() -> Promise @@ -27,7 +28,7 @@ final class CourseReviewsPersistenceService: CourseReviewsPersistenceServiceProt } } - func fetch(by courseID: Course.IdType) -> Promise<[CourseReview]> { + func fetch(courseID: Course.IdType) -> Promise<[CourseReview]> { Promise { seal in CourseReview.fetch(courseID: courseID).done { seal.fulfill($0) @@ -35,7 +36,7 @@ final class CourseReviewsPersistenceService: CourseReviewsPersistenceServiceProt } } - func fetch(by courseID: Course.IdType, userID: User.IdType) -> Promise { + func fetch(courseID: Course.IdType, userID: User.IdType) -> Promise { Promise { seal in CourseReview.fetch(courseID: courseID, userID: userID).done { reviews in seal.fulfill(reviews.first) @@ -43,6 +44,14 @@ final class CourseReviewsPersistenceService: CourseReviewsPersistenceServiceProt } } + func fetch(userID: User.IdType) -> Promise<[CourseReview]> { + Promise { seal in + CourseReview.fetch(userID: userID).done { + seal.fulfill($0) + } + } + } + func delete(by courseReviewID: CourseReview.IdType) -> Promise { Promise { seal in CourseReview.delete(courseReviewID).done { diff --git a/Stepic/Sources/Services/Models/Persistence/CoursesPersistenceService.swift b/Stepic/Sources/Services/Models/Persistence/CoursesPersistenceService.swift index 8d1f52078f..2e8146452d 100644 --- a/Stepic/Sources/Services/Models/Persistence/CoursesPersistenceService.swift +++ b/Stepic/Sources/Services/Models/Persistence/CoursesPersistenceService.swift @@ -2,23 +2,23 @@ import Foundation import PromiseKit protocol CoursesPersistenceServiceProtocol: AnyObject { - func fetch(ids: [Course.IdType], page: Int) -> Promise<([Course], Meta)> + func fetch(ids: [Course.IdType]) -> Promise<[Course]> func fetch(id: Course.IdType) -> Promise func fetchEnrolled() -> Guarantee<[Course]> func fetchAll() -> Guarantee<[Course]> } extension CoursesPersistenceServiceProtocol { - func fetch(ids: [Course.IdType]) -> Promise<([Course], Meta)> { - self.fetch(ids: ids, page: 1) + func fetch(ids: [Course.IdType], page: Int = 1) -> Promise<([Course], Meta)> { + self.fetch(ids: ids).map { ($0, Meta.oneAndOnlyPage) } } } final class CoursesPersistenceService: CoursesPersistenceServiceProtocol { - func fetch(ids: [Course.IdType], page: Int) -> Promise<([Course], Meta)> { + func fetch(ids: [Course.IdType]) -> Promise<[Course]> { Promise { seal in Course.fetchAsync(ids: ids).done { courses in - seal.fulfill((courses, Meta.oneAndOnlyPage)) + seal.fulfill(courses) }.catch { _ in seal.reject(Error.fetchFailed) } @@ -27,7 +27,7 @@ final class CoursesPersistenceService: CoursesPersistenceServiceProtocol { func fetch(id: Course.IdType) -> Promise { Promise { seal in - self.fetch(ids: [id]).done { courses, _ in + self.fetch(ids: [id]).done { courses in seal.fulfill(courses.first) }.catch { _ in seal.reject(Error.fetchFailed) diff --git a/Stepic/en.lproj/Localizable.strings b/Stepic/en.lproj/Localizable.strings index 4ea6d01f87..b2830d20e5 100644 --- a/Stepic/en.lproj/Localizable.strings +++ b/Stepic/en.lproj/Localizable.strings @@ -335,6 +335,9 @@ followers567890 = "followers"; authors1 = "author"; authors234 = "authors"; authors567890 = "authors"; +reviews1 = "review"; +reviews234 = "reviews"; +reviews567890 = "reviews"; SearchPlaceholderEmpty = "Sorry, we didn't find any courses matching your request"; SearchPlaceholderError = "Error while searching courses. Press to try again"; TagPlaceholderError = "Error while loading courses. Press to try again"; @@ -504,6 +507,15 @@ UserCoursesTabAllCoursesTitle = "All"; UserCoursesTabFavoritesTitle = "Favorite"; UserCoursesTabArchivedTitle = "Archive"; +/* User Courses Reviews */ +UserCoursesReviewsTitle = "Reviews"; +UserCoursesReviewsBlockTitle = "My Reviews"; +UserCoursesReviewsPlaceholderEmptyTitle = "No Reviews"; +UserCoursesReviewsLeaveReview = "Leave a Review"; +UserCoursesReviewsPossibleReviews1 = "new course for review"; +UserCoursesReviewsPossibleReviews234 = "new courses for review"; +UserCoursesReviewsPossibleReviews567890 = "new courses for review"; + RetentionNotificationOnNextDayTitle = "Don't slow down"; RetentionNotificationOnNextDayText = "You've done a great job learning so far. Come back and get even smarter! 🚀"; RetentionNotificationOnThirdDayTitle = "Time to study"; diff --git a/Stepic/ru.lproj/Localizable.strings b/Stepic/ru.lproj/Localizable.strings index 160540acf1..7c2829906c 100644 --- a/Stepic/ru.lproj/Localizable.strings +++ b/Stepic/ru.lproj/Localizable.strings @@ -336,6 +336,9 @@ followers567890 = "подписчиков"; authors1 = "автор"; authors234 = "автора"; authors567890 = "авторов"; +reviews1 = "отзыв"; +reviews234 = "отзыва"; +reviews567890 = "отзывов"; SearchPlaceholderEmpty = "Упс, мы не нашли ни одного курса по запросу"; SearchPlaceholderError = "Ошибка при поиске курсов. Нажмите, чтобы попробовать еще раз"; TagPlaceholderError = "Ошибка при загрузке курсов. Нажмите, чтобы попробовать еще раз"; @@ -506,6 +509,15 @@ UserCoursesTabAllCoursesTitle = "Все"; UserCoursesTabFavoritesTitle = "Избранные"; UserCoursesTabArchivedTitle = "Архив"; +/* User Courses Reviews */ +UserCoursesReviewsTitle = "Отзывы"; +UserCoursesReviewsBlockTitle = "Мои отзывы"; +UserCoursesReviewsPlaceholderEmptyTitle = "Нет отзывов"; +UserCoursesReviewsLeaveReview = "Оставить отзыв"; +UserCoursesReviewsPossibleReviews1 = "новый курс для отзыва"; +UserCoursesReviewsPossibleReviews234 = "новых курса для отзыва"; +UserCoursesReviewsPossibleReviews567890 = "новых курсов для отзыва"; + RetentionNotificationOnNextDayTitle = "Не сбавляем темп"; RetentionNotificationOnNextDayText = "Вы проделали отличную работу вчера - это хороший повод зайти сегодня и продолжить учиться! 🚀"; RetentionNotificationOnThirdDayTitle = "Время учиться"; diff --git a/StepicTests/Info-Develop.plist b/StepicTests/Info-Develop.plist index d32acbb7c6..45edb913f6 100644 --- a/StepicTests/Info-Develop.plist +++ b/StepicTests/Info-Develop.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.174-develop + 1.175-develop CFBundleSignature ???? CFBundleVersion - 333 + 335 diff --git a/StepicTests/Info-Production.plist b/StepicTests/Info-Production.plist index 8945c3a15d..d26d28253f 100644 --- a/StepicTests/Info-Production.plist +++ b/StepicTests/Info-Production.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.174 + 1.175 CFBundleSignature ???? CFBundleVersion - 333 + 335 diff --git a/StepicTests/Info-Release.plist b/StepicTests/Info-Release.plist index 658a992c03..f2140bdd7e 100644 --- a/StepicTests/Info-Release.plist +++ b/StepicTests/Info-Release.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.174-release + 1.175-release CFBundleSignature ???? CFBundleVersion - 333 + 335 diff --git a/StepicUITests/Info-Develop.plist b/StepicUITests/Info-Develop.plist index 6ed4fd714c..eac22780cd 100644 --- a/StepicUITests/Info-Develop.plist +++ b/StepicUITests/Info-Develop.plist @@ -15,8 +15,8 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.174-develop + 1.175-develop CFBundleVersion - 333 + 335 diff --git a/StepicUITests/Info-Production.plist b/StepicUITests/Info-Production.plist index 22fc394138..6f9eaa0b7c 100644 --- a/StepicUITests/Info-Production.plist +++ b/StepicUITests/Info-Production.plist @@ -15,8 +15,8 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.174 + 1.175 CFBundleVersion - 333 + 335 diff --git a/StepicUITests/Info-Release.plist b/StepicUITests/Info-Release.plist index 3841babe85..1d30d69efe 100644 --- a/StepicUITests/Info-Release.plist +++ b/StepicUITests/Info-Release.plist @@ -15,8 +15,8 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.174-release + 1.175-release CFBundleVersion - 333 + 335 diff --git a/StepicWidget/Info-Develop.plist b/StepicWidget/Info-Develop.plist index ad51b1966d..82bbae8cb5 100644 --- a/StepicWidget/Info-Develop.plist +++ b/StepicWidget/Info-Develop.plist @@ -17,9 +17,9 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.174-develop + 1.175-develop CFBundleVersion - 333 + 335 NSExtension NSExtensionPointIdentifier diff --git a/StepicWidget/Info-Production.plist b/StepicWidget/Info-Production.plist index f64fd46c89..0f2b24cb76 100644 --- a/StepicWidget/Info-Production.plist +++ b/StepicWidget/Info-Production.plist @@ -17,9 +17,9 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.174 + 1.175 CFBundleVersion - 333 + 335 NSExtension NSExtensionPointIdentifier diff --git a/StepicWidget/Info-Release.plist b/StepicWidget/Info-Release.plist index 997eb225f5..bf9be05503 100644 --- a/StepicWidget/Info-Release.plist +++ b/StepicWidget/Info-Release.plist @@ -17,9 +17,9 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.174-release + 1.175-release CFBundleVersion - 333 + 335 NSExtension NSExtensionPointIdentifier diff --git a/StickerPackExtension/Info-Develop.plist b/StickerPackExtension/Info-Develop.plist index 7a7a854f0c..986adb8909 100644 --- a/StickerPackExtension/Info-Develop.plist +++ b/StickerPackExtension/Info-Develop.plist @@ -17,9 +17,9 @@ CFBundlePackageType XPC! CFBundleShortVersionString - 1.174-develop + 1.175-develop CFBundleVersion - 333 + 335 NSExtension NSExtensionPointIdentifier diff --git a/StickerPackExtension/Info-Production.plist b/StickerPackExtension/Info-Production.plist index 2361205ffb..4a75d4fa50 100644 --- a/StickerPackExtension/Info-Production.plist +++ b/StickerPackExtension/Info-Production.plist @@ -17,9 +17,9 @@ CFBundlePackageType XPC! CFBundleShortVersionString - 1.174 + 1.175 CFBundleVersion - 333 + 335 NSExtension NSExtensionPointIdentifier diff --git a/StickerPackExtension/Info-Release.plist b/StickerPackExtension/Info-Release.plist index 11775b3d5f..5935b719cb 100644 --- a/StickerPackExtension/Info-Release.plist +++ b/StickerPackExtension/Info-Release.plist @@ -17,9 +17,9 @@ CFBundlePackageType XPC! CFBundleShortVersionString - 1.174-release + 1.175-release CFBundleVersion - 333 + 335 NSExtension NSExtensionPointIdentifier diff --git a/fastlane/Devicefile b/fastlane/Devicefile index a77c6581be..4f0f04ba16 100644 Binary files a/fastlane/Devicefile and b/fastlane/Devicefile differ diff --git a/fastlane/release-notes.txt b/fastlane/release-notes.txt index 4ff5fbb966..51a7375684 100644 --- a/fastlane/release-notes.txt +++ b/fastlane/release-notes.txt @@ -1,3 +1,2 @@ Что тестировать: -- Перейти на новое API для демо-уроков APPS-3317 -- Не отображаются названия экранов в back навигации на iOS 14 APPS-3318 \ No newline at end of file +- Экран отзывов пользователя APPS-3320 \ No newline at end of file