From 8bf2a177634f1efeb1dcbd34b7ee3cd01d1d4fde Mon Sep 17 00:00:00 2001 From: Ivan Magda Date: Wed, 20 Nov 2019 21:01:20 +0300 Subject: [PATCH 01/16] Edit step text (#564) * Add new model version * Parse can edit action * Display edit lesson bar button item * Generate module * Handle click on edit bar button item * Refactor rename EditLesson -> EditStep * Fetch step source * Fill text view with fetched step source * Update placeholder * Register placeholder for connection error * Handle text changes * Update text view text insets * Disable text view's autocorrection features * Enable haptics feedback for SVProgressHUD * Remote step source update * Refresh step on step source updated * Fix quizzes content text update * Update only text on step source change * Add more bar button item * Add edit message * Hide content views on loading --- Stepic.xcodeproj/project.pbxproj | 76 ++++- Stepic/AppDelegate.swift | 1 + Stepic/JSONSerializable.swift | 2 + Stepic/Lesson+CoreDataProperties.swift | 84 ++--- Stepic/Lesson.swift | 99 +++--- Stepic/Model.xcdatamodeld/.xccurrentversion | 2 +- .../contents | 299 ++++++++++++++++++ .../Modules/EditStep/EditStepAssembly.swift | 25 ++ .../Modules/EditStep/EditStepDataFlow.swift | 62 ++++ .../Modules/EditStep/EditStepInteractor.swift | 107 +++++++ .../Modules/EditStep/EditStepPresenter.swift | 50 +++ .../Modules/EditStep/EditStepProvider.swift | 44 +++ .../Modules/EditStep/EditStepView.swift | 180 +++++++++++ .../EditStep/EditStepViewController.swift | 174 ++++++++++ .../Modules/EditStep/EditStepViewModel.swift | 6 + .../InputOutput/EditStepOutputProtocol.swift | 5 + .../Modules/NewLesson/NewLessonDataFlow.swift | 29 ++ .../NewLesson/NewLessonInteractor.swift | 28 +- .../NewLesson/NewLessonPresenter.swift | 21 +- .../NewLesson/NewLessonViewController.swift | 77 ++++- .../NewLesson/NewLessonViewModel.swift | 1 + .../InputOutput/NewStepInputProtocol.swift | 1 + .../Modules/NewStep/NewStepDataFlow.swift | 12 + .../Modules/NewStep/NewStepInteractor.swift | 8 + .../Modules/NewStep/NewStepPresenter.swift | 37 ++- .../Sources/Modules/NewStep/NewStepView.swift | 7 + .../NewStep/NewStepViewController.swift | 11 + .../Network/StepSourcesNetworkService.swift | 33 ++ Stepic/StepSources/StepSource.swift | 57 ++++ Stepic/StepSourcesAPI.swift | 32 ++ Stepic/en.lproj/Localizable.strings | 8 + Stepic/ru.lproj/Localizable.strings | 8 + 32 files changed, 1489 insertions(+), 97 deletions(-) create mode 100644 Stepic/Model.xcdatamodeld/Model_lesson_can_edit.xcdatamodel/contents create mode 100644 Stepic/Sources/Modules/EditStep/EditStepAssembly.swift create mode 100644 Stepic/Sources/Modules/EditStep/EditStepDataFlow.swift create mode 100644 Stepic/Sources/Modules/EditStep/EditStepInteractor.swift create mode 100644 Stepic/Sources/Modules/EditStep/EditStepPresenter.swift create mode 100644 Stepic/Sources/Modules/EditStep/EditStepProvider.swift create mode 100644 Stepic/Sources/Modules/EditStep/EditStepView.swift create mode 100644 Stepic/Sources/Modules/EditStep/EditStepViewController.swift create mode 100644 Stepic/Sources/Modules/EditStep/EditStepViewModel.swift create mode 100644 Stepic/Sources/Modules/EditStep/InputOutput/EditStepOutputProtocol.swift create mode 100644 Stepic/Sources/Services/Models/Network/StepSourcesNetworkService.swift create mode 100644 Stepic/StepSources/StepSource.swift create mode 100644 Stepic/StepSourcesAPI.swift diff --git a/Stepic.xcodeproj/project.pbxproj b/Stepic.xcodeproj/project.pbxproj index 99e66442db..dc5164bfaa 100644 --- a/Stepic.xcodeproj/project.pbxproj +++ b/Stepic.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 00D9752C274A93E6D910182D /* SettingsStepFontSizeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5A069203FEC11080B9275D2 /* SettingsStepFontSizeView.swift */; }; + 03DD4B3EFF2A6EAD11A9CF0E /* EditStepOutputProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFF6FE42BAC2B17962D9C85C /* EditStepOutputProtocol.swift */; }; 04DF353CB70440D52BB27C5B /* WriteCourseReviewProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B97CDBCF62D822BE8DF6A365 /* WriteCourseReviewProvider.swift */; }; 079FDB74452B3A3BB122EBFB /* NewMatchingQuizInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A484FA9C0CDF08A193A71381 /* NewMatchingQuizInteractor.swift */; }; 0800B8181D06D961006C987E /* DiscussionProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0800B8171D06D961006C987E /* DiscussionProxy.swift */; }; @@ -377,6 +378,7 @@ 0FC4DF01DB2BF054237EBD04 /* NewChoiceQuizInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 077CA2A02D61A314B281C7C3 /* NewChoiceQuizInteractor.swift */; }; 10B5CCF7E39ED3E7BB8382F6 /* SettingsStepFontSizeInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35918ED4BFF0FC4C8692E8A6 /* SettingsStepFontSizeInteractor.swift */; }; 10F32F9073D41A146E8A3994 /* WriteCommentOutputProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 995EDC0C4A4B7085EE7589C2 /* WriteCommentOutputProtocol.swift */; }; + 16BBE062FD18B06A7E6E65CD /* EditStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04C0635413716CE788EAC4CC /* EditStepView.swift */; }; 18DBB0FACACD30EC7F311E3C /* NewCodeQuizFullscreenView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 081C378AAE221323BF85346D /* NewCodeQuizFullscreenView.swift */; }; 18E3319B16168E42505378AB /* SettingsStepFontSizePresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3934F4CAA53B3137AE330BC4 /* SettingsStepFontSizePresenter.swift */; }; 19CEA9C063AE5CD5A682CFA9 /* ProfileEditInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E5BA4688D7B46C416231BD9 /* ProfileEditInteractor.swift */; }; @@ -675,6 +677,10 @@ 2CF0885D205BED9700FCB9C0 /* StepikPlaceholderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 2CF0885C205BED9700FCB9C0 /* StepikPlaceholderView.xib */; }; 2CF08861205BEE2C00FCB9C0 /* StepikPlaceholder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CF08860205BEE2B00FCB9C0 /* StepikPlaceholder.swift */; }; 2CF08864205BEF3C00FCB9C0 /* StepikPlaceholderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CF08863205BEEB900FCB9C0 /* StepikPlaceholderView.swift */; }; + 2CF10C892384243100F8CC95 /* StepSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CF10C882384243100F8CC95 /* StepSource.swift */; }; + 2CF10C8B238426B300F8CC95 /* StepSourcesNetworkService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CF10C8A238426B300F8CC95 /* StepSourcesNetworkService.swift */; }; + 2CF10C8D238426DC00F8CC95 /* StepSourcesAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CF10C8C238426DC00F8CC95 /* StepSourcesAPI.swift */; }; + 2CF10C8F23842EB300F8CC95 /* EditStepViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CF10C8E23842EB300F8CC95 /* EditStepViewModel.swift */; }; 2CF1B33E2163BE720008DA0C /* StoriesAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08484EEC211AF4300006266F /* StoriesAssembly.swift */; }; 2CF1B33F2163BE770008DA0C /* StoriesPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08484EF5211AF4310006266F /* StoriesPresenter.swift */; }; 2CF1B3402163BE820008DA0C /* StoriesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08484EE5211AF42E0006266F /* StoriesViewController.swift */; }; @@ -972,6 +978,7 @@ 7AC15207BB66E845FDBEE234 /* NewCodeQuizFullscreenInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3025F71BBEA83B2F2D4EE286 /* NewCodeQuizFullscreenInteractor.swift */; }; 7D334A7F2599276A266E17AE /* WriteCommentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C65429C78822944952072FB /* WriteCommentView.swift */; }; 7FBB25F3FD251734F7E049DD /* NewStepDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 49034C40838BCF58896D0118 /* NewStepDataFlow.swift */; }; + 81AA1C99009AE54671EA94AA /* EditStepPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4ABF7F0EEB3C23FDB077812B /* EditStepPresenter.swift */; }; 857E1FD24A70F87BCA6EBABA /* NewStringQuizInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2111290D2E9D752A22AFC024 /* NewStringQuizInteractor.swift */; }; 85CD3504769C1B6C8C21661A /* NewMatchingQuizAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DC73A992112910D33A82457 /* NewMatchingQuizAssembly.swift */; }; 861B96371FE1DF7F00773EDA /* CAGradientLayer+Init.swift in Sources */ = {isa = PBXBuildFile; fileRef = 861B96361FE1DF7F00773EDA /* CAGradientLayer+Init.swift */; }; @@ -984,12 +991,16 @@ 86BB7C022019538100063538 /* CongratsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86BB7C012019538000063538 /* CongratsView.swift */; }; 86BB7C07201953AF00063538 /* CongratulationAlertManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86BB7C04201953AE00063538 /* CongratulationAlertManager.swift */; }; 86BB7C09201953AF00063538 /* CongratulationViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 86BB7C06201953AF00063538 /* CongratulationViewController.xib */; }; + 8DE634AD0E06B64B305A3923 /* EditStepInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3750E7500C08250CD7F6FC2B /* EditStepInteractor.swift */; }; 8DF8D727FFAF37772B6AE2B9 /* WriteCourseReviewInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22D74D3F78768D42D919852E /* WriteCourseReviewInteractor.swift */; }; 92E876582D263061F44316EB /* NewFreeAnswerQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6F7667856CC2C2A2AC43631 /* NewFreeAnswerQuizView.swift */; }; 95EA87E2E8976E57928066B2 /* DownloadsPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBE0AD7C5EA50025234E0043 /* DownloadsPresenter.swift */; }; 97B20DBCAE18BA196B55CA91 /* WriteCourseReviewDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C485A2D511BCFEA6E85B22F /* WriteCourseReviewDataFlow.swift */; }; + A08FD585E04A1E2F34F2EEC0 /* EditStepDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A715786928D212A9475B378B /* EditStepDataFlow.swift */; }; A0A7AC991F92BEC73820B298 /* WriteCommentDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7DBF5A43025E726B6D61E1E /* WriteCommentDataFlow.swift */; }; + A103594E8906CF385487A4B3 /* EditStepProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B8F2031F6100BFD4570B903 /* EditStepProvider.swift */; }; A40E18B5A40D2E0EFD4F5710 /* DownloadsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70E668471939A3102C37C0A9 /* DownloadsProvider.swift */; }; + AC5F57E43EC0844613551EFF /* EditStepAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80DF178AB92B327825602F51 /* EditStepAssembly.swift */; }; B05BCD4B9FBDCF94AF7DB650 /* DiscussionsPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B671AACF375B3ED49120185 /* DiscussionsPresenter.swift */; }; B0D23296AB3FAC5BC6F45029 /* NewLessonViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC193B8E01FF4A61441ABF13 /* NewLessonViewController.swift */; }; B8042A238380ACC1F32082E1 /* NewStepAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D1AB142BF62815DF8939656 /* NewStepAssembly.swift */; }; @@ -1002,6 +1013,7 @@ C88A7FEA545C088A225A40DA /* NewSortingQuizAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 055309785EA029DFB13747CF /* NewSortingQuizAssembly.swift */; }; CC41AA7838E7609F7DBECDB7 /* NewMatchingQuizPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D779F899F835A1724F0C1186 /* NewMatchingQuizPresenter.swift */; }; CCD2044493C055C6F7C7A3DD /* NewChoiceQuizPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF89927A843447A9AE4C0AF4 /* NewChoiceQuizPresenter.swift */; }; + CEE9851CA09075C21B66447E /* EditStepViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 387BE230BE0ABC4E38FF9642 /* EditStepViewController.swift */; }; D07003A6B86B063D4892BA44 /* DownloadsInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E30A86C7EDD2A584367E5E8 /* DownloadsInteractor.swift */; }; D5D552D4FF73B1724C73BE34 /* DownloadsAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1B424AC542C505D00498C94 /* DownloadsAssembly.swift */; }; D6BC8F93D8D6EF86E9EE704C /* UnsupportedQuizInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3AF52F67CF39A1A5DC20F44 /* UnsupportedQuizInteractor.swift */; }; @@ -1057,6 +1069,7 @@ /* Begin PBXFileReference section */ 02E495D6D30976CAD27AE1EB /* NewStringQuizPresenter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NewStringQuizPresenter.swift; sourceTree = ""; }; + 04C0635413716CE788EAC4CC /* EditStepView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EditStepView.swift; sourceTree = ""; }; 055309785EA029DFB13747CF /* NewSortingQuizAssembly.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NewSortingQuizAssembly.swift; sourceTree = ""; }; 063EC2B5FD732A66BDA65259 /* WriteCourseReviewPresenter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WriteCourseReviewPresenter.swift; sourceTree = ""; }; 063F920E9867D0DB4C2CBA6A /* NewStringQuizAssembly.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NewStringQuizAssembly.swift; sourceTree = ""; }; @@ -1481,6 +1494,7 @@ 232D0A4BD87325BE0E568BF0 /* NewFreeAnswerQuizInteractor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NewFreeAnswerQuizInteractor.swift; sourceTree = ""; }; 233DE469779653213327B9C5 /* WriteCourseReviewView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WriteCourseReviewView.swift; sourceTree = ""; }; 2A8E4DEE020E670D88BD7FEA /* UnsupportedQuizPresenter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UnsupportedQuizPresenter.swift; sourceTree = ""; }; + 2B8F2031F6100BFD4570B903 /* EditStepProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EditStepProvider.swift; sourceTree = ""; }; 2C0176C02188953700DDB9D0 /* NotificationAlertsAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationAlertsAnalytics.swift; sourceTree = ""; }; 2C01BB67233CD92C00C8DCF0 /* Require.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Require.swift; sourceTree = ""; }; 2C01D3A922DDB7EA00C84CEE /* DefaultsContainer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DefaultsContainer.swift; sourceTree = ""; }; @@ -1550,6 +1564,7 @@ 2C29B62F22C664C500730C16 /* ChoiceElementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChoiceElementView.swift; sourceTree = ""; }; 2C2D2D8A22E9FC2F003F1563 /* CodeEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CodeEditorView.swift; sourceTree = ""; }; 2C2EA36A212D5FEF002116C9 /* Result.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Result.swift; sourceTree = ""; }; + 2C2EBAB92382E3EB00AB1B83 /* Model_lesson_can_edit.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Model_lesson_can_edit.xcdatamodel; sourceTree = ""; }; 2C2F0BE62186EEB8007DCA0A /* StreakNotificationsRequestAlertDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreakNotificationsRequestAlertDataSource.swift; sourceTree = ""; }; 2C2F0BE82186EF87007DCA0A /* CommonNotificationsRequestAlertDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonNotificationsRequestAlertDataSource.swift; sourceTree = ""; }; 2C2F0BEB2186F0C3007DCA0A /* NotificationsRequestAlertPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsRequestAlertPresenter.swift; sourceTree = ""; }; @@ -1780,6 +1795,10 @@ 2CF0885C205BED9700FCB9C0 /* StepikPlaceholderView.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = StepikPlaceholderView.xib; sourceTree = ""; }; 2CF08860205BEE2B00FCB9C0 /* StepikPlaceholder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StepikPlaceholder.swift; sourceTree = ""; }; 2CF08863205BEEB900FCB9C0 /* StepikPlaceholderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepikPlaceholderView.swift; sourceTree = ""; }; + 2CF10C882384243100F8CC95 /* StepSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepSource.swift; sourceTree = ""; }; + 2CF10C8A238426B300F8CC95 /* StepSourcesNetworkService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepSourcesNetworkService.swift; sourceTree = ""; }; + 2CF10C8C238426DC00F8CC95 /* StepSourcesAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepSourcesAPI.swift; sourceTree = ""; }; + 2CF10C8E23842EB300F8CC95 /* EditStepViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditStepViewModel.swift; sourceTree = ""; }; 2CF1DD9B230A8D780083350A /* NewSortingQuizViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewSortingQuizViewModel.swift; sourceTree = ""; }; 2CF4252A2024C10C002D7305 /* ru */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = ru; path = ru.lproj/overlay_hard.png; sourceTree = ""; }; 2CF4252C2024C10D002D7305 /* ru */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = ru; path = ru.lproj/overlay_simple.png; sourceTree = ""; }; @@ -1799,7 +1818,9 @@ 362684BA69196E87B5919671 /* NewStepProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NewStepProvider.swift; sourceTree = ""; }; 36E041388C85D190467E74A5 /* NewChoiceQuizViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NewChoiceQuizViewController.swift; sourceTree = ""; }; 36F53BF6D2A309CFC55A72D5 /* WriteCommentAssembly.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WriteCommentAssembly.swift; sourceTree = ""; }; + 3750E7500C08250CD7F6FC2B /* EditStepInteractor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EditStepInteractor.swift; sourceTree = ""; }; 37D0922F478391C8B44EA5D0 /* NewFreeAnswerQuizPresenter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NewFreeAnswerQuizPresenter.swift; sourceTree = ""; }; + 387BE230BE0ABC4E38FF9642 /* EditStepViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EditStepViewController.swift; sourceTree = ""; }; 3934F4CAA53B3137AE330BC4 /* SettingsStepFontSizePresenter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SettingsStepFontSizePresenter.swift; sourceTree = ""; }; 3AD36917F532D0F83798B2F2 /* NewChoiceQuizAssembly.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NewChoiceQuizAssembly.swift; sourceTree = ""; }; 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 = ""; }; @@ -1810,6 +1831,7 @@ 49034C40838BCF58896D0118 /* NewStepDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NewStepDataFlow.swift; 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 = ""; }; 4A4D51541BF7AE1FCD2865C0 /* ProfileEditAssembly.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ProfileEditAssembly.swift; sourceTree = ""; }; + 4ABF7F0EEB3C23FDB077812B /* EditStepPresenter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EditStepPresenter.swift; sourceTree = ""; }; 4C9CD16032427B60FC4C99EF /* ProfileEditDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ProfileEditDataFlow.swift; sourceTree = ""; }; 4CCE255730E82181FAAF0AAA /* WriteCommentViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WriteCommentViewController.swift; sourceTree = ""; }; 4E30A86C7EDD2A584367E5E8 /* DownloadsInteractor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DownloadsInteractor.swift; sourceTree = ""; }; @@ -2076,6 +2098,7 @@ 7398B256EE198D1F6A6AF959 /* NewMatchingQuizViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NewMatchingQuizViewController.swift; sourceTree = ""; }; 79467CCB416E6C3CC9613912 /* NewStringQuizView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NewStringQuizView.swift; sourceTree = ""; }; 7D3417B44078A2AB57A0E63F /* NewCodeQuizFullscreenPresenter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NewCodeQuizFullscreenPresenter.swift; sourceTree = ""; }; + 80DF178AB92B327825602F51 /* EditStepAssembly.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EditStepAssembly.swift; sourceTree = ""; }; 80E8045E8645E07DAAA62D82 /* NewLessonProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NewLessonProvider.swift; sourceTree = ""; }; 831CC7AB7FC20E336DB6813F /* ProfileEditView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ProfileEditView.swift; sourceTree = ""; }; 84763D78E2F327874DA51576 /* NewStepInteractor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NewStepInteractor.swift; sourceTree = ""; }; @@ -2104,6 +2127,7 @@ A1F285E88F0E449B536C9DE9 /* NewCodeQuizFullscreenDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NewCodeQuizFullscreenDataFlow.swift; sourceTree = ""; }; A458B834FD5BF8E1E16E5A0A /* DiscussionsAssembly.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DiscussionsAssembly.swift; sourceTree = ""; }; A484FA9C0CDF08A193A71381 /* NewMatchingQuizInteractor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NewMatchingQuizInteractor.swift; sourceTree = ""; }; + A715786928D212A9475B378B /* EditStepDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EditStepDataFlow.swift; sourceTree = ""; }; AACBF39DAC4A4A599F8949F2 /* WriteCommentInteractor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WriteCommentInteractor.swift; sourceTree = ""; }; AC5B5698955B5FF2B7EFB6F4 /* NewLessonDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NewLessonDataFlow.swift; sourceTree = ""; }; ACDDB66D0848F4DCD752795B /* NewStringQuizDataFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NewStringQuizDataFlow.swift; sourceTree = ""; }; @@ -2121,6 +2145,7 @@ BFB28C8741D8E5CBF35B3437 /* WriteCourseReviewViewController.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WriteCourseReviewViewController.swift; sourceTree = ""; }; C6F7667856CC2C2A2AC43631 /* NewFreeAnswerQuizView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NewFreeAnswerQuizView.swift; sourceTree = ""; }; C77A40A485EDD8A3E4AA0157 /* WriteCommentProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WriteCommentProvider.swift; sourceTree = ""; }; + CFF6FE42BAC2B17962D9C85C /* EditStepOutputProtocol.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EditStepOutputProtocol.swift; sourceTree = ""; }; D109E72D69B31373C97237F8 /* Pods-Stepic.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Stepic.release.xcconfig"; path = "Target Support Files/Pods-Stepic/Pods-Stepic.release.xcconfig"; sourceTree = ""; }; D1B424AC542C505D00498C94 /* DownloadsAssembly.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DownloadsAssembly.swift; sourceTree = ""; }; D3AF52F67CF39A1A5DC20F44 /* UnsupportedQuizInteractor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = UnsupportedQuizInteractor.swift; sourceTree = ""; }; @@ -2697,6 +2722,7 @@ 080CE1451E9560EB0089A27F /* SectionsAPI.swift */, 08AB82341D74926F00FDEADE /* StepicsAPI.swift */, 080CE14E1E9562F30089A27F /* StepsAPI.swift */, + 2CF10C8C238426DC00F8CC95 /* StepSourcesAPI.swift */, 082E35B120B5F1E4006E28F9 /* StorageRecordsAPI.swift */, 080E1ACD212583E5006B58A9 /* StoryTemplatesAPI.swift */, 080CE1601E9581960089A27F /* SubmissionsAPI.swift */, @@ -3628,6 +3654,7 @@ children = ( 2CA3DAA62179DF7300F43888 /* Discussions */, 2C1661012358D3290020B7F4 /* StepOptions */, + 2CF10C872384240800F8CC95 /* StepSources */, ); name = PlainObjects; sourceTree = ""; @@ -4579,6 +4606,14 @@ name = StepikPlaceholderView; sourceTree = ""; }; + 2CF10C872384240800F8CC95 /* StepSources */ = { + isa = PBXGroup; + children = ( + 2CF10C882384243100F8CC95 /* StepSource.swift */, + ); + path = StepSources; + sourceTree = ""; + }; 2CF1DD9D230A99A00083350A /* Views */ = { isa = PBXGroup; children = ( @@ -4588,6 +4623,14 @@ path = Views; sourceTree = ""; }; + 2E90778F7C4AF2F70EE30777 /* InputOutput */ = { + isa = PBXGroup; + children = ( + CFF6FE42BAC2B17962D9C85C /* EditStepOutputProtocol.swift */, + ); + path = InputOutput; + sourceTree = ""; + }; 3850CCACAD3492098C912EF0 /* NewFreeAnswerQuiz */ = { isa = PBXGroup; children = ( @@ -4710,6 +4753,7 @@ 62E98848DAB7C9A5711ACE31 /* CourseList */, B995B449201C2011220D4332 /* Discussions */, 4917B198DBE55DE112B1DC53 /* Downloads */, + 8141687664C8CAB499F5FCE0 /* EditStep */, 62E98C473245FCD11E5CAFD7 /* Explore */, 62E98AD1A0A46256AE28CD6C /* ExploreSubmodules */, 62E98DED2155646F770C5AAD /* FullscreenCourseList */, @@ -4796,6 +4840,7 @@ 62E989FAD86F79364CC2EF89 /* ProgressesNetworkService.swift */, 62E98E680AE36BC996E47264 /* SectionsNetworkService.swift */, 62E987944A98FB989B36D72C /* StepsNetworkService.swift */, + 2CF10C8A238426B300F8CC95 /* StepSourcesNetworkService.swift */, 2C6BBBBE22B26DB100889A45 /* SubmissionsNetworkService.swift */, 62E98E41865820B1B8F7357D /* UnitsNetworkService.swift */, 2C16495822C10DD300DF18CA /* UserActivitiesNetworkService.swift */, @@ -5409,6 +5454,22 @@ path = UnsupportedQuiz; sourceTree = ""; }; + 8141687664C8CAB499F5FCE0 /* EditStep */ = { + isa = PBXGroup; + children = ( + 80DF178AB92B327825602F51 /* EditStepAssembly.swift */, + A715786928D212A9475B378B /* EditStepDataFlow.swift */, + 3750E7500C08250CD7F6FC2B /* EditStepInteractor.swift */, + 4ABF7F0EEB3C23FDB077812B /* EditStepPresenter.swift */, + 2B8F2031F6100BFD4570B903 /* EditStepProvider.swift */, + 04C0635413716CE788EAC4CC /* EditStepView.swift */, + 387BE230BE0ABC4E38FF9642 /* EditStepViewController.swift */, + 2CF10C8E23842EB300F8CC95 /* EditStepViewModel.swift */, + 2E90778F7C4AF2F70EE30777 /* InputOutput */, + ); + path = EditStep; + sourceTree = ""; + }; 86BB7C032019539300063538 /* Congratulation */ = { isa = PBXGroup; children = ( @@ -6579,6 +6640,7 @@ 0837492D1DE5B07900144C14 /* AlertManager.swift in Sources */, 08DF78D21F64059900AEEA85 /* StepikLabel.swift in Sources */, 2CBD855C201799B700E14F83 /* AdaptiveRatingsViewController.swift in Sources */, + 2CF10C8F23842EB300F8CC95 /* EditStepViewModel.swift in Sources */, 08883AC4214C6AF200898BBE /* ProfileAssembly.swift in Sources */, 2C6BBBB522B261E700889A45 /* BaseQuizViewController.swift in Sources */, 2C20C86822F903D10052E9BF /* CodeEditorThemeService.swift in Sources */, @@ -6746,6 +6808,7 @@ 62E98C3824298B04928351E5 /* FormatterHelper.swift in Sources */, 62E9801D3AE83BF0735EFA3B /* CourseListAssembly.swift in Sources */, 62E98DDD53A76A1822756659 /* CourseListViewController.swift in Sources */, + 2CF10C8B238426B300F8CC95 /* StepSourcesNetworkService.swift in Sources */, 2C9E78B5237423CA00880459 /* DiscussionsView.swift in Sources */, 62E9837D44C9C2B50161FCA5 /* CourseListInteractor.swift in Sources */, 62E980EEA62C9B94A76B856A /* CourseListPresenter.swift in Sources */, @@ -6880,6 +6943,7 @@ 62E98E58E7BC6B6AF4881084 /* CourseReviewSummariesPersistenceService.swift in Sources */, 62E98BF2CB3B773C9E011073 /* CourseInfoTabInfoInputProtocol.swift in Sources */, 62E98CFD78F78B8053639E86 /* CourseInfoTabInfoProvider.swift in Sources */, + 2CF10C8D238426DC00F8CC95 /* StepSourcesAPI.swift in Sources */, 62E982769F779FD011D51705 /* CourseInfoTabInfoViewModel.swift in Sources */, 62E983408FE8FBDBC232AD18 /* ContinueCourseProvider.swift in Sources */, 2CD6E25D234E388B00F49303 /* EmailAddressesPersistenceService.swift in Sources */, @@ -6998,6 +7062,7 @@ 0BC1205C2851D3AFE8026893 /* NewMatchingQuizDataFlow.swift in Sources */, 348FD1B42836EEF4AFD23E9F /* NewMatchingQuizView.swift in Sources */, 682816CD6963D970C0C4374E /* SettingsStepFontSizeAssembly.swift in Sources */, + 2CF10C892384243100F8CC95 /* StepSource.swift in Sources */, 21F7D3E517821171331D8B52 /* SettingsStepFontSizeViewController.swift in Sources */, 10B5CCF7E39ED3E7BB8382F6 /* SettingsStepFontSizeInteractor.swift in Sources */, 18E3319B16168E42505378AB /* SettingsStepFontSizePresenter.swift in Sources */, @@ -7039,6 +7104,14 @@ 36D7748CC6CAC2ACEFCEDC82 /* DownloadsDataFlow.swift in Sources */, A40E18B5A40D2E0EFD4F5710 /* DownloadsProvider.swift in Sources */, 46DDCD0E0E352C61FA053980 /* DownloadsView.swift in Sources */, + AC5F57E43EC0844613551EFF /* EditStepAssembly.swift in Sources */, + CEE9851CA09075C21B66447E /* EditStepViewController.swift in Sources */, + 8DE634AD0E06B64B305A3923 /* EditStepInteractor.swift in Sources */, + 81AA1C99009AE54671EA94AA /* EditStepPresenter.swift in Sources */, + A08FD585E04A1E2F34F2EEC0 /* EditStepDataFlow.swift in Sources */, + A103594E8906CF385487A4B3 /* EditStepProvider.swift in Sources */, + 16BBE062FD18B06A7E6E65CD /* EditStepView.swift in Sources */, + 03DD4B3EFF2A6EAD11A9CF0E /* EditStepOutputProtocol.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -7483,6 +7556,7 @@ 08D1EF6E1BB5618700BE84E6 /* Model.xcdatamodeld */ = { isa = XCVersionGroup; children = ( + 2C2EBAB92382E3EB00AB1B83 /* Model_lesson_can_edit.xcdatamodel */, 2CD6E254234DFFD500F49303 /* Model_email_addresses.xcdatamodel */, 2C5E907D2333BE4E00288BE3 /* Model_last_code_language.xcdatamodel */, 2CA1EDAC231FA770003F8576 /* Model_block_remove_animation.xcdatamodel */, @@ -7526,7 +7600,7 @@ 0802AC531C7222B200C4F3E6 /* Model_v2.xcdatamodel */, 08D1EF6F1BB5618700BE84E6 /* Model.xcdatamodel */, ); - currentVersion = 2CD6E254234DFFD500F49303 /* Model_email_addresses.xcdatamodel */; + currentVersion = 2C2EBAB92382E3EB00AB1B83 /* Model_lesson_can_edit.xcdatamodel */; path = Model.xcdatamodeld; sourceTree = ""; versionGroupType = wrapper.xcdatamodel; diff --git a/Stepic/AppDelegate.swift b/Stepic/AppDelegate.swift index fb556bc9d9..b5ebb6ec21 100644 --- a/Stepic/AppDelegate.swift +++ b/Stepic/AppDelegate.swift @@ -50,6 +50,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { SVProgressHUD.setMinimumDismissTimeInterval(0.5) SVProgressHUD.setDefaultMaskType(SVProgressHUDMaskType.clear) + SVProgressHUD.setHapticsEnabled(true) ConnectionHelper.shared.instantiate() self.alamofireRequestsLogger.startIfDebug() diff --git a/Stepic/JSONSerializable.swift b/Stepic/JSONSerializable.swift index 17798ba9e7..719aff5ab8 100644 --- a/Stepic/JSONSerializable.swift +++ b/Stepic/JSONSerializable.swift @@ -10,6 +10,8 @@ import Foundation import PromiseKit import SwiftyJSON +typealias JSONDictionary = [String: Any] + protocol JSONSerializable { associatedtype IdType: Equatable diff --git a/Stepic/Lesson+CoreDataProperties.swift b/Stepic/Lesson+CoreDataProperties.swift index 32770fbac9..df3e6f9064 100644 --- a/Stepic/Lesson+CoreDataProperties.swift +++ b/Stepic/Lesson+CoreDataProperties.swift @@ -22,11 +22,10 @@ extension Lesson { @NSManaged var managedTimeToComplete: NSNumber? @NSManaged var managedVoteDelta: NSNumber? @NSManaged var managedPassedBy: NSNumber? + @NSManaged var managedCanEdit: NSNumber? @NSManaged var managedStepsArray: NSObject? - @NSManaged var managedSteps: NSOrderedSet? - @NSManaged var managedUnit: Unit? static var oldEntity: NSEntityDescription { @@ -38,91 +37,98 @@ extension Lesson { } var id: Int { - set(newId) { - self.managedId = newId as NSNumber? - } get { - return managedId?.intValue ?? -1 + return self.managedId?.intValue ?? -1 + } + set { + self.managedId = newValue as NSNumber? } } var title: String { - set(value) { - self.managedTitle = value - } get { - return managedTitle ?? "No title" + return self.managedTitle ?? "No title" + } + set { + self.managedTitle = newValue } } var slug: String { - set(value) { - self.managedSlug = value - } get { - return managedSlug ?? "" + return self.managedSlug ?? "" + } + set { + self.managedSlug = newValue } } var coverURL: String? { - set(value) { - managedCoverURL = value - } get { - return managedCoverURL + return self.managedCoverURL + } + set { + self.managedCoverURL = newValue } } var isFeatured: Bool { - set(value) { - self.managedFeatured = value as NSNumber? - } get { - return managedFeatured?.boolValue ?? false + return self.managedFeatured?.boolValue ?? false + } + set { + self.managedFeatured = newValue as NSNumber? } } var isPublic: Bool { - set(value) { - self.managedPublic = value as NSNumber? - } get { - return managedPublic?.boolValue ?? false + return self.managedPublic?.boolValue ?? false + } + set { + self.managedPublic = newValue as NSNumber? } } - var stepsArray: [Int] { - set(value) { - self.managedStepsArray = value as NSObject? + var canEdit: Bool { + get { + return self.managedCanEdit?.boolValue ?? false + } + set { + self.managedCanEdit = newValue as NSNumber? } + } + var stepsArray: [IdType] { get { - return (self.managedStepsArray as? [Int]) ?? [] + return (self.managedStepsArray as? [IdType]) ?? [] + } + set { + self.managedStepsArray = newValue as NSObject? } } var steps: [Step] { get { - return (managedSteps?.array as? [Step]) ?? [] + return (self.managedSteps?.array as? [Step]) ?? [] } - - set(value) { - managedSteps = NSOrderedSet(array: value) + set { + self.managedSteps = NSOrderedSet(array: newValue) } } var timeToComplete: Double { get { - return managedTimeToComplete?.doubleValue ?? 0 + return self.managedTimeToComplete?.doubleValue ?? 0 } - set(value) { - managedTimeToComplete = value as NSNumber? + set { + self.managedTimeToComplete = newValue as NSNumber? } } var voteDelta: Int { get { - return managedVoteDelta?.intValue ?? 0 + return self.managedVoteDelta?.intValue ?? 0 } set { self.managedVoteDelta = newValue as NSNumber? @@ -139,6 +145,6 @@ extension Lesson { } var unit: Unit? { - return managedUnit + return self.managedUnit } } diff --git a/Stepic/Lesson.swift b/Stepic/Lesson.swift index f0eed3163c..5f60688321 100644 --- a/Stepic/Lesson.swift +++ b/Stepic/Lesson.swift @@ -13,41 +13,44 @@ import SwiftyJSON final class Lesson: NSManagedObject, IDFetchable { typealias IdType = Int + var isCached: Bool { + if self.steps.isEmpty { + return false + } + + for video in self.getVideos() { + if video.state != .cached { + return false + } + } + + return true + } + required convenience init(json: JSON) { self.init() - initialize(json) + self.initialize(json) } func initialize(_ json: JSON) { - id = json["id"].intValue - title = json["title"].stringValue - isFeatured = json["is_featured"].boolValue - isPublic = json["is_public"].boolValue - slug = json["slug"].stringValue - coverURL = json["cover_url"].string - timeToComplete = json["time_to_complete"].doubleValue - stepsArray = json["steps"].arrayObject as! [Int] - passedBy = json["passed_by"].intValue - voteDelta = json["vote_delta"].intValue - } - - static func getLesson(_ id: Int) -> Lesson? { - let request = NSFetchRequest(entityName: "Lesson") - - let predicate = NSPredicate(format: "managedId== %@", id as NSNumber) - - request.predicate = predicate - - do { - let results = try CoreDataHelper.instance.context.fetch(request) - return (results as? [Lesson])?.first - } catch { - return nil + self.id = json[JSONKey.id.rawValue].intValue + self.title = json[JSONKey.title.rawValue].stringValue + self.isFeatured = json[JSONKey.isFeatured.rawValue].boolValue + self.isPublic = json[JSONKey.isPublic.rawValue].boolValue + self.slug = json[JSONKey.slug.rawValue].stringValue + self.coverURL = json[JSONKey.coverURL.rawValue].string + self.timeToComplete = json[JSONKey.timeToComplete.rawValue].doubleValue + self.stepsArray = json[JSONKey.steps.rawValue].arrayObject as! [Int] + self.passedBy = json[JSONKey.passedBy.rawValue].intValue + self.voteDelta = json[JSONKey.voteDelta.rawValue].intValue + + if let actionsDictionary = json[JSONKey.actions.rawValue].dictionary { + self.canEdit = actionsDictionary[JSONKey.editLesson.rawValue]?.stringValue == JSONKey.actionStatusGranted } } func update(json: JSON) { - initialize(json) + self.initialize(json) } func loadSteps(completion: @escaping (() -> Void), error errorHandler: ((String) -> Void)? = nil, onlyLesson: Bool = false) { @@ -133,27 +136,24 @@ final class Lesson: NSManagedObject, IDFetchable { return videos } - var isCached: Bool { - if self.steps.isEmpty { - return false - } + static func getLesson(_ id: IdType) -> Lesson? { + let request = NSFetchRequest(entityName: "Lesson") + request.predicate = NSPredicate(format: "managedId== %@", id as NSNumber) - for video in self.getVideos() { - if video.state != .cached { - return false - } + do { + let results = try CoreDataHelper.instance.context.fetch(request) + return (results as? [Lesson])?.first + } catch { + return nil } - - return true } - static func fetch(_ ids: [Int]) -> [Lesson] { + static func fetch(_ ids: [IdType]) -> [Lesson] { let request = NSFetchRequest(entityName: "Lesson") - let idPredicates = ids.map { - NSPredicate(format: "managedId == %@", $0 as NSNumber) - } + let idPredicates = ids.map { NSPredicate(format: "managedId == %@", $0 as NSNumber) } request.predicate = NSCompoundPredicate(type: NSCompoundPredicate.LogicalType.or, subpredicates: idPredicates) + do { guard let results = try CoreDataHelper.instance.context.fetch(request) as? [Lesson] else { return [] @@ -163,4 +163,23 @@ final class Lesson: NSManagedObject, IDFetchable { return [] } } + + // MARK: - Types + + enum JSONKey: String { + static let actionStatusGranted = "#" + + case id + case title + case isFeatured = "is_featured" + case isPublic = "is_public" + case slug + case coverURL = "cover_url" + case timeToComplete = "time_to_complete" + case steps + case passedBy = "passed_by" + case voteDelta = "vote_delta" + case actions + case editLesson = "edit_lesson" + } } diff --git a/Stepic/Model.xcdatamodeld/.xccurrentversion b/Stepic/Model.xcdatamodeld/.xccurrentversion index d450ec49dc..06516c397b 100644 --- a/Stepic/Model.xcdatamodeld/.xccurrentversion +++ b/Stepic/Model.xcdatamodeld/.xccurrentversion @@ -3,6 +3,6 @@ _XCCurrentVersionName - Model_email_addresses.xcdatamodel + Model_lesson_can_edit.xcdatamodel diff --git a/Stepic/Model.xcdatamodeld/Model_lesson_can_edit.xcdatamodel/contents b/Stepic/Model.xcdatamodeld/Model_lesson_can_edit.xcdatamodel/contents new file mode 100644 index 0000000000..26f24894c4 --- /dev/null +++ b/Stepic/Model.xcdatamodeld/Model_lesson_can_edit.xcdatamodel/contents @@ -0,0 +1,299 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Stepic/Sources/Modules/EditStep/EditStepAssembly.swift b/Stepic/Sources/Modules/EditStep/EditStepAssembly.swift new file mode 100644 index 0000000000..d51b2ac7b6 --- /dev/null +++ b/Stepic/Sources/Modules/EditStep/EditStepAssembly.swift @@ -0,0 +1,25 @@ +import UIKit + +final class EditStepAssembly: Assembly { + private let stepID: Step.IdType + private weak var moduleOutput: EditStepOutputProtocol? + + init(stepID: Step.IdType, output: EditStepOutputProtocol? = nil) { + self.stepID = stepID + self.moduleOutput = output + } + + func makeModule() -> UIViewController { + let provider = EditStepProvider( + stepSourcesNetworkService: StepSourcesNetworkService(stepSourcesAPI: StepSourcesAPI()) + ) + let presenter = EditStepPresenter() + let interactor = EditStepInteractor(stepID: self.stepID, presenter: presenter, provider: provider) + let viewController = EditStepViewController(interactor: interactor) + + presenter.viewController = viewController + interactor.moduleOutput = self.moduleOutput + + return viewController + } +} diff --git a/Stepic/Sources/Modules/EditStep/EditStepDataFlow.swift b/Stepic/Sources/Modules/EditStep/EditStepDataFlow.swift new file mode 100644 index 0000000000..f4c07adbeb --- /dev/null +++ b/Stepic/Sources/Modules/EditStep/EditStepDataFlow.swift @@ -0,0 +1,62 @@ +import Foundation + +enum EditStep { + // MARK: Common structs + + struct StepSourceData { + let originalText: String + let currentText: String + } + + // MARK: - Use cases - + + /// Load step source content + enum LoadStepSource { + struct Request { } + + struct Response { + let data: Result + } + + struct ViewModel { + let state: ViewControllerState + } + } + + /// Handle user input changes + enum UpdateStepText { + struct Request { + let text: String + } + + struct Response { + let data: StepSourceData + } + + struct ViewModel { + let viewModel: EditStepViewModel + } + } + + /// Try to update remote step source via API + enum RemoteStepSourceUpdate { + struct Request { } + + struct Response { + let isSuccessful: Bool + } + + struct ViewModel { + let isSuccessful: Bool + let feedback: String + } + } + + // MARK: - States + + enum ViewControllerState { + case loading + case error + case result(data: EditStepViewModel) + } +} diff --git a/Stepic/Sources/Modules/EditStep/EditStepInteractor.swift b/Stepic/Sources/Modules/EditStep/EditStepInteractor.swift new file mode 100644 index 0000000000..6b3b11691f --- /dev/null +++ b/Stepic/Sources/Modules/EditStep/EditStepInteractor.swift @@ -0,0 +1,107 @@ +import Foundation +import Logging +import PromiseKit + +protocol EditStepInteractorProtocol { + func doStepSourceLoad(request: EditStep.LoadStepSource.Request) + func doStepSourceTextUpdate(request: EditStep.UpdateStepText.Request) + func doRemoteStepSourceUpdate(request: EditStep.RemoteStepSourceUpdate.Request) +} + +// MARK: - EditStepInteractor: EditStepInteractorProtocol - + +final class EditStepInteractor: EditStepInteractorProtocol { + private static let logger = Logger(label: "com.AlexKarpov.Stepic.WriteCommentInteractor") + + weak var moduleOutput: EditStepOutputProtocol? + + private let stepID: Step.IdType + private let presenter: EditStepPresenterProtocol + private let provider: EditStepProviderProtocol + + private var currentStepSource: StepSource? + private var currentText: String = "" + + init( + stepID: Step.IdType, + presenter: EditStepPresenterProtocol, + provider: EditStepProviderProtocol + ) { + self.stepID = stepID + self.presenter = presenter + self.provider = provider + } + + // MARK: EditStepInteractorProtocol + + func doStepSourceLoad(request: EditStep.LoadStepSource.Request) { + EditStepInteractor.logger.info("edit step interactor :: start fetching step source = \(self.stepID)") + + self.provider.fetchStepSource(stepID: self.stepID).done { stepSource in + guard let stepSource = stepSource else { + EditStepInteractor.logger.error( + "edit step interactor :: error while fetching step source, no step source returned" + ) + return self.presenter.presentStepSource(response: .init(data: .failure(Error.noStepSource))) + } + + EditStepInteractor.logger.info("edit step interactor :: finish fetching step source") + + self.currentStepSource = stepSource + self.currentText = stepSource.text + + self.presenter.presentStepSource(response: .init(data: .success(self.makeStepSourceDataFromCurrentData()))) + }.catch { error in + EditStepInteractor.logger.error("edit step interactor :: error while fetching step source, error \(error)") + self.presenter.presentStepSource(response: .init(data: .failure(error))) + } + } + + func doStepSourceTextUpdate(request: EditStep.UpdateStepText.Request) { + self.currentText = request.text + self.presenter.presentStepSourceTextUpdate(response: .init(data: self.makeStepSourceDataFromCurrentData())) + } + + func doRemoteStepSourceUpdate(request: EditStep.RemoteStepSourceUpdate.Request) { + guard let currentStepSource = self.currentStepSource else { + EditStepInteractor.logger.info("edit step interactor :: error while updating step source, no step source") + return self.presenter.presentStepSourceEditResult(response: .init(isSuccessful: false)) + } + + let updatingStepSource = StepSource(stepSource: currentStepSource) + updatingStepSource.text = self.currentText + + EditStepInteractor.logger.info("edit step interactor :: start updating step source = \(updatingStepSource.id)") + + self.provider.updateStepSource(updatingStepSource).done { stepSource in + EditStepInteractor.logger.info("edit step interactor :: finish updating step source = \(stepSource.id)") + + self.currentStepSource = stepSource + self.currentText = stepSource.text + + self.presenter.presentStepSourceEditResult(response: .init(isSuccessful: true)) + + DispatchQueue.main.async { + self.moduleOutput?.handleStepSourceUpdated(stepSource) + } + }.catch { error in + EditStepInteractor.logger.error("edit step interactor :: error while updating step source, error \(error)") + self.presenter.presentStepSourceEditResult(response: .init(isSuccessful: false)) + } + } + + // MARK: Private API + + private func makeStepSourceDataFromCurrentData() -> EditStep.StepSourceData { + return .init( + originalText: self.currentStepSource?.text ?? "", + currentText: self.currentText + ) + } + + // MARK: - Types + + enum Error: Swift.Error { + case noStepSource + } +} diff --git a/Stepic/Sources/Modules/EditStep/EditStepPresenter.swift b/Stepic/Sources/Modules/EditStep/EditStepPresenter.swift new file mode 100644 index 0000000000..222c35a0c9 --- /dev/null +++ b/Stepic/Sources/Modules/EditStep/EditStepPresenter.swift @@ -0,0 +1,50 @@ +import UIKit + +protocol EditStepPresenterProtocol { + func presentStepSource(response: EditStep.LoadStepSource.Response) + func presentStepSourceTextUpdate(response: EditStep.UpdateStepText.Response) + func presentStepSourceEditResult(response: EditStep.RemoteStepSourceUpdate.Response) +} + +// MARK: - EditStepPresenter: EditStepPresenterProtocol - + +final class EditStepPresenter: EditStepPresenterProtocol { + weak var viewController: EditStepViewControllerProtocol? + + func presentStepSource(response: EditStep.LoadStepSource.Response) { + switch response.data { + case .success(let data): + let viewModel = self.makeViewModel(currentText: data.currentText, originalText: data.originalText) + self.viewController?.displayStepSource(viewModel: .init(state: .result(data: viewModel))) + case .failure: + self.viewController?.displayStepSource(viewModel: .init(state: .error)) + } + } + + func presentStepSourceTextUpdate(response: EditStep.UpdateStepText.Response) { + let viewModel = self.makeViewModel( + currentText: response.data.currentText, + originalText: response.data.originalText + ) + self.viewController?.displayStepSourceTextUpdate(viewModel: .init(viewModel: viewModel)) + } + + func presentStepSourceEditResult(response: EditStep.RemoteStepSourceUpdate.Response) { + let feedback = response.isSuccessful + ? NSLocalizedString("EditStepRemoteUpdateSuccessfulTitle", comment: "") + : NSLocalizedString("EditStepRemoteUpdateUnsuccessfulTitle", comment: "") + + self.viewController?.displayStepSourceEditResult( + viewModel: .init(isSuccessful: response.isSuccessful, feedback: feedback) + ) + } + + // MARK: Private API + + private func makeViewModel(currentText: String, originalText: String) -> EditStepViewModel { + return EditStepViewModel( + text: currentText, + isFilled: currentText != originalText + ) + } +} diff --git a/Stepic/Sources/Modules/EditStep/EditStepProvider.swift b/Stepic/Sources/Modules/EditStep/EditStepProvider.swift new file mode 100644 index 0000000000..952ce66860 --- /dev/null +++ b/Stepic/Sources/Modules/EditStep/EditStepProvider.swift @@ -0,0 +1,44 @@ +import Foundation +import PromiseKit + +protocol EditStepProviderProtocol { + func fetchStepSource(stepID: Step.IdType) -> Promise + func updateStepSource(_ stepSource: StepSource) -> Promise +} + +// MARK: - EditStepProvider: EditStepProviderProtocol - + +final class EditStepProvider: EditStepProviderProtocol { + private let stepSourcesNetworkService: StepSourcesNetworkService + + init(stepSourcesNetworkService: StepSourcesNetworkService) { + self.stepSourcesNetworkService = stepSourcesNetworkService + } + + func fetchStepSource(stepID: Step.IdType) -> Promise { + return Promise { seal in + self.stepSourcesNetworkService.fetch(ids: [stepID]).done { stepSources, _ in + seal.fulfill(stepSources.first) + }.catch { _ in + seal.reject(Error.networkFetchFailed) + } + } + } + + func updateStepSource(_ stepSource: StepSource) -> Promise { + return Promise { seal in + self.stepSourcesNetworkService.update(stepSource: stepSource).done { stepSource in + seal.fulfill(stepSource) + }.catch { _ in + seal.reject(Error.networkUpdateFailed) + } + } + } + + // MARK: Types + + enum Error: Swift.Error { + case networkFetchFailed + case networkUpdateFailed + } +} diff --git a/Stepic/Sources/Modules/EditStep/EditStepView.swift b/Stepic/Sources/Modules/EditStep/EditStepView.swift new file mode 100644 index 0000000000..793a85b354 --- /dev/null +++ b/Stepic/Sources/Modules/EditStep/EditStepView.swift @@ -0,0 +1,180 @@ +import SnapKit +import UIKit + +// MARK: Appearance - + +extension EditStepView { + struct Appearance { + let backgroundColor = UIColor.white + + let loadingIndicatorColor = UIColor.mainDark + + let messageFont = UIFont.systemFont(ofSize: 12) + let messageTextColor = UIColor(hex: 0x8E8E93) + let messageLabelInsets = LayoutInsets(top: 16, left: 16, right: 16) + + let separatorInsets = LayoutInsets(top: 8) + + let textViewInsets = LayoutInsets(top: 16) + let textViewTextInsets = UIEdgeInsets(top: 0, left: 16, bottom: 16, right: 16) + let textViewFont = UIFont.systemFont(ofSize: 16) + let textViewTextColor = UIColor.mainDark + let textViewPlaceholderColor = UIColor.mainDark.withAlphaComponent(0.4) + } +} + +// MARK: - EditStepViewDelegate: class - + +protocol EditStepViewDelegate: class { + func editStepView(_ view: EditStepView, didChangeText text: String) +} + +// MARK: - EditStepView: UIView - + +final class EditStepView: UIView { + let appearance: Appearance + + weak var delegate: EditStepViewDelegate? + + private lazy var messageLabel: UILabel = { + let label = UILabel() + label.text = NSLocalizedString("EditStepMessage", comment: "") + label.font = self.appearance.messageFont + label.textColor = self.appearance.messageTextColor + label.numberOfLines = 0 + return label + }() + + private lazy var separatorView = SeparatorView() + + private lazy var textView: TableInputTextView = { + let textView = TableInputTextView() + textView.font = self.appearance.textViewFont + textView.textColor = self.appearance.textViewTextColor + textView.placeholderColor = self.appearance.textViewPlaceholderColor + textView.placeholder = NSLocalizedString("EditStepPlaceholder", comment: "") + textView.textInsets = self.appearance.textViewTextInsets + // Enable scrolling + textView.isScrollEnabled = true + textView.isUserInteractionEnabled = true + // Disable features + textView.autocapitalizationType = .none + textView.autocorrectionType = .no + textView.spellCheckingType = .no + textView.dataDetectorTypes = [] + + textView.delegate = self + + return textView + }() + + private lazy var loadingIndicator: UIActivityIndicatorView = { + let loadingIndicatorView = UIActivityIndicatorView(style: .whiteLarge) + loadingIndicatorView.color = self.appearance.loadingIndicatorColor + loadingIndicatorView.hidesWhenStopped = true + loadingIndicatorView.startAnimating() + return loadingIndicatorView + }() + + var text: String? { + didSet { + self.textView.text = self.text + } + } + + var isEnabled: Bool = true { + didSet { + self.textView.isEditable = self.isEnabled + self.textView.isSelectable = self.isEnabled + } + } + + 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") + } + + // MARK: Public API + + func showLoading() { + self.loadingIndicator.startAnimating() + self.setStepContentViewsHidden(true) + } + + func hideLoading() { + self.loadingIndicator.stopAnimating() + self.setStepContentViewsHidden(false) + } + + // MARK: Private API + + private func setStepContentViewsHidden(_ isHidden: Bool) { + for view in self.subviews where view !== self.loadingIndicator { + view.isHidden = isHidden + } + } +} + +// MARK: - EditStepView: ProgrammaticallyInitializableViewProtocol - + +extension EditStepView: ProgrammaticallyInitializableViewProtocol { + func setupView() { + self.backgroundColor = self.appearance.backgroundColor + } + + func addSubviews() { + self.addSubview(self.messageLabel) + self.addSubview(self.separatorView) + self.addSubview(self.textView) + self.addSubview(self.loadingIndicator) + } + + func makeConstraints() { + self.messageLabel.translatesAutoresizingMaskIntoConstraints = false + self.messageLabel.snp.makeConstraints { make in + make.leading.equalTo(self.safeAreaLayoutGuide.snp.leading).offset(self.appearance.messageLabelInsets.left) + make.top.equalToSuperview().offset(self.appearance.messageLabelInsets.top) + make.trailing.equalToSuperview().offset(-self.appearance.messageLabelInsets.right) + } + + self.separatorView.translatesAutoresizingMaskIntoConstraints = false + self.separatorView.snp.makeConstraints { make in + make.leading.equalTo(self.messageLabel.snp.leading) + make.top.equalTo(self.messageLabel.snp.bottom).offset(self.appearance.separatorInsets.top) + make.trailing.equalToSuperview() + } + + self.textView.translatesAutoresizingMaskIntoConstraints = false + self.textView.snp.makeConstraints { make in + make.leading.equalTo(self.safeAreaLayoutGuide.snp.leading) + make.top.equalTo(self.separatorView.snp.bottom).offset(self.appearance.textViewInsets.top) + make.trailing.equalTo(self.safeAreaLayoutGuide.snp.trailing) + make.bottom.equalTo(self.safeAreaLayoutGuide.snp.bottom) + } + + self.loadingIndicator.translatesAutoresizingMaskIntoConstraints = false + self.loadingIndicator.snp.makeConstraints { make in + make.center.equalToSuperview() + } + } +} + +// MARK: - EditStepView: UITextViewDelegate - + +extension EditStepView: UITextViewDelegate { + func textViewDidChange(_ textView: UITextView) { + self.delegate?.editStepView(self, didChangeText: textView.text) + } +} diff --git a/Stepic/Sources/Modules/EditStep/EditStepViewController.swift b/Stepic/Sources/Modules/EditStep/EditStepViewController.swift new file mode 100644 index 0000000000..55c232c99f --- /dev/null +++ b/Stepic/Sources/Modules/EditStep/EditStepViewController.swift @@ -0,0 +1,174 @@ +import SVProgressHUD +import UIKit + +// MARK: EditStepViewControllerProtocol - + +protocol EditStepViewControllerProtocol: class { + func displayStepSource(viewModel: EditStep.LoadStepSource.ViewModel) + func displayStepSourceTextUpdate(viewModel: EditStep.UpdateStepText.ViewModel) + func displayStepSourceEditResult(viewModel: EditStep.RemoteStepSourceUpdate.ViewModel) +} + +// MARK: - EditStepViewController: UIViewController, ControllerWithStepikPlaceholder - + +final class EditStepViewController: UIViewController, ControllerWithStepikPlaceholder { + lazy var editStepView = self.view as? EditStepView + + var placeholderContainer = StepikPlaceholderControllerContainer() + + private let interactor: EditStepInteractorProtocol + private var state: EditStep.ViewControllerState + + private lazy var cancelBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .cancel, + target: self, + action: #selector(self.cancelButtonDidClick(_:)) + ) + + private lazy var doneBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .done, + target: self, + action: #selector(self.doneButtonDidClick(_:)) + ) + + private lazy var activityIndicatorBarButtonItem: UIBarButtonItem = { + let activityIndicator = UIActivityIndicatorView(style: .white) + activityIndicator.color = .mainDark + activityIndicator.startAnimating() + return UIBarButtonItem(customView: activityIndicator) + }() + + init( + interactor: EditStepInteractorProtocol, + initialState: EditStep.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") + } + + // MARK: UIViewController life cycle + + override func loadView() { + let view = EditStepView(frame: UIScreen.main.bounds) + view.delegate = self + self.view = view + } + + override func viewDidLoad() { + super.viewDidLoad() + + self.title = NSLocalizedString("EditStepTitle", comment: "") + self.edgesForExtendedLayout = [] + + self.navigationItem.leftBarButtonItem = self.cancelBarButtonItem + self.navigationItem.rightBarButtonItem = self.doneBarButtonItem + self.doneBarButtonItem.isEnabled = false + + self.registerPlaceholders() + + self.updateState(newState: self.state) + self.interactor.doStepSourceLoad(request: .init()) + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + self.view.endEditing(true) + } + + // MARK: Private API + + private func registerPlaceholders() { + self.registerPlaceholder( + placeholder: StepikPlaceholder( + .noConnection, + action: { [weak self] in + guard let strongSelf = self else { + return + } + + strongSelf.updateState(newState: .loading) + strongSelf.interactor.doStepSourceLoad(request: .init()) + } + ), + for: .connectionError + ) + } + + private func updateState(newState: EditStep.ViewControllerState) { + self.state = newState + + switch newState { + case .result(let viewModel): + self.editStepView?.hideLoading() + self.isPlaceholderShown = false + self.updateView(newViewModel: viewModel) + case .loading: + self.editStepView?.showLoading() + self.isPlaceholderShown = false + case .error: + self.showPlaceholder(for: .connectionError) + } + } + + // MARK: Actions + + @objc + private func cancelButtonDidClick(_ sender: UIBarButtonItem) { + self.dismiss(animated: true) + } + + @objc + private func doneButtonDidClick(_ sender: UIBarButtonItem) { + self.view.endEditing(true) + self.editStepView?.isEnabled = false + self.navigationItem.rightBarButtonItem = self.activityIndicatorBarButtonItem + + self.interactor.doRemoteStepSourceUpdate(request: .init()) + } +} + +// MARK: - EditStepViewController: EditStepViewControllerProtocol - + +extension EditStepViewController: EditStepViewControllerProtocol { + func displayStepSource(viewModel: EditStep.LoadStepSource.ViewModel) { + self.updateState(newState: viewModel.state) + } + + func displayStepSourceTextUpdate(viewModel: EditStep.UpdateStepText.ViewModel) { + self.updateView(newViewModel: viewModel.viewModel) + } + + func displayStepSourceEditResult(viewModel: EditStep.RemoteStepSourceUpdate.ViewModel) { + if viewModel.isSuccessful { + SVProgressHUD.showSuccess(withStatus: viewModel.feedback) + self.navigationItem.rightBarButtonItem = nil + + self.dismiss(animated: true) + } else { + SVProgressHUD.showError(withStatus: viewModel.feedback) + self.editStepView?.isEnabled = true + self.navigationItem.rightBarButtonItem = self.doneBarButtonItem + } + } + + // MARK: Private helpers + + private func updateView(newViewModel: EditStepViewModel) { + self.editStepView?.text = newViewModel.text + self.doneBarButtonItem.isEnabled = newViewModel.isFilled + } +} + +// MARK: - EditStepViewController: EditStepViewDelegate - + +extension EditStepViewController: EditStepViewDelegate { + func editStepView(_ view: EditStepView, didChangeText text: String) { + self.interactor.doStepSourceTextUpdate(request: .init(text: text)) + } +} diff --git a/Stepic/Sources/Modules/EditStep/EditStepViewModel.swift b/Stepic/Sources/Modules/EditStep/EditStepViewModel.swift new file mode 100644 index 0000000000..eb9728c51b --- /dev/null +++ b/Stepic/Sources/Modules/EditStep/EditStepViewModel.swift @@ -0,0 +1,6 @@ +import Foundation + +struct EditStepViewModel { + let text: String + let isFilled: Bool +} diff --git a/Stepic/Sources/Modules/EditStep/InputOutput/EditStepOutputProtocol.swift b/Stepic/Sources/Modules/EditStep/InputOutput/EditStepOutputProtocol.swift new file mode 100644 index 0000000000..7cc0f115bc --- /dev/null +++ b/Stepic/Sources/Modules/EditStep/InputOutput/EditStepOutputProtocol.swift @@ -0,0 +1,5 @@ +import Foundation + +protocol EditStepOutputProtocol: class { + func handleStepSourceUpdated(_ stepSource: StepSource) +} diff --git a/Stepic/Sources/Modules/NewLesson/NewLessonDataFlow.swift b/Stepic/Sources/Modules/NewLesson/NewLessonDataFlow.swift index 14dde1843b..92a014b5e1 100644 --- a/Stepic/Sources/Modules/NewLesson/NewLessonDataFlow.swift +++ b/Stepic/Sources/Modules/NewLesson/NewLessonDataFlow.swift @@ -12,6 +12,7 @@ enum NewLesson { let steps: [Step] let progresses: [Progress] let startStepIndex: Int + let canEdit: Bool } struct Response { @@ -86,6 +87,34 @@ enum NewLesson { } } + /// Edit current step text + enum EditStepPresentation { + struct Request { + let index: Int + } + + struct Response { + let stepID: Step.IdType + } + + struct ViewModel { + let stepID: Step.IdType + } + } + + /// Load new step HTML text (after step source updated) + enum StepTextUpdate { + struct Response { + let index: Int + let stepSource: StepSource + } + + struct ViewModel { + let index: Int + let text: String + } + } + /// Handle HUD enum BlockingWaitingIndicatorUpdate { struct Response { diff --git a/Stepic/Sources/Modules/NewLesson/NewLessonInteractor.swift b/Stepic/Sources/Modules/NewLesson/NewLessonInteractor.swift index f8cc67bb45..305adb02a5 100644 --- a/Stepic/Sources/Modules/NewLesson/NewLessonInteractor.swift +++ b/Stepic/Sources/Modules/NewLesson/NewLessonInteractor.swift @@ -3,6 +3,7 @@ import PromiseKit protocol NewLessonInteractorProtocol { func doLessonLoad(request: NewLesson.LessonLoad.Request) + func doEditStepPresentation(request: NewLesson.EditStepPresentation.Request) } final class NewLessonInteractor: NewLessonInteractorProtocol { @@ -48,6 +49,15 @@ final class NewLessonInteractor: NewLessonInteractorProtocol { self.refresh(context: self.lastLoadState.context, startStep: self.lastLoadState.startStep) } + func doEditStepPresentation(request: NewLesson.EditStepPresentation.Request) { + guard let lesson = self.currentLesson, + let stepID = lesson.stepsArray[safe: request.index] else { + return + } + + self.presenter.presentEditStep(response: .init(stepID: stepID)) + } + // MARK: Private API private func refresh(context: NewLesson.Context, startStep: NewLesson.StartStep? = nil) { @@ -122,7 +132,8 @@ final class NewLessonInteractor: NewLessonInteractorProtocol { lesson: lesson, steps: steps, progresses: progresses, - startStepIndex: startStepIndex + startStepIndex: startStepIndex, + canEdit: lesson.canEdit ) self.presenter.presentLesson(response: .init(state: .success(data))) @@ -195,6 +206,8 @@ final class NewLessonInteractor: NewLessonInteractorProtocol { } } +// MARK: - NewLessonInteractor: NewStepOutputProtocol - + extension NewLessonInteractor: NewStepOutputProtocol { func handlePreviousUnitNavigation() { guard let unit = self.previousUnit else { @@ -246,3 +259,16 @@ extension NewLessonInteractor: NewStepOutputProtocol { self.presenter.presentCurrentStepUpdate(response: .init(index: index)) } } + +// MARK: - NewLessonInteractor: EditStepOutputProtocol - + +extension NewLessonInteractor: EditStepOutputProtocol { + func handleStepSourceUpdated(_ stepSource: StepSource) { + guard let lesson = self.currentLesson, + let stepIndex = lesson.stepsArray.firstIndex(where: { $0 == stepSource.id }) else { + return + } + + self.presenter.presentStepTextUpdate(response: .init(index: stepIndex, stepSource: stepSource)) + } +} diff --git a/Stepic/Sources/Modules/NewLesson/NewLessonPresenter.swift b/Stepic/Sources/Modules/NewLesson/NewLessonPresenter.swift index 5387cbe869..0fe902a53f 100644 --- a/Stepic/Sources/Modules/NewLesson/NewLessonPresenter.swift +++ b/Stepic/Sources/Modules/NewLesson/NewLessonPresenter.swift @@ -7,6 +7,8 @@ protocol NewLessonPresenterProtocol { func presentStepTooltipInfoUpdate(response: NewLesson.StepTooltipInfoUpdate.Response) func presentStepPassedStatusUpdate(response: NewLesson.StepPassedStatusUpdate.Response) func presentCurrentStepUpdate(response: NewLesson.CurrentStepUpdate.Response) + func presentEditStep(response: NewLesson.EditStepPresentation.Response) + func presentStepTextUpdate(response: NewLesson.StepTextUpdate.Response) func presentWaitingState(response: NewLesson.BlockingWaitingIndicatorUpdate.Response) } @@ -26,7 +28,8 @@ final class NewLessonPresenter: NewLessonPresenterProtocol { lesson: result.lesson, steps: result.steps, progresses: result.progresses, - startStepIndex: result.startStepIndex + startStepIndex: result.startStepIndex, + canEdit: result.canEdit ) ) ) @@ -70,6 +73,16 @@ final class NewLessonPresenter: NewLessonPresenterProtocol { self.viewController?.displayCurrentStepUpdate(viewModel: .init(index: response.index)) } + func presentStepTextUpdate(response: NewLesson.StepTextUpdate.Response) { + self.viewController?.displayStepTextUpdate( + viewModel: .init(index: response.index, text: response.stepSource.text) + ) + } + + func presentEditStep(response: NewLesson.EditStepPresentation.Response) { + self.viewController?.displayEditStep(viewModel: .init(stepID: response.stepID)) + } + func presentWaitingState(response: NewLesson.BlockingWaitingIndicatorUpdate.Response) { self.viewController?.displayBlockingLoadingIndicator(viewModel: .init(shouldDismiss: response.shouldDismiss)) } @@ -80,7 +93,8 @@ final class NewLessonPresenter: NewLessonPresenterProtocol { lesson: Lesson, steps: [Step], progresses: [Progress], - startStepIndex: Int + startStepIndex: Int, + canEdit: Bool ) -> NewLessonViewModel { let lessonTitle = lesson.title let steps: [NewLessonViewModel.StepDescription] = steps.enumerated().map { index, step in @@ -108,7 +122,8 @@ final class NewLessonPresenter: NewLessonPresenterProtocol { stepLinkMaker: { "\(StepicApplicationsInfo.stepicURL)/lesson/\(lesson.id)/step/\($0)?from_mobile_app=true" }, - startStepIndex: startStepIndex + startStepIndex: startStepIndex, + canEdit: canEdit ) } diff --git a/Stepic/Sources/Modules/NewLesson/NewLessonViewController.swift b/Stepic/Sources/Modules/NewLesson/NewLessonViewController.swift index ce7e1ed88f..2e90860312 100644 --- a/Stepic/Sources/Modules/NewLesson/NewLessonViewController.swift +++ b/Stepic/Sources/Modules/NewLesson/NewLessonViewController.swift @@ -5,6 +5,8 @@ import SVProgressHUD import Tabman import UIKit +// MARK: NewLessonViewControllerProtocol: class - + protocol NewLessonViewControllerProtocol: class { func displayLesson(viewModel: NewLesson.LessonLoad.ViewModel) func displayLessonNavigation(viewModel: NewLesson.LessonNavigationLoad.ViewModel) @@ -12,9 +14,13 @@ protocol NewLessonViewControllerProtocol: class { func displayStepTooltipInfoUpdate(viewModel: NewLesson.StepTooltipInfoUpdate.ViewModel) func displayStepPassedStatusUpdate(viewModel: NewLesson.StepPassedStatusUpdate.ViewModel) func displayCurrentStepUpdate(viewModel: NewLesson.CurrentStepUpdate.ViewModel) + func displayEditStep(viewModel: NewLesson.EditStepPresentation.ViewModel) + func displayStepTextUpdate(viewModel: NewLesson.StepTextUpdate.ViewModel) func displayBlockingLoadingIndicator(viewModel: NewLesson.BlockingWaitingIndicatorUpdate.ViewModel) } +// MARK: - NewLessonViewController: TabmanViewController, ControllerWithStepikPlaceholder - + final class NewLessonViewController: TabmanViewController, ControllerWithStepikPlaceholder { private static let animationDuration: TimeInterval = 0.25 @@ -51,6 +57,16 @@ final class NewLessonViewController: TabmanViewController, ControllerWithStepikP return item }() + private lazy var moreBarButtonItem = UIBarButtonItem( + image: UIImage(named: "horizontal-dots-icon")?.withRenderingMode(.alwaysTemplate), + style: .plain, + target: self, + action: #selector(self.moreButtonClicked) + ) + + private lazy var studentRightBarButtonItems = [self.shareBarButtonItem, self.infoBarButtonItem] + private lazy var teacherRightBarButtonItems = [self.moreBarButtonItem, self.infoBarButtonItem] + private lazy var loadingIndicator: UIActivityIndicatorView = { let loadingIndicatorView = UIActivityIndicatorView(style: .whiteLarge) loadingIndicatorView.color = Appearance.loadingIndicatorColor @@ -116,6 +132,8 @@ final class NewLessonViewController: TabmanViewController, ControllerWithStepikP fatalError("init(coder:) has not been implemented") } + // MARK: UIViewController life cycle + override func viewDidLoad() { super.viewDidLoad() @@ -138,8 +156,6 @@ final class NewLessonViewController: TabmanViewController, ControllerWithStepikP } self.addSubviews() - - self.navigationItem.rightBarButtonItems = [self.shareBarButtonItem, self.infoBarButtonItem] self.dataSource = self self.updateState() @@ -205,6 +221,10 @@ final class NewLessonViewController: TabmanViewController, ControllerWithStepikP self.stepControllers = Array(repeating: nil, count: data.steps.count) self.stepModulesInputs = Array(repeating: nil, count: data.steps.count) + self.navigationItem.rightBarButtonItems = data.canEdit + ? self.teacherRightBarButtonItems + : self.studentRightBarButtonItems + if let styledNavigationController = self.navigationController as? StyledNavigationController { styledNavigationController.changeShadowViewAlpha(0.0, sender: self) } @@ -359,6 +379,38 @@ final class NewLessonViewController: TabmanViewController, ControllerWithStepikP } } } + + @objc + private func moreButtonClicked() { + let alert = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) + alert.addAction( + UIAlertAction( + title: NSLocalizedString("Share", comment: ""), + style: .default, + handler: { [weak self] _ in + self?.shareButtonClicked() + } + ) + ) + alert.addAction( + UIAlertAction( + title: NSLocalizedString("EditStepAlertActionTitle", comment: ""), + style: .default, + handler: { [weak self] _ in + guard let strongSelf = self, + let currentIndex = strongSelf.currentIndex else { + return + } + + strongSelf.interactor.doEditStepPresentation(request: .init(index: currentIndex)) + } + ) + ) + alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: ""), style: .cancel)) + alert.popoverPresentationController?.barButtonItem = self.moreBarButtonItem + + self.present(module: alert) + } } extension NewLessonViewController: PageboyViewControllerDataSource { @@ -408,6 +460,8 @@ extension NewLessonViewController: TMBarDataSource { } } +// MARK: - NewLessonViewController: NewLessonViewControllerProtocol - + extension NewLessonViewController: NewLessonViewControllerProtocol { func displayLesson(viewModel: NewLesson.LessonLoad.ViewModel) { self.state = viewModel.state @@ -447,6 +501,23 @@ extension NewLessonViewController: NewLessonViewControllerProtocol { self.scrollToPage(.at(index: viewModel.index), animated: true) } + func displayEditStep(viewModel: NewLesson.EditStepPresentation.ViewModel) { + let assembly = EditStepAssembly( + stepID: viewModel.stepID, + output: self.interactor as? EditStepOutputProtocol + ) + let navigationController = StyledNavigationController(rootViewController: assembly.makeModule()) + self.present(navigationController, animated: true) + } + + func displayStepTextUpdate(viewModel: NewLesson.StepTextUpdate.ViewModel) { + guard let stepModuleInput = self.stepModulesInputs[safe: viewModel.index] else { + return + } + + stepModuleInput?.updateStepText(viewModel.text) + } + func displayBlockingLoadingIndicator(viewModel: NewLesson.BlockingWaitingIndicatorUpdate.ViewModel) { if viewModel.shouldDismiss { SVProgressHUD.dismiss() @@ -456,6 +527,8 @@ extension NewLessonViewController: NewLessonViewControllerProtocol { } } +// MARK: - NewLessonViewController: EasyTipViewDelegate - + extension NewLessonViewController: EasyTipViewDelegate { func easyTipViewDidDismiss(_ tipView: EasyTipView) { self.isTooltipVisible = false diff --git a/Stepic/Sources/Modules/NewLesson/NewLessonViewModel.swift b/Stepic/Sources/Modules/NewLesson/NewLessonViewModel.swift index 5c2aa02001..32359732c0 100644 --- a/Stepic/Sources/Modules/NewLesson/NewLessonViewModel.swift +++ b/Stepic/Sources/Modules/NewLesson/NewLessonViewModel.swift @@ -11,4 +11,5 @@ struct NewLessonViewModel { let steps: [StepDescription] let stepLinkMaker: (String) -> String let startStepIndex: Int + let canEdit: Bool } diff --git a/Stepic/Sources/Modules/NewStep/InputOutput/NewStepInputProtocol.swift b/Stepic/Sources/Modules/NewStep/InputOutput/NewStepInputProtocol.swift index 4d5df8f674..081ed6c666 100644 --- a/Stepic/Sources/Modules/NewStep/InputOutput/NewStepInputProtocol.swift +++ b/Stepic/Sources/Modules/NewStep/InputOutput/NewStepInputProtocol.swift @@ -2,4 +2,5 @@ import Foundation protocol NewStepInputProtocol: class { func updateStepNavigation(canNavigateToPreviousUnit: Bool, canNavigateToNextUnit: Bool, canNavigateToNextStep: Bool) + func updateStepText(_ text: String) } diff --git a/Stepic/Sources/Modules/NewStep/NewStepDataFlow.swift b/Stepic/Sources/Modules/NewStep/NewStepDataFlow.swift index 0b32e1b7b8..1d51d7031d 100644 --- a/Stepic/Sources/Modules/NewStep/NewStepDataFlow.swift +++ b/Stepic/Sources/Modules/NewStep/NewStepDataFlow.swift @@ -19,6 +19,18 @@ enum NewStep { } } + /// Update step HTML text - after step source being updated + enum StepTextUpdate { + struct Response { + let text: String + let fontSize: FontSize + } + + struct ViewModel { + let htmlText: String + } + } + /// Update bottom step controls – navigation buttons enum ControlsUpdate { struct Response { diff --git a/Stepic/Sources/Modules/NewStep/NewStepInteractor.swift b/Stepic/Sources/Modules/NewStep/NewStepInteractor.swift index c31e4cca20..aebea4efb9 100644 --- a/Stepic/Sources/Modules/NewStep/NewStepInteractor.swift +++ b/Stepic/Sources/Modules/NewStep/NewStepInteractor.swift @@ -123,6 +123,8 @@ final class NewStepInteractor: NewStepInteractorProtocol { } } +// MARK: - NewStepInteractor: NewStepInputProtocol - + extension NewStepInteractor: NewStepInputProtocol { func updateStepNavigation( canNavigateToPreviousUnit: Bool, @@ -137,4 +139,10 @@ extension NewStepInteractor: NewStepInputProtocol { ) ) } + + func updateStepText(_ text: String) { + self.provider.fetchCurrentFontSize().done { fontSize in + self.presenter.presentStepTextUpdate(response: .init(text: text, fontSize: fontSize)) + } + } } diff --git a/Stepic/Sources/Modules/NewStep/NewStepPresenter.swift b/Stepic/Sources/Modules/NewStep/NewStepPresenter.swift index 0f0ff5d6f3..a18eee8579 100644 --- a/Stepic/Sources/Modules/NewStep/NewStepPresenter.swift +++ b/Stepic/Sources/Modules/NewStep/NewStepPresenter.swift @@ -3,6 +3,7 @@ import UIKit protocol NewStepPresenterProtocol { func presentStep(response: NewStep.StepLoad.Response) + func presentStepTextUpdate(response: NewStep.StepTextUpdate.Response) func presentControlsUpdate(response: NewStep.ControlsUpdate.Response) } @@ -30,6 +31,15 @@ final class NewStepPresenter: NewStepPresenterProtocol { } } + func presentStepTextUpdate(response: NewStep.StepTextUpdate.Response) { + let htmlString = NewStepPresenter.makeProcessedContentHTMLString( + response.text, + fontSize: response.fontSize + ) + + self.viewController?.displayStepTextUpdate(viewModel: .init(htmlText: htmlString)) + } + func presentControlsUpdate(response: NewStep.ControlsUpdate.Response) { let viewModel = NewStep.ControlsUpdate.ViewModel( canNavigateToPreviousUnit: response.canNavigateToPreviousUnit, @@ -66,17 +76,11 @@ final class NewStepPresenter: NewStepPresenterProtocol { } return .video(viewModel: nil) default: - var injections = ContentProcessor.defaultInjections - injections.append(FontSizeInjection(fontSize: fontSize)) - - let contentProcessor = ContentProcessor( - content: step.block.text ?? "", - rules: ContentProcessor.defaultRules, - injections: injections + let htmlString = NewStepPresenter.makeProcessedContentHTMLString( + step.block.text ?? "", + fontSize: fontSize ) - let content = contentProcessor.processContent() - - return .text(htmlString: content) + return .text(htmlString: htmlString) } }() @@ -102,4 +106,17 @@ final class NewStepPresenter: NewStepPresenterProtocol { seal(viewModel) } } + + private static func makeProcessedContentHTMLString(_ text: String, fontSize: FontSize) -> String { + var injections = ContentProcessor.defaultInjections + injections.append(FontSizeInjection(fontSize: fontSize)) + + let contentProcessor = ContentProcessor( + content: text, + rules: ContentProcessor.defaultRules, + injections: injections + ) + + return contentProcessor.processContent() + } } diff --git a/Stepic/Sources/Modules/NewStep/NewStepView.swift b/Stepic/Sources/Modules/NewStep/NewStepView.swift index bc96f207c7..d9afccc2c7 100644 --- a/Stepic/Sources/Modules/NewStep/NewStepView.swift +++ b/Stepic/Sources/Modules/NewStep/NewStepView.swift @@ -165,6 +165,13 @@ final class NewStepView: UIView { } } + func updateText(_ htmlText: String) { + if self.stepTextView.superview != nil { + self.stepTextView.reset() + self.stepTextView.loadHTMLText(htmlText) + } + } + // MARK: Private API private func positionVideoPreview() { diff --git a/Stepic/Sources/Modules/NewStep/NewStepViewController.swift b/Stepic/Sources/Modules/NewStep/NewStepViewController.swift index 126aec16aa..c857902348 100644 --- a/Stepic/Sources/Modules/NewStep/NewStepViewController.swift +++ b/Stepic/Sources/Modules/NewStep/NewStepViewController.swift @@ -3,6 +3,7 @@ import UIKit protocol NewStepViewControllerProtocol: class { func displayStep(viewModel: NewStep.StepLoad.ViewModel) + func displayStepTextUpdate(viewModel: NewStep.StepTextUpdate.ViewModel) func displayControlsUpdate(viewModel: NewStep.ControlsUpdate.ViewModel) } @@ -159,11 +160,17 @@ final class NewStepViewController: UIViewController, ControllerWithStepikPlaceho } } +// MARK: - NewStepViewController: NewStepViewControllerProtocol - + extension NewStepViewController: NewStepViewControllerProtocol { func displayStep(viewModel: NewStep.StepLoad.ViewModel) { self.state = viewModel.state } + func displayStepTextUpdate(viewModel: NewStep.StepTextUpdate.ViewModel) { + self.newStepView?.updateText(viewModel.htmlText) + } + func displayControlsUpdate(viewModel: NewStep.ControlsUpdate.ViewModel) { self.newStepView?.updateNavigationButtons( hasPreviousButton: viewModel.canNavigateToPreviousUnit, @@ -173,6 +180,8 @@ extension NewStepViewController: NewStepViewControllerProtocol { } } +// MARK: - NewStepViewController: NewStepViewDelegate - + extension NewStepViewController: NewStepViewDelegate { func newStepViewDidRequestVideo(_ view: NewStepView) { guard case .result(let viewModel) = self.state, @@ -256,6 +265,8 @@ extension NewStepViewController: NewStepViewDelegate { } } +// MARK: - NewStepViewController: BaseQuizOutputProtocol - + extension NewStepViewController: BaseQuizOutputProtocol { func handleCorrectSubmission() { self.interactor.doStepDoneRequest(request: .init()) diff --git a/Stepic/Sources/Services/Models/Network/StepSourcesNetworkService.swift b/Stepic/Sources/Services/Models/Network/StepSourcesNetworkService.swift new file mode 100644 index 0000000000..15d7c1a130 --- /dev/null +++ b/Stepic/Sources/Services/Models/Network/StepSourcesNetworkService.swift @@ -0,0 +1,33 @@ +import Foundation +import PromiseKit + +protocol StepSourcesNetworkServiceProtocol: class { + func fetch(ids: [StepSource.IdType], page: Int) -> Promise<([StepSource], Meta)> + func update(stepSource: StepSource) -> Promise +} + +extension StepSourcesNetworkServiceProtocol { + func fetch(ids: [StepSource.IdType]) -> Promise<([StepSource], Meta)> { + return self.fetch(ids: ids, page: 1) + } +} + +final class StepSourcesNetworkService: StepSourcesNetworkServiceProtocol { + private let stepSourcesAPI: StepSourcesAPI + + init(stepSourcesAPI: StepSourcesAPI) { + self.stepSourcesAPI = stepSourcesAPI + } + + func fetch(ids: [StepSource.IdType], page: Int) -> Promise<([StepSource], Meta)> { + if ids.isEmpty { + return .value(([], Meta.oneAndOnlyPage)) + } + + return self.stepSourcesAPI.retrieve(ids: ids, page: page) + } + + func update(stepSource: StepSource) -> Promise { + return self.stepSourcesAPI.update(stepSource) + } +} diff --git a/Stepic/StepSources/StepSource.swift b/Stepic/StepSources/StepSource.swift new file mode 100644 index 0000000000..8b4717f0fc --- /dev/null +++ b/Stepic/StepSources/StepSource.swift @@ -0,0 +1,57 @@ +import Foundation +import SwiftyJSON + +final class StepSource: JSONSerializable { + typealias IdType = Int + + var id: IdType = -1 + var block: JSONDictionary = [:] + + var text: String { + get { + return self.block[JSONKey.text.rawValue] as? String ?? "" + } + set { + self.block[JSONKey.text.rawValue] = newValue + } + } + + var json: JSON { + return [ + JSONKey.block.rawValue: self.block + ] + } + + init(stepSource: StepSource) { + self.id = stepSource.id + self.block = stepSource.block + } + + init(json: JSON) { + self.update(json: json) + } + + func update(json: JSON) { + self.id = json[JSONKey.id.rawValue].intValue + + if let block = json[JSONKey.block.rawValue].dictionaryObject { + self.block = block + } + } + + // MARK: Types + + enum JSONKey: String { + case id + case block + case text + } +} + +// MARK: - StepSource: CustomDebugStringConvertible - + +extension StepSource: CustomDebugStringConvertible { + var debugDescription: String { + return "StepSource(id: \(self.id), block: \(self.block))" + } +} diff --git a/Stepic/StepSourcesAPI.swift b/Stepic/StepSourcesAPI.swift new file mode 100644 index 0000000000..3f1f19944d --- /dev/null +++ b/Stepic/StepSourcesAPI.swift @@ -0,0 +1,32 @@ +import Alamofire +import Foundation +import PromiseKit +import SwiftyJSON + +final class StepSourcesAPI: APIEndpoint { + override var name: String { return "step-sources" } + + /// Get step sources by ids. + func retrieve(ids: [StepSource.IdType], page: Int = 1) -> Promise<([StepSource], Meta)> { + let parameters: Parameters = [ + "ids": ids, + "page": page + ] + + return self.retrieve.request( + requestEndpoint: self.name, + paramName: self.name, + params: parameters, + withManager: self.manager + ) + } + + func update(_ stepSource: StepSource) -> Promise { + return self.update.request( + requestEndpoint: self.name, + paramName: "stepSource", + updatingObject: stepSource, + withManager: self.manager + ) + } +} diff --git a/Stepic/en.lproj/Localizable.strings b/Stepic/en.lproj/Localizable.strings index 5f4f9c0cec..47216995b3 100644 --- a/Stepic/en.lproj/Localizable.strings +++ b/Stepic/en.lproj/Localizable.strings @@ -719,6 +719,7 @@ LessonTooltipPointsTitle = "You will get: %@ for step"; LessonTooltipTimeToCompleteTitle = "%@ for lesson"; StepVideoPlayingNotReachableErrorTitle = "You're offline"; StepVideoPlayingNotReachableErrorMessage = "Check​ your​ ​internet​ ​connection,​ ​then​ ​try​ ​again."; +EditStepAlertActionTitle = "Edit"; /* Video player */ VideoPlayerPlaybackFailedStateAlertTitle = "Video player error"; @@ -798,3 +799,10 @@ WriteCommentCancelPromptDestructiveActionTitle = "Delete"; /* Downloads */ DownloadsTitle = "Downloaded courses"; + +/* Edit step */ +EditStepTitle = "Edit step"; +EditStepPlaceholder = "Text..."; +EditStepMessage = "You can only edit the step's text, and only using raw HTML. To access full step editor please use the web version of Stepik."; +EditStepRemoteUpdateSuccessfulTitle = "Updated"; +EditStepRemoteUpdateUnsuccessfulTitle = "Failed to update"; diff --git a/Stepic/ru.lproj/Localizable.strings b/Stepic/ru.lproj/Localizable.strings index 60ab0b6f65..4cc7dc79c5 100644 --- a/Stepic/ru.lproj/Localizable.strings +++ b/Stepic/ru.lproj/Localizable.strings @@ -720,6 +720,7 @@ LessonTooltipPointsTitle = "Вы получите: %@ за шаг"; LessonTooltipTimeToCompleteTitle = "%@ на урок"; StepVideoPlayingNotReachableErrorTitle = "Ошибка соединения"; StepVideoPlayingNotReachableErrorMessage = "Подключитесь к интернету и повторите попытку."; +EditStepAlertActionTitle = "Редактировать"; /* Video player */ VideoPlayerPlaybackFailedStateAlertTitle = "Ошибка видеоплеера"; @@ -799,3 +800,10 @@ WriteCommentCancelPromptDestructiveActionTitle = "Удалить"; /* Downloads */ DownloadsTitle = "Загруженные курсы"; + +/* Edit step */ +EditStepTitle = "Редактирование шага"; +EditStepPlaceholder = "Текст..."; +EditStepMessage = "Вы можете редактировать только текст шага и только в виде HTML кода. Для доступа к полноценному редактору шагов, пожалуйста, воспользуйтесь веб версией Stepik."; +EditStepRemoteUpdateSuccessfulTitle = "Обновлено"; +EditStepRemoteUpdateUnsuccessfulTitle = "Не удалось обновить"; From 274a1a4f58dbc67dbbde280af67d9be389c9a46e Mon Sep 17 00:00:00 2001 From: Ivan Magda Date: Fri, 22 Nov 2019 13:03:19 +0300 Subject: [PATCH 02/16] Update SDWebImage from 5.3.1 to 5.3.2 (#567) * Update SDWebImage from 5.3.1 to 5.3.2 * Update all pods --- Podfile | 2 +- Podfile.lock | 26 +++++++++++++------------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/Podfile b/Podfile index d938c3eba9..5298136775 100644 --- a/Podfile +++ b/Podfile @@ -8,7 +8,7 @@ def shared_pods pod 'Alamofire', '4.9.1' pod 'Atributika', '4.9.0' pod 'SwiftyJSON', '5.0.0' - pod 'SDWebImage', '5.3.1' + pod 'SDWebImage', '5.3.2' pod 'SVGKit', :git => 'https://github.com/SVGKit/SVGKit.git', :branch => '2.x' pod 'Logging', :git => 'https://github.com/ivan-magda/swift-log.git', :branch => 'swift-4' pod 'Fabric', '1.10.2' diff --git a/Podfile.lock b/Podfile.lock index 5ce52c8fff..04ad35959e 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -65,7 +65,7 @@ PODS: - FirebaseCoreDiagnosticsInterop (~> 1.0) - GoogleUtilities/Environment (~> 6.2) - GoogleUtilities/Logger (~> 6.2) - - FirebaseCoreDiagnostics (1.1.1): + - FirebaseCoreDiagnostics (1.1.2): - FirebaseCoreDiagnosticsInterop (~> 1.0) - GoogleDataTransportCCTSupport (~> 1.0) - GoogleUtilities/Environment (~> 6.2) @@ -99,9 +99,9 @@ PODS: - GoogleUtilities/Network (~> 6.0) - "GoogleUtilities/NSData+zlib (~> 6.0)" - nanopb (= 0.3.9011) - - GoogleDataTransport (3.1.0) - - GoogleDataTransportCCTSupport (1.2.1): - - GoogleDataTransport (~> 3.0) + - GoogleDataTransport (3.2.0) + - GoogleDataTransportCCTSupport (1.2.2): + - GoogleDataTransport (~> 3.2) - nanopb (~> 0.3.901) - GoogleUtilities/AppDelegateSwizzler (6.3.2): - GoogleUtilities/Environment @@ -158,9 +158,9 @@ PODS: - Protobuf (3.10.0) - Quick (2.2.0) - Reveal-SDK (24) - - SDWebImage (5.3.1): - - SDWebImage/Core (= 5.3.1) - - SDWebImage/Core (5.3.1) + - SDWebImage (5.3.2): + - SDWebImage/Core (= 5.3.2) + - SDWebImage/Core (5.3.2) - SnapKit (4.2.0) - STRegex (2.1.0) - SVGKit (2.1.0): @@ -220,7 +220,7 @@ DEPENDENCIES: - PromiseKit (= 6.11.0) - Quick (= 2.2.0) - Reveal-SDK - - SDWebImage (= 5.3.1) + - SDWebImage (= 5.3.2) - SnapKit (= 4.2.0) - STRegex (= 2.1.0) - SVGKit (from `https://github.com/SVGKit/SVGKit.git`, branch `2.x`) @@ -346,14 +346,14 @@ SPEC CHECKSUMS: FirebaseAnalytics: 45f36d9c429fc91d206283900ab75390cd05ee8a FirebaseAnalyticsInterop: d48b6ab67bcf016a05e55b71fc39c61c0cb6b7f3 FirebaseCore: 307ea2508df730c5865334e41965bd9ea344b0e5 - FirebaseCoreDiagnostics: af29e43048607588c050889d19204f4d7b758c9f + FirebaseCoreDiagnostics: 511f4f3ed7d440bb69127e8b97c2bc8befae639e FirebaseCoreDiagnosticsInterop: e9b1b023157e3a2fc6418b5cb601e79b9af7b3a0 FirebaseInstanceID: ebd2ea79ee38db0cb5f5167b17a0d387e1cc7b6e FirebaseMessaging: e8d71368a5c579083da02203146c953f3386d503 FirebaseRemoteConfig: 6ad68503c04701b8d9d709240711bc0bf6edaf94 GoogleAppMeasurement: dfe55efa543e899d906309eaaac6ca26d249862f - GoogleDataTransport: 67cc56f6280d1bc9d470285e851ec49ee9013dba - GoogleDataTransportCCTSupport: f6ab1962e9dc05ab1fb938b795e5b310209edeec + GoogleDataTransport: 8e9b210c97d55fbff306cc5468ff91b9cb32dcf5 + GoogleDataTransportCCTSupport: ef79a4728b864946a8aafdbab770d5294faf3b5f GoogleUtilities: 547a86735c6f0ee30ad17e94df4fc21f616b71cb HexColors: 6ad3947c3447a055a3aa8efa859def096351fe5f Highlightr: 595f3e100737c8de41113385da8bd0b5b65212c6 @@ -373,7 +373,7 @@ SPEC CHECKSUMS: Protobuf: a4dc852ad69c027ca2166ed287b856697814375b Quick: 7fb19e13be07b5dfb3b90d4f9824c855a11af40e Reveal-SDK: 5d7e56b8f018c0a88b3a2c10bf68d598bbd3b071 - SDWebImage: 7137d57385fb632129838c1e6ab9528a22c666cc + SDWebImage: 6ac2eb96571bff96ecde31a987172c5934a0eb7d SnapKit: fe8a619752f3f27075cc9a90244d75c6c3f27e2a STRegex: dfa420d93d8c1402956233b3879ec1fc14b45fbe SVGKit: 8a2fc74258bdb2abb54d3b65f3dd68b0277a9c4d @@ -390,6 +390,6 @@ SPEC CHECKSUMS: VK-ios-sdk: 62a10b6571fbcda0657f455fedce7fedf55b4cd0 YandexMobileMetrica: edb00e8af2903290e142ba4c488adf8d394e828a -PODFILE CHECKSUM: 485ae6b6c427eb3da72aa07ad498b14c0232ab51 +PODFILE CHECKSUM: 1a9ef034b87e939726fa45dce93df960f35abbcf COCOAPODS: 1.8.4 From 9954879eb2338e8531d55ef72f3f793107949e7e Mon Sep 17 00:00:00 2001 From: Ivan Magda Date: Fri, 22 Nov 2019 20:08:40 +0300 Subject: [PATCH 03/16] New discussions fixes (#566) * Make vote image disabled tint color more transparent * Group bottom controls * Change name label's font weight to bold * Add pinned badge * Use horizontal dots menu icon * Dicrease leading space for replies * Fix separators offsets * Custom font size for web view based content text view * Layout skeleton * Center votes labels vertically with date and reply labels * Write comment via empty discussions placeholder * Update discussions button on appear * Present discussions embedded in write comment module if needed * Set navigation bar's shadow view alpha to 1 at on appear * Show disabled comments message * Update localization --- Stepic.xcodeproj/project.pbxproj | 16 ++ Stepic/DiscussionsSkeletonView.swift | 165 ++++++++++++++ Stepic/Extensions/UILabelExtensions.swift | 8 +- .../Contents.json | 2 +- .../discussions-pin.pdf} | Bin 5612 -> 4073 bytes Stepic/Scripts.swift | 28 ++- .../ContentProcessingInjection.swift | 33 +++ .../Discussions/DiscussionsAssembly.swift | 3 + .../Discussions/DiscussionsInteractor.swift | 19 +- .../Discussions/DiscussionsPresenter.swift | 28 ++- .../Discussions/DiscussionsProvider.swift | 15 ++ .../DiscussionsViewController.swift | 18 +- .../Discussions/DiscussionsViewModel.swift | 1 + .../DiscussionsInputProtocol.swift | 5 + .../Views/Cell/DiscussionsCellView.swift | 214 ++++++++++++------ .../Views/Cell/DiscussionsTableViewCell.swift | 121 +++++----- .../DiscussionsTableViewDataSource.swift | 31 +-- .../Discussions/Views/DiscussionsView.swift | 2 +- .../Modules/NewStep/NewStepDataFlow.swift | 29 +++ .../Modules/NewStep/NewStepInteractor.swift | 20 ++ .../Modules/NewStep/NewStepPresenter.swift | 59 +++-- .../Modules/NewStep/NewStepProvider.swift | 15 ++ .../Sources/Modules/NewStep/NewStepView.swift | 8 +- .../NewStep/NewStepViewController.swift | 64 +++++- .../Modules/NewStep/NewStepViewModel.swift | 1 + .../NewStep/Views/StepControlsView.swift | 6 + Stepic/Sources/Views/ImageButton.swift | 7 +- .../StepikPlaceholderStyle+Placeholders.swift | 4 +- Stepic/en.lproj/Localizable.strings | 9 +- Stepic/ru.lproj/Localizable.strings | 5 +- 30 files changed, 742 insertions(+), 194 deletions(-) create mode 100644 Stepic/DiscussionsSkeletonView.swift rename Stepic/Images.xcassets/New discussions/{discussions-dots-menu.imageset => discussions-pin.imageset}/Contents.json (80%) rename Stepic/Images.xcassets/New discussions/{discussions-dots-menu.imageset/discussions-dots-menu.pdf => discussions-pin.imageset/discussions-pin.pdf} (54%) create mode 100644 Stepic/Sources/Modules/Discussions/InputOutput/DiscussionsInputProtocol.swift diff --git a/Stepic.xcodeproj/project.pbxproj b/Stepic.xcodeproj/project.pbxproj index dc5164bfaa..f06ca94c40 100644 --- a/Stepic.xcodeproj/project.pbxproj +++ b/Stepic.xcodeproj/project.pbxproj @@ -527,6 +527,7 @@ 2C6BBBBF22B26DB200889A45 /* SubmissionsNetworkService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C6BBBBE22B26DB100889A45 /* SubmissionsNetworkService.swift */; }; 2C6E9CD41FED657E001821A2 /* Adaptive.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 2C6E9CD31FED657E001821A2 /* Adaptive.storyboard */; }; 2C6E9CDB1FF27543001821A2 /* AdaptiveStorageManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C6E9CDA1FF27543001821A2 /* AdaptiveStorageManager.swift */; }; + 2C74AEC823881E6A00EB6725 /* DiscussionsInputProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C74AEC723881E6A00EB6725 /* DiscussionsInputProtocol.swift */; }; 2C79F61821873CD9004CC082 /* NotificationsRequestOnlySettingsAlertPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C79F61721873CD9004CC082 /* NotificationsRequestOnlySettingsAlertPresenter.swift */; }; 2C7F782922708AA60089FDD7 /* StepTabBarButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C7F782822708AA60089FDD7 /* StepTabBarButton.swift */; }; 2C7F782B2270B0910089FDD7 /* LessonInfoTooltipView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C7F782A2270B0910089FDD7 /* LessonInfoTooltipView.swift */; }; @@ -627,6 +628,7 @@ 2CBCBD4B20D1AAFC000B5732 /* AchievementsListTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 2CBCBD4920D1AAFC000B5732 /* AchievementsListTableViewCell.xib */; }; 2CBD855C201799B700E14F83 /* AdaptiveRatingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CBD855B201799B700E14F83 /* AdaptiveRatingsViewController.swift */; }; 2CC0754720177A2E004A6005 /* AdaptiveStatsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CC0754620177A2E004A6005 /* AdaptiveStatsViewController.swift */; }; + 2CC16BA923875DE30000EF36 /* DiscussionsSkeletonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CC16BA823875DE30000EF36 /* DiscussionsSkeletonView.swift */; }; 2CC2A78E235DE26700B2DC44 /* DiscussionsLoadMoreTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CC2A78D235DE26700B2DC44 /* DiscussionsLoadMoreTableViewCell.swift */; }; 2CC3518A1F682A02004255B6 /* SocialAuthCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CC351881F682A02004255B6 /* SocialAuthCollectionViewCell.swift */; }; 2CC3518B1F682A02004255B6 /* SocialAuthCollectionViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 2CC351891F682A02004255B6 /* SocialAuthCollectionViewCell.xib */; }; @@ -1649,6 +1651,7 @@ 2C6E9CD31FED657E001821A2 /* Adaptive.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = Adaptive.storyboard; sourceTree = ""; }; 2C6E9CDA1FF27543001821A2 /* AdaptiveStorageManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptiveStorageManager.swift; sourceTree = ""; }; 2C733C391F29E090000E7FAF /* AdaptiveStatsManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = AdaptiveStatsManager.swift; path = Stepic/AdaptiveStatsManager.swift; sourceTree = SOURCE_ROOT; }; + 2C74AEC723881E6A00EB6725 /* DiscussionsInputProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscussionsInputProtocol.swift; sourceTree = ""; }; 2C76ACCC1F16496C0077D9D7 /* AdaptiveRatingManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = AdaptiveRatingManager.swift; path = Stepic/AdaptiveRatingManager.swift; sourceTree = SOURCE_ROOT; }; 2C79F61721873CD9004CC082 /* NotificationsRequestOnlySettingsAlertPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsRequestOnlySettingsAlertPresenter.swift; sourceTree = ""; }; 2C7F782822708AA60089FDD7 /* StepTabBarButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepTabBarButton.swift; sourceTree = ""; }; @@ -1746,6 +1749,7 @@ 2CBCD3A7213583EB005D10FF /* Model_CourseListRename_v25.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Model_CourseListRename_v25.xcdatamodel; sourceTree = ""; }; 2CBD855B201799B700E14F83 /* AdaptiveRatingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptiveRatingsViewController.swift; sourceTree = ""; }; 2CC0754620177A2E004A6005 /* AdaptiveStatsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AdaptiveStatsViewController.swift; sourceTree = ""; }; + 2CC16BA823875DE30000EF36 /* DiscussionsSkeletonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscussionsSkeletonView.swift; sourceTree = ""; }; 2CC2A78D235DE26700B2DC44 /* DiscussionsLoadMoreTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscussionsLoadMoreTableViewCell.swift; sourceTree = ""; }; 2CC351851F6827BE004255B6 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Auth.storyboard; sourceTree = ""; }; 2CC351881F682A02004255B6 /* SocialAuthCollectionViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SocialAuthCollectionViewCell.swift; sourceTree = ""; }; @@ -3679,6 +3683,7 @@ 62E9856D3E54568A1415665B /* ContinueCourseSkeletonView.swift */, 62E989B9228830C92A91B2DE /* CourseListsCollectionSkeletonView.swift */, 62E98A399A313681138734B4 /* CourseWidgetSkeletonView.swift */, + 2CC16BA823875DE30000EF36 /* DiscussionsSkeletonView.swift */, 2C22043320E28AD50060117A /* AchievementListSkeletonPlaceholderView.xib */, 2C22043120E2804F0060117A /* AchievementSkeletonPlaceholderView.xib */, 2C22042F20E27E400060117A /* ProfileCellSkeletonPlaceholderView.xib */, @@ -4110,6 +4115,14 @@ name = Helpers; sourceTree = ""; }; + 2C74AEC623881DF700EB6725 /* InputOutput */ = { + isa = PBXGroup; + children = ( + 2C74AEC723881E6A00EB6725 /* DiscussionsInputProtocol.swift */, + ); + path = InputOutput; + sourceTree = ""; + }; 2C7F782622708A520089FDD7 /* Views */ = { isa = PBXGroup; children = ( @@ -5507,6 +5520,7 @@ 4F646AB1EA500AD2EB797350 /* DiscussionsProvider.swift */, DF03A1A429714E7C4A2EBA60 /* DiscussionsViewController.swift */, 2C3A24562359DD2E00E2405F /* DiscussionsViewModel.swift */, + 2C74AEC623881DF700EB6725 /* InputOutput */, 2C3A24552359DA9200E2405F /* Views */, ); path = Discussions; @@ -6233,6 +6247,7 @@ 2C53E00122DDDBFD0084BA2B /* NewCodeQuizAssembly.swift in Sources */, 08BC47141CDA3E99009A1D25 /* ExecutableTaskTypes.swift in Sources */, 2C6BBBBA22B261E700889A45 /* BaseQuizInteractor.swift in Sources */, + 2C74AEC823881E6A00EB6725 /* DiscussionsInputProtocol.swift in Sources */, 2C20778622BBC05F00D44DC0 /* NewStringQuizViewModel.swift in Sources */, 08901E6A1CD1019A00D94613 /* UpdateChecker.swift in Sources */, 08DF78BC1F5EEFFE00AEEA85 /* MenuBlockTableViewCell.swift in Sources */, @@ -6748,6 +6763,7 @@ 62E985DD90444821A835C02F /* CourseListsCollectionSkeletonView.swift in Sources */, 62E98A53567525055BCCA089 /* AppDelegate.swift in Sources */, 2C20C85E22F8E19F0052E9BF /* StepOptionsPlainObject.swift in Sources */, + 2CC16BA923875DE30000EF36 /* DiscussionsSkeletonView.swift in Sources */, 2CCC505E21E8EA88004D9FC1 /* PersonalDeadlinesTimeService.swift in Sources */, 62E9874D1EAE49639F81C916 /* URL+AppendQueryParameters.swift in Sources */, 62E982F9CF826D3E99B7EE08 /* NotificationPermissionStatusSettingsObserver.swift in Sources */, diff --git a/Stepic/DiscussionsSkeletonView.swift b/Stepic/DiscussionsSkeletonView.swift new file mode 100644 index 0000000000..4b6a56ac4a --- /dev/null +++ b/Stepic/DiscussionsSkeletonView.swift @@ -0,0 +1,165 @@ +import SnapKit +import UIKit + +extension DiscussionsSkeletonView { + struct Appearance { + let labelCornerRadius: CGFloat = 5.0 + + let avatarImageViewInsets = UIEdgeInsets(top: 16, left: 16, bottom: 0, right: 0) + let avatarImageViewSize = CGSize(width: 36, height: 36) + let avatarImageViewCornerRadius: CGFloat = 4.0 + + let badgeViewInsets = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 0) + let badgeViewSize = CGSize(width: 80, height: 12) + + let dotsMenuViewInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 16) + let dotsMenuViewSize = CGSize(width: 24, height: 12) + + let nameLabelHeight: CGFloat = 14.0 + let nameLabelInsets = UIEdgeInsets(top: 8, left: 16, bottom: 8, right: 16) + + let textLabelInsets = UIEdgeInsets(top: 8, left: 0, bottom: 16, right: 16) + let textLabelHeight: CGFloat = 14.0 + + let separatorHeight: CGFloat = 0.5 + let separatorColor = UIColor(hex: 0xe7e7e7) + } +} + +final class DiscussionsSkeletonView: UIView { + let appearance: Appearance + + private lazy var avatarView: UIView = { + let view = UIView() + view.clipsToBounds = true + view.layer.cornerRadius = self.appearance.avatarImageViewCornerRadius + return view + }() + + private lazy var badgeView: UIView = { + let view = UIView() + view.clipsToBounds = true + view.layer.cornerRadius = self.appearance.labelCornerRadius + return view + }() + + private lazy var dotsMenuView: UIView = { + let view = UIView() + view.clipsToBounds = true + view.layer.cornerRadius = self.appearance.labelCornerRadius + return view + }() + + private lazy var nameLabelView: UIView = { + let view = UIView() + view.clipsToBounds = true + view.layer.cornerRadius = self.appearance.labelCornerRadius + return view + }() + + private lazy var descriptionLabel1View: UIView = { + let view = UIView() + view.clipsToBounds = true + view.layer.cornerRadius = self.appearance.labelCornerRadius + return view + }() + + private lazy var descriptionLabel2View: UIView = { + let view = UIView() + view.clipsToBounds = true + view.layer.cornerRadius = self.appearance.labelCornerRadius + return view + }() + + private lazy var separatorView: UIView = { + let view = UIView() + view.backgroundColor = self.appearance.separatorColor + return view + }() + + init(frame: CGRect = .zero, appearance: Appearance = Appearance()) { + self.appearance = appearance + super.init(frame: frame) + + self.addSubviews() + self.makeConstraints() + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension DiscussionsSkeletonView: ProgrammaticallyInitializableViewProtocol { + func addSubviews() { + self.addSubview(self.avatarView) + self.addSubview(self.badgeView) + self.addSubview(self.dotsMenuView) + self.addSubview(self.nameLabelView) + self.addSubview(self.descriptionLabel1View) + self.addSubview(self.descriptionLabel2View) + self.addSubview(self.separatorView) + } + + func makeConstraints() { + self.avatarView.translatesAutoresizingMaskIntoConstraints = false + self.avatarView.snp.makeConstraints { make in + make.top.equalToSuperview().offset(self.appearance.avatarImageViewInsets.top) + make.leading.equalToSuperview().offset(self.appearance.avatarImageViewInsets.left) + make.size.equalTo(self.appearance.avatarImageViewSize) + } + + self.badgeView.translatesAutoresizingMaskIntoConstraints = false + self.badgeView.snp.makeConstraints { make in + make.leading.equalTo(self.avatarView.snp.trailing).offset(self.appearance.badgeViewInsets.left) + make.top.equalTo(self.avatarView.snp.top) + make.size.equalTo(self.appearance.badgeViewSize) + } + + self.dotsMenuView.translatesAutoresizingMaskIntoConstraints = false + self.dotsMenuView.snp.makeConstraints { make in + make.top.equalTo(self.avatarView.snp.top) + make.trailing.equalToSuperview().offset(-self.appearance.dotsMenuViewInsets.right) + make.size.equalTo(self.appearance.dotsMenuViewSize) + } + + self.nameLabelView.translatesAutoresizingMaskIntoConstraints = false + self.nameLabelView.snp.makeConstraints { make in + make.height.equalTo(self.appearance.nameLabelHeight) + make.bottom.equalTo(self.avatarView.snp.bottom).priority(999) + make.leading + .equalTo(self.avatarView.snp.trailing) + .offset(self.appearance.nameLabelInsets.left) + make.width.equalTo(self.snp.width).multipliedBy(0.4) + } + + self.descriptionLabel1View.translatesAutoresizingMaskIntoConstraints = false + self.descriptionLabel1View.snp.makeConstraints { make in + make.leading.equalTo(self.nameLabelView.snp.leading) + make.top + .equalTo(self.nameLabelView.snp.bottom) + .offset(self.appearance.nameLabelInsets.bottom) + make.height.equalTo(self.appearance.textLabelHeight) + make.width.equalTo(self.descriptionLabel2View).multipliedBy(0.8) + } + + self.descriptionLabel2View.translatesAutoresizingMaskIntoConstraints = false + self.descriptionLabel2View.snp.makeConstraints { make in + make.leading.equalTo(self.descriptionLabel1View.snp.leading) + make.top + .equalTo(self.descriptionLabel1View.snp.bottom) + .offset(self.appearance.textLabelInsets.top) + make.trailing.equalToSuperview().offset(-self.appearance.textLabelInsets.right) + make.bottom + .equalTo(self.separatorView.snp.top) + .offset(-self.appearance.textLabelInsets.bottom) + make.height.equalTo(self.appearance.textLabelHeight) + } + + self.separatorView.translatesAutoresizingMaskIntoConstraints = false + self.separatorView.snp.makeConstraints { make in + make.height.equalTo(self.appearance.separatorHeight) + make.leading.bottom.trailing.equalToSuperview() + } + } +} diff --git a/Stepic/Extensions/UILabelExtensions.swift b/Stepic/Extensions/UILabelExtensions.swift index 8baef81ca8..7cc09e6b47 100644 --- a/Stepic/Extensions/UILabelExtensions.swift +++ b/Stepic/Extensions/UILabelExtensions.swift @@ -150,7 +150,13 @@ extension CGSize { } final class WiderLabel: UILabel { + var widthDelta: CGFloat = 10 { + didSet { + self.invalidateIntrinsicContentSize() + } + } + override var intrinsicContentSize: CGSize { - return super.intrinsicContentSize.sizeByDelta(dw: 10, dh: 0) + return super.intrinsicContentSize.sizeByDelta(dw: self.widthDelta, dh: 0) } } diff --git a/Stepic/Images.xcassets/New discussions/discussions-dots-menu.imageset/Contents.json b/Stepic/Images.xcassets/New discussions/discussions-pin.imageset/Contents.json similarity index 80% rename from Stepic/Images.xcassets/New discussions/discussions-dots-menu.imageset/Contents.json rename to Stepic/Images.xcassets/New discussions/discussions-pin.imageset/Contents.json index 2251db8a7a..2027030bb8 100644 --- a/Stepic/Images.xcassets/New discussions/discussions-dots-menu.imageset/Contents.json +++ b/Stepic/Images.xcassets/New discussions/discussions-pin.imageset/Contents.json @@ -2,7 +2,7 @@ "images" : [ { "idiom" : "universal", - "filename" : "discussions-dots-menu.pdf" + "filename" : "discussions-pin.pdf" } ], "info" : { diff --git a/Stepic/Images.xcassets/New discussions/discussions-dots-menu.imageset/discussions-dots-menu.pdf b/Stepic/Images.xcassets/New discussions/discussions-pin.imageset/discussions-pin.pdf similarity index 54% rename from Stepic/Images.xcassets/New discussions/discussions-dots-menu.imageset/discussions-dots-menu.pdf rename to Stepic/Images.xcassets/New discussions/discussions-pin.imageset/discussions-pin.pdf index 029c5a9691f3f936085e7b346e743fbded52e0c5..6f1b6ce8def06ea498bff2b4d4d3d149175908b4 100644 GIT binary patch delta 905 zcmaE({Zf8HKz-^Y-@Ir;o|f;Htk+rBOnUTxm--1Ub^|Gk24RJ-e;fP02<>*bf15-8 z#7xDd{#DETyADo!>1d|b9&*w8WdLs~^W=3t3ty*6=^jzuX_ZvxJh|#oqR_Ft70qsi z3;c{KoFdt|x0EXD8!j_G8~EyJor5rAqs{g_!`X&^9pu_G5ACZjOW(5dwcqqt+IKr2 z3T7_bWcb)6T*J}-Y{Uun-RBK{RJxe?D?BOjpJaG;j_~Oj9Spsn(;lXUC~ppa{j%#? z%f};0VmhYnUz-xsd~FSWY?*poafxB+#%WIrH@q|sFE-X@;Z-lZ*1F1xJ2l~|*uKLh z%4f2+c6^hKj4D*Li<-SLrvA*-J=?zhcb)gL$*@2=l(&8T;or?iJHDr+$MUpFXcz%Od;zFI(*jxE=Gqe7??Gy;t!4|1!6i*XK^W zaWCk>%g#67CHUFS=a)Ijev9rb=znK@R6@LJ>GS<6GbgigrRJp+mlUNY=5nP@{Kdy? zY;HK2o3TvLIX@@AD7YXoIaR^NMnT`Xcyd3ZsECDvfkJ*#78gj!Cp9mtXkuV$U>s#&pbkWu3i`hJDK3d6sR|k{Rz?PfmWGBHO6=@lR#gdcHy(DfP{n_!q{Y+_+F*3%LE>%@meb%7 delta 2266 zcmah~3pA8@8_&qn6tyL42J@~Org53~o!dL(dXVIjVFr;#8HOlEGb8tAXanon|z zwM5$?MMSPiA6w(D>?gUDubai-#Op^yyw0ApWpNRf6w!t^ZUKUOz5RK zi6{(6DZ~@u51*=|=Hdzv-tJM`>l)o%$KGW0M-D&A}Wqqv~9W+ zZ>%jz_CK5XdT{Y#N#R3=bCE9dR*Ynh!dubAEcI#4)$y~Q)}l*kMeRomnBBNNa>DlS zMmui^9j~?I#Cf%-Z_^oSs;Ik;%8F>fM8LuXv`YDlyM#+_eO+#Lk;BuiF{X%Hm@4z8 z5>sPwvm8nuqJrWVa%W(71}Bi~BLqxL0K$PF2!H_)U;<`l(hJEA_T_N`k+Ln>QwDM1 z2Kx(70JKYZl!)LG5yAxse^wx{LOH_KodZ06khGfgE<+$8fM99K3kN*OfB^&$L4ZhM z0|X{lzzgO3a0LK;^+f)H;$H`pfvAXu>g2$Z3a1Y1DJ50##3 z4~YD{PYJkdR0Tpl*E=k8UeHS&}B-hba2qp@y$YRP9kk$c^9lx>wfzTG_Qh(AEY4Q)Mv) z*-YqS7-6VS1R!X&iO=Q+5!dklH8_YQPDNOV;$1zH(5}0d#q@UM{f^#$^&1pg)eHCC zx3AhcR8*PeDXu(;y7NoFnni_J?E7S5Z-+zA<`{h3Sn|yVTZ_7S1Esj0$mXYt3(Ol% zZej1Wab}g_7PvX<>femTzB(R(+1$?P*`?um-s`;G1+jN6>&q5)7GPVbg7%V`=iNR( zi<*q)-+qHCbVliyxS3v-bZkFssaLkt=xLtD01xX)%Yb{5o7AJoLfENdb}%-y?WYdSN1<-GYkeZ=P5Ta>%;4vNENgWVd_KKqw*Q{F{y42MI0^3}>wOu#gH1QgbO%w?r7 zR*Y1~t~_#oqVaZu%_`j$X=~g(TsB?#hL?1ZNjA=vV&8u2@pzLdKh4fsi?QLNe51&# zR8x)KR6Lj2pR=Q`&Hq;ytj>(;;&=u!@lMl^O^tlheRE0JCC}=LTn+cOE|Rt(=Qgc6 zCvr3~e!|X7LF;s8aQ1wMSJlxTmFvcz7E0nFkRq z{=nmuh;Bw~cIAlPwqle4$F)8G5L1M+vAr;MM0dxP?E!`7M$7r2Mi^W2T}5XBK0GBW zRq`M!^dGCbEvk0s`RHK4Zm{!lQk&)wBCs(D6@))7T2i=x z@+eDeDAT`4ePr@@_pHL9`k6<=ru9AL74PsmpNt}3d6{lqRJ*6;-dd|Q5E6rn%|oC+ zdfM>njs#w-@8zCmq8uxq77s?Mv0XQ&zw7A03N5T8b;bWa?}f>56wT+W2c_pmEx+q3 z>c#p*^!Zq;f4rILx!Z^|d3zMoA`&GWZkdWV-?Cy@ZxDlz!RBU|Wf+RJ*=dG$$Lxpg zF!smpeY!76i~4;pQC)zYQS0q5Ew6rd)t!##WniwnoKcx~j(F<2ZzjnETl|V$ek!a} zlFcis=Fg4e3PKkIuieKyx{oqw^21A4H%O8PVENfL_a9$Q2NVo*UTiViBnp}mKf93> zKcH&Q%bW~=X4%8d`$4Vm)3!g?%-Q6quyxb(m@DV*AJ1;idLvPu9mRjbk5kRRY0y=@ z+0V#2Qp3@UdN8V+u4EQ-)E52j)jeicXe;D#q1_;u8gcsa!!Xvwp4v=7Lt;bFVUv!t z2`5&Lg@1^dI{i=7em%ABwwGEWI3a9YerkJelx5IWNtOA`e8ZkQc2OT_9~yqZy*OrG zeFXkZ3mu+(VpihzKJSBPsbuSj=-ivQWlVlvwCj3df=Fvh1A#F8PuX5oOqoV`0t8zt zD@$(y*B4mTBqji`|3Z=fhe%N>QW5b;eTcFaBT^yaUkNg$TUVAWHc_T15Xk_MEDlxH zk)w)ply#H|Zd|?~fENr9AVadaS^2aQNP$R13IvOdRM3BauvO4E diff --git a/Stepic/Scripts.swift b/Stepic/Scripts.swift index 004ac61c02..a3af350831 100644 --- a/Stepic/Scripts.swift +++ b/Stepic/Scripts.swift @@ -77,13 +77,31 @@ struct Scripts { /// Returns script that replaces font size variables with the provided ones at `stepikcontent.css`. static func fontSize(_ fontSize: FontSize) -> String { + return self.fontSizeScript( + bodyFontSizeString: fontSize.body, + h1FontSizeString: fontSize.h1, + h2FontSizeString: fontSize.h2, + h3FontSizeString: fontSize.h3, + blockquoteFontSizeString: fontSize.blockquote + ) + } + + /// Returns script that replaces font size variables with the provided ones at `stepikcontent.css`. + /// Example: h1FontSizeString = 20pt, h2FontSizeString = 17pt, blockquoteFontSizeString = 16px + static func fontSizeScript( + bodyFontSizeString: String = FontSize.small.body, + h1FontSizeString: String = FontSize.small.h1, + h2FontSizeString: String = FontSize.small.h2, + h3FontSizeString: String = FontSize.small.h3, + blockquoteFontSizeString: String = FontSize.small.blockquote + ) -> String { let script = self.loadScriptWithKey(self.fontSizeScriptKey) return script - .replacingOccurrences(of: "##--body-font-size##", with: fontSize.body) - .replacingOccurrences(of: "##--h1-font-size##", with: fontSize.h1) - .replacingOccurrences(of: "##--h2-font-size##", with: fontSize.h2) - .replacingOccurrences(of: "##--h3-font-size##", with: fontSize.h3) - .replacingOccurrences(of: "##--blockquote-font-size##", with: fontSize.blockquote) + .replacingOccurrences(of: "##--body-font-size##", with: bodyFontSizeString) + .replacingOccurrences(of: "##--h1-font-size##", with: h1FontSizeString) + .replacingOccurrences(of: "##--h2-font-size##", with: h2FontSizeString) + .replacingOccurrences(of: "##--h3-font-size##", with: h3FontSizeString) + .replacingOccurrences(of: "##--blockquote-font-size##", with: blockquoteFontSizeString) } private static func loadScriptWithKey(_ key: String) -> String { diff --git a/Stepic/Sources/Frameworks/ContentProcessor/ContentProcessingInjection.swift b/Stepic/Sources/Frameworks/ContentProcessor/ContentProcessingInjection.swift index 517f9ca054..324af95f76 100644 --- a/Stepic/Sources/Frameworks/ContentProcessor/ContentProcessingInjection.swift +++ b/Stepic/Sources/Frameworks/ContentProcessor/ContentProcessingInjection.swift @@ -114,3 +114,36 @@ final class FontSizeInjection: ContentProcessingInjection { return Scripts.fontSize(self.fontSize) } } + +/// Injects script that assigns custom font sizes. +final class CustomFontSizeInjection: ContentProcessingInjection { + private let bodyFontSize: Int + private let h1FontSize: Int + private let h2FontSize: Int + private let h3FontSize: Int + private let blockquoteFontSize: Int + + init( + bodyFontSize: Int, + h1FontSize: Int, + h2FontSize: Int, + h3FontSize: Int, + blockquoteFontSize: Int + ) { + self.bodyFontSize = bodyFontSize + self.h1FontSize = h1FontSize + self.h2FontSize = h2FontSize + self.h3FontSize = h3FontSize + self.blockquoteFontSize = blockquoteFontSize + } + + var headScript: String { + return Scripts.fontSizeScript( + bodyFontSizeString: "\(self.bodyFontSize)pt", + h1FontSizeString: "\(self.h1FontSize)pt", + h2FontSizeString: "\(self.h2FontSize)pt", + h3FontSizeString: "\(self.h3FontSize)pt", + blockquoteFontSizeString: "\(self.blockquoteFontSize)px" + ) + } +} diff --git a/Stepic/Sources/Modules/Discussions/DiscussionsAssembly.swift b/Stepic/Sources/Modules/Discussions/DiscussionsAssembly.swift index b6e04938c6..7a0bdf962b 100644 --- a/Stepic/Sources/Modules/Discussions/DiscussionsAssembly.swift +++ b/Stepic/Sources/Modules/Discussions/DiscussionsAssembly.swift @@ -1,6 +1,8 @@ import UIKit final class DiscussionsAssembly: Assembly { + var moduleInput: DiscussionsInputProtocol? + private let discussionProxyID: DiscussionProxy.IdType private let stepID: Step.IdType private let presentationContext: Discussions.PresentationContext @@ -35,6 +37,7 @@ final class DiscussionsAssembly: Assembly { let viewController = DiscussionsViewController(interactor: interactor) presenter.viewController = viewController + self.moduleInput = interactor return viewController } diff --git a/Stepic/Sources/Modules/Discussions/DiscussionsInteractor.swift b/Stepic/Sources/Modules/Discussions/DiscussionsInteractor.swift index c524960541..b2de5b1612 100644 --- a/Stepic/Sources/Modules/Discussions/DiscussionsInteractor.swift +++ b/Stepic/Sources/Modules/Discussions/DiscussionsInteractor.swift @@ -217,6 +217,8 @@ final class DiscussionsInteractor: DiscussionsInteractorProtocol { if let discussionIndex = self.currentDiscussions.firstIndex(where: { $0.id == commentID }) { self.currentDiscussions.remove(at: discussionIndex) self.currentReplies[commentID] = nil + + self.provider.decrementStepDiscussionsCount(stepID: self.stepID).cauterize() } else { for (discussionID, replies) in self.currentReplies { guard let replyIndex = replies.firstIndex(where: { $0.id == commentID }) else { @@ -461,11 +463,14 @@ final class DiscussionsInteractor: DiscussionsInteractorProtocol { ? discussionsWindow.require() : self.getLoadedDiscussionsWindow() - let index = discussionsWindow.endIndex == 0 - ? self.currentDiscussionsIDs.count - : self.currentDiscussionsIDs.count - discussionsWindow.endIndex - 1 + let leftToLoad: Int = { + if discussionsWindow.endIndex == 0 { + return self.currentDiscussionsIDs.count - self.currentDiscussions.count + } + return self.currentDiscussionsIDs.count - discussionsWindow.endIndex - 1 + }() - return max(index, 0) + return max(leftToLoad, 0) } private func getLoadedDiscussionsWindow() -> (startIndex: Int, endIndex: Int) { @@ -486,8 +491,8 @@ final class DiscussionsInteractor: DiscussionsInteractorProtocol { return (0, 0) case .scrollTo(let discussionID, _): + // This could happen when the selected comment was deleted and there are no more comments. guard let discussionIndex = self.currentDiscussionsIDs.index(of: discussionID) else { - assertionFailure("Discussion must appear in the collection") return (0, 0) } @@ -679,6 +684,10 @@ final class DiscussionsInteractor: DiscussionsInteractorProtocol { } } +// MARK: - DiscussionsInteractor: DiscussionsInputProtocol - + +extension DiscussionsInteractor: DiscussionsInputProtocol { } + // MARK: - DiscussionsInteractor: WriteCommentOutputProtocol - extension DiscussionsInteractor: WriteCommentOutputProtocol { diff --git a/Stepic/Sources/Modules/Discussions/DiscussionsPresenter.swift b/Stepic/Sources/Modules/Discussions/DiscussionsPresenter.swift index 2363eaa4c4..ef1ed23a1a 100644 --- a/Stepic/Sources/Modules/Discussions/DiscussionsPresenter.swift +++ b/Stepic/Sources/Modules/Discussions/DiscussionsPresenter.swift @@ -166,7 +166,11 @@ final class DiscussionsPresenter: DiscussionsPresenterProtocol { ) } - private func makeCommentViewModel(comment: Comment, isSelected: Bool) -> DiscussionsCommentViewModel { + private func makeCommentViewModel( + comment: Comment, + isSelected: Bool, + hasReplies: Bool + ) -> DiscussionsCommentViewModel { let avatarImageURL: URL? = { if let userInfo = comment.userInfo { return URL(string: userInfo.avatarURL) @@ -188,11 +192,23 @@ final class DiscussionsPresenter: DiscussionsPresenterProtocol { let text: String = { let trimmedText = comment.text.trimmingCharacters(in: .whitespacesAndNewlines) if isWebViewSupportNeeded { + var injections = ContentProcessor.defaultInjections + injections.append( + CustomFontSizeInjection( + bodyFontSize: 11, + h1FontSize: 19, + h2FontSize: 16, + h3FontSize: 13, + blockquoteFontSize: 15 + ) + ) + let contentProcessor = ContentProcessor( content: trimmedText, rules: ContentProcessor.defaultRules, - injections: ContentProcessor.defaultInjections + injections: injections ) + return contentProcessor.processContent() } return trimmedText @@ -223,7 +239,8 @@ final class DiscussionsPresenter: DiscussionsPresenterProtocol { voteValue: voteValue, canEdit: comment.actions.contains(.edit), canDelete: comment.actions.contains(.delete), - canVote: comment.actions.contains(.vote) + canVote: comment.actions.contains(.vote), + hasReplies: hasReplies ) } @@ -236,12 +253,13 @@ final class DiscussionsPresenter: DiscussionsPresenterProtocol { let repliesViewModels = self.sortedReplies( replies, parentDiscussion: discussion - ).map { self.makeCommentViewModel(comment: $0, isSelected: false) } + ).map { self.makeCommentViewModel(comment: $0, isSelected: false, hasReplies: false) } + let hasReplies = !discussion.repliesIDs.isEmpty let leftToLoadCount = discussion.repliesIDs.count - repliesViewModels.count return DiscussionsDiscussionViewModel( - comment: self.makeCommentViewModel(comment: discussion, isSelected: isSelected), + comment: self.makeCommentViewModel(comment: discussion, isSelected: isSelected, hasReplies: hasReplies), replies: repliesViewModels, repliesLeftToLoadCount: leftToLoadCount, formattedRepliesLeftToLoad: "\(NSLocalizedString("ShowMoreDiscussions", comment: "")) (\(leftToLoadCount))", diff --git a/Stepic/Sources/Modules/Discussions/DiscussionsProvider.swift b/Stepic/Sources/Modules/Discussions/DiscussionsProvider.swift index 300384323c..00a09c2584 100644 --- a/Stepic/Sources/Modules/Discussions/DiscussionsProvider.swift +++ b/Stepic/Sources/Modules/Discussions/DiscussionsProvider.swift @@ -7,6 +7,7 @@ protocol DiscussionsProviderProtocol { func deleteComment(id: Comment.IdType) -> Promise func updateVote(_ vote: Vote) -> Promise func incrementStepDiscussionsCount(stepID: Step.IdType) -> Promise + func decrementStepDiscussionsCount(stepID: Step.IdType) -> Promise } final class DiscussionsProvider: DiscussionsProviderProtocol { @@ -81,10 +82,24 @@ final class DiscussionsProvider: DiscussionsProviderProtocol { } } + func decrementStepDiscussionsCount(stepID: Step.IdType) -> Promise { + return Promise { seal in + self.stepsPersistenceService.fetch(ids: [stepID]).done { steps in + if let step = steps.first { + step.discussionsCount? -= 1 + } + CoreDataHelper.instance.save() + }.catch { _ in + seal.reject(Error.stepDiscussionsDecrementFailed) + } + } + } + enum Error: Swift.Error { case fetchFailed case commentDeleteFailed case voteUpdateFailed case stepDiscussionsIncrementFailed + case stepDiscussionsDecrementFailed } } diff --git a/Stepic/Sources/Modules/Discussions/DiscussionsViewController.swift b/Stepic/Sources/Modules/Discussions/DiscussionsViewController.swift index e88899bc72..ae373a5733 100644 --- a/Stepic/Sources/Modules/Discussions/DiscussionsViewController.swift +++ b/Stepic/Sources/Modules/Discussions/DiscussionsViewController.swift @@ -84,6 +84,14 @@ final class DiscussionsViewController: UIViewController, ControllerWithStepikPla self.interactor.doDiscussionsLoad(request: .init()) } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + if let styledNavigationController = self.navigationController as? StyledNavigationController { + styledNavigationController.changeShadowViewAlpha(1.0, sender: self) + } + } + // MARK: - Private API private func registerPlaceholders() { @@ -101,7 +109,15 @@ final class DiscussionsViewController: UIViewController, ControllerWithStepikPla ), for: .connectionError ) - self.registerPlaceholder(placeholder: StepikPlaceholder(.emptyDiscussions), for: .empty) + self.registerPlaceholder( + placeholder: StepikPlaceholder( + .emptyDiscussions, + action: { [weak self] in + self?.didClickWriteComment() + } + ), + for: .empty + ) } private func updateState(newState: Discussions.ViewControllerState) { diff --git a/Stepic/Sources/Modules/Discussions/DiscussionsViewModel.swift b/Stepic/Sources/Modules/Discussions/DiscussionsViewModel.swift index 4be953c4a1..0331184065 100644 --- a/Stepic/Sources/Modules/Discussions/DiscussionsViewModel.swift +++ b/Stepic/Sources/Modules/Discussions/DiscussionsViewModel.swift @@ -30,4 +30,5 @@ struct DiscussionsCommentViewModel { let canEdit: Bool let canDelete: Bool let canVote: Bool + let hasReplies: Bool } diff --git a/Stepic/Sources/Modules/Discussions/InputOutput/DiscussionsInputProtocol.swift b/Stepic/Sources/Modules/Discussions/InputOutput/DiscussionsInputProtocol.swift new file mode 100644 index 0000000000..c7a745ae09 --- /dev/null +++ b/Stepic/Sources/Modules/Discussions/InputOutput/DiscussionsInputProtocol.swift @@ -0,0 +1,5 @@ +import Foundation + +// Conforms to WriteCommentOutputProtocol to be able to discussions module with embedded write comment module. +// See NewStepViewController's displayDiscussions(viewModel:) usages. +protocol DiscussionsInputProtocol: WriteCommentOutputProtocol { } diff --git a/Stepic/Sources/Modules/Discussions/Views/Cell/DiscussionsCellView.swift b/Stepic/Sources/Modules/Discussions/Views/Cell/DiscussionsCellView.swift index 44bd18c832..112d80c542 100644 --- a/Stepic/Sources/Modules/Discussions/Views/Cell/DiscussionsCellView.swift +++ b/Stepic/Sources/Modules/Discussions/Views/Cell/DiscussionsCellView.swift @@ -1,25 +1,36 @@ import SnapKit import UIKit +// MARK: Appearance - + extension DiscussionsCellView { struct Appearance { let avatarImageViewInsets = LayoutInsets(top: 16, left: 16) let avatarImageViewSize = CGSize(width: 36, height: 36) let avatarImageViewCornerRadius: CGFloat = 4.0 - let badgeLabelInsets = LayoutInsets(left: 16) let badgeLabelFont = UIFont.systemFont(ofSize: 10, weight: .medium) - let badgeLabelTextColor = UIColor.white - let badgeLabelBackgroundColor = UIColor.stepicGreen - let badgeLabelCornerRadius: CGFloat = 10 - let badgeLabelHeight: CGFloat = 20 + let badgeTintColor = UIColor.white + let badgeCornerRadius: CGFloat = 10 + + let badgeUserRoleWidthDelta: CGFloat = 16 + let badgeUserRoleBackgroundColor = UIColor.stepicGreen + + let badgeIsPinnedBackgroundColor = UIColor(hex: 0x6C7BDF) + let badgeIsPinnedImageSize = CGSize(width: 10, height: 10) + let badgeIsPinnedImageInsets = UIEdgeInsets(top: 1, left: 8, bottom: 0, right: 2) + let badgeIsPinnedTitleInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 8) + + let badgesStackViewHeight: CGFloat = 20 + let badgesStackViewSpacing: CGFloat = 8 + let badgeStackViewInsets = LayoutInsets(left: 16) let dotsMenuImageSize = CGSize(width: 20, height: 20) let dotsMenuImageTintColor = UIColor.mainDark.withAlphaComponent(0.5) let dotsMenuImageInsets = LayoutInsets(top: 16, right: 16) let nameLabelInsets = LayoutInsets(top: 8, left: 16, right: 16) - let nameLabelFont = UIFont.systemFont(ofSize: 14, weight: .medium) + let nameLabelFont = UIFont.systemFont(ofSize: 14, weight: .bold) let nameLabelTextColor = UIColor.mainDark let nameLabelHeight: CGFloat = 18 @@ -29,7 +40,8 @@ extension DiscussionsCellView { let textContentTextLabelFont = UIFont.systemFont(ofSize: 14) let textContentTextLabelTextColor = UIColor.mainDark - let bottomControlsSpacing: CGFloat = 4 + let bottomControlsSpacing: CGFloat = 16 + let bottomControlsSubgroupSpacing: CGFloat = 8 let bottomControlsInsets = LayoutInsets(top: 8, left: 16, bottom: 16, right: 16) let bottomControlsHeight: CGFloat = 20 @@ -39,13 +51,14 @@ extension DiscussionsCellView { let replyButtonFont = UIFont.systemFont(ofSize: 12, weight: .light) let replyButtonTextColor = UIColor(hex: 0x3E50CB) - let likeImageSize = CGSize(width: 20, height: 20) - let likeImageNormalTintColor = UIColor.mainDark.withAlphaComponent(0.5) - let likeImageFilledTintColor = UIColor.mainDark - let likeButtonFont = UIFont.systemFont(ofSize: 12, weight: .light) - let likeButtonTitleInsets = UIEdgeInsets(top: 2, left: 4, bottom: 0, right: 0) - - let dislikeButtonTitleInsets = UIEdgeInsets(top: 2, left: 4, bottom: 0, right: 0) + // Like & dislike + let voteImageSize = CGSize(width: 20, height: 20) + let voteImageFilledTintColor = UIColor.mainDark + let voteImageNormalTintColor = UIColor.mainDark.withAlphaComponent(0.5) + let voteImageDisabledTintColor = UIColor.mainDark.withAlphaComponent(0.25) + let voteButtonFont = UIFont.systemFont(ofSize: 12, weight: .light) + let voteLikeButtonTitleInsets = UIEdgeInsets(top: 4, left: 4, bottom: 0, right: 0) + let voteDislikeButtonTitleInsets = UIEdgeInsets(top: 4, left: 4, bottom: 0, right: 0) } } @@ -67,26 +80,53 @@ final class DiscussionsCellView: UIView { return button }() - private lazy var badgeLabel: UILabel = { + private lazy var userRoleBadgeLabel: UILabel = { let label = WiderLabel() + label.widthDelta = self.appearance.badgeUserRoleWidthDelta label.font = self.appearance.badgeLabelFont - label.textColor = self.appearance.badgeLabelTextColor - label.backgroundColor = self.appearance.badgeLabelBackgroundColor + label.textColor = self.appearance.badgeTintColor + label.backgroundColor = self.appearance.badgeUserRoleBackgroundColor label.textAlignment = .center label.numberOfLines = 1 - - label.layer.cornerRadius = self.appearance.badgeLabelCornerRadius + // Round corners + label.layer.cornerRadius = self.appearance.badgeCornerRadius label.layer.masksToBounds = true label.clipsToBounds = true - return label }() + private lazy var isPinnedImageButton: ImageButton = { + let imageButton = ImageButton() + imageButton.imageSize = self.appearance.badgeIsPinnedImageSize + imageButton.imageInsets = self.appearance.badgeIsPinnedImageInsets + imageButton.titleInsets = self.appearance.badgeIsPinnedTitleInsets + imageButton.tintColor = self.appearance.badgeTintColor + imageButton.font = self.appearance.badgeLabelFont + imageButton.title = NSLocalizedString("DiscussionsIsPinnedBadgeTitle", comment: "") + imageButton.image = UIImage(named: "discussions-pin")?.withRenderingMode(.alwaysTemplate) + imageButton.backgroundColor = self.appearance.badgeIsPinnedBackgroundColor + imageButton.disabledAlpha = 1.0 + imageButton.isEnabled = false + // Round corners + imageButton.layer.cornerRadius = self.appearance.badgeCornerRadius + imageButton.layer.masksToBounds = true + imageButton.clipsToBounds = true + return imageButton + }() + + private lazy var badgesStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [self.userRoleBadgeLabel, self.isPinnedImageButton]) + stackView.axis = .horizontal + stackView.distribution = .fill + stackView.spacing = self.appearance.badgesStackViewSpacing + return stackView + }() + private lazy var dotsMenuImageButton: ImageButton = { let imageButton = ImageButton() imageButton.imageSize = self.appearance.dotsMenuImageSize imageButton.tintColor = self.appearance.dotsMenuImageTintColor - imageButton.image = UIImage(named: "discussions-dots-menu")?.withRenderingMode(.alwaysTemplate) + imageButton.image = UIImage(named: "horizontal-dots-icon")?.withRenderingMode(.alwaysTemplate) imageButton.addTarget(self, action: #selector(self.dotsMenuDidClick), for: .touchUpInside) return imageButton }() @@ -149,46 +189,60 @@ final class DiscussionsCellView: UIView { private lazy var likeImageButton: ImageButton = { let imageButton = ImageButton() - imageButton.imageSize = self.appearance.likeImageSize - imageButton.tintColor = self.appearance.likeImageNormalTintColor - imageButton.font = self.appearance.likeButtonFont + imageButton.imageSize = self.appearance.voteImageSize + imageButton.tintColor = self.appearance.voteImageNormalTintColor + imageButton.font = self.appearance.voteButtonFont imageButton.title = "0" imageButton.image = UIImage(named: "discussions-thumb-up")?.withRenderingMode(.alwaysTemplate) - imageButton.titleInsets = self.appearance.likeButtonTitleInsets + imageButton.titleInsets = self.appearance.voteLikeButtonTitleInsets imageButton.addTarget(self, action: #selector(self.likeDidClick), for: .touchUpInside) return imageButton }() private lazy var dislikeImageButton: ImageButton = { let imageButton = ImageButton() - imageButton.imageSize = self.appearance.likeImageSize - imageButton.tintColor = self.appearance.likeImageNormalTintColor - imageButton.font = self.appearance.likeButtonFont + imageButton.imageSize = self.appearance.voteImageSize + imageButton.tintColor = self.appearance.voteImageNormalTintColor + imageButton.font = self.appearance.voteButtonFont imageButton.title = "0" imageButton.image = UIImage(named: "discussions-thumb-down")?.withRenderingMode(.alwaysTemplate) - imageButton.titleInsets = self.appearance.dislikeButtonTitleInsets + imageButton.titleInsets = self.appearance.voteDislikeButtonTitleInsets imageButton.addTarget(self, action: #selector(self.dislikeDidClick), for: .touchUpInside) return imageButton }() private lazy var bottomControlsStackView: UIStackView = { - let stackView = UIStackView( - arrangedSubviews: [self.dateLabel, self.replyButton, self.likeImageButton, self.dislikeImageButton] - ) - stackView.axis = .horizontal - stackView.distribution = .equalSpacing - stackView.spacing = self.appearance.bottomControlsSpacing - return stackView + let dateAndReplyStackView = UIStackView(arrangedSubviews: [self.dateLabel, self.replyButton]) + dateAndReplyStackView.axis = .horizontal + dateAndReplyStackView.distribution = .fill + dateAndReplyStackView.spacing = self.appearance.bottomControlsSubgroupSpacing + dateAndReplyStackView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + let votesStackView = UIStackView(arrangedSubviews: [self.likeImageButton, self.dislikeImageButton]) + votesStackView.axis = .horizontal + votesStackView.distribution = .fill + votesStackView.spacing = self.appearance.bottomControlsSubgroupSpacing * 2 + + let containerStackView = UIStackView(arrangedSubviews: [dateAndReplyStackView, votesStackView]) + containerStackView.axis = .horizontal + containerStackView.distribution = .equalSpacing + containerStackView.spacing = self.appearance.bottomControlsSpacing + + return containerStackView }() // Dynamically show/hide badge - private var badgeLabelHeightConstraint: Constraint? + private var badgesStackViewHeightConstraint: Constraint? private var nameLabelTopConstraint: Constraint? // Keeps track of web content text view height private var currentWebBasedTextViewHeight = Appearance().textContentWebBasedTextViewDefaultHeight private var currentText: String? + private var isBadgesHidden: Bool { + return self.userRoleBadgeLabel.isHidden && self.isPinnedImageButton.isHidden + } + var onDotsMenuClick: (() -> Void)? var onReplyClick: (() -> Void)? var onLikeClick: (() -> Void)? @@ -220,25 +274,16 @@ final class DiscussionsCellView: UIView { return self.resetViews() } - switch viewModel.userRole { - case .student: - self.updateBadge(text: "", isHidden: true) - case .teacher: - self.updateBadge(text: NSLocalizedString("CourseStaff", comment: ""), isHidden: false) - case .staff: - self.updateBadge(text: NSLocalizedString("Staff", comment: ""), isHidden: false) - } - - self.updateVotes( - likes: viewModel.likesCount, - dislikes: viewModel.dislikesCount, - voteValue: viewModel.voteValue, - canVote: viewModel.canVote - ) - self.nameLabel.text = viewModel.username self.dateLabel.text = viewModel.formattedDate + self.updateBadges(userRole: viewModel.userRole, isPinned: viewModel.isPinned) + self.updateVotes( + likesCount: viewModel.likesCount, + dislikesCount: viewModel.dislikesCount, + canVote: viewModel.canVote, + voteValue: viewModel.voteValue + ) self.updateTextContent(text: viewModel.text, isWebViewSupportNeeded: viewModel.isWebViewSupportNeeded) if let url = viewModel.avatarImageURL { @@ -247,8 +292,8 @@ final class DiscussionsCellView: UIView { } func calculateContentHeight(maxPreferredWidth: CGFloat) -> CGFloat { - let userInfoHeight = (self.badgeLabel.isHidden ? 0 : self.appearance.badgeLabelHeight) - + (self.badgeLabel.isHidden ? 0 : self.appearance.nameLabelInsets.top) + let userInfoHeight = (self.isBadgesHidden ? 0 : self.appearance.badgesStackViewHeight) + + (self.isBadgesHidden ? 0 : self.appearance.nameLabelInsets.top) + self.appearance.nameLabelHeight return self.appearance.avatarImageViewInsets.top + userInfoHeight @@ -262,34 +307,53 @@ final class DiscussionsCellView: UIView { // MARK: - Private API private func resetViews() { - self.updateBadge(text: "", isHidden: true) self.nameLabel.text = nil self.dateLabel.text = nil - self.updateVotes(likes: 0, dislikes: 0, voteValue: nil, canVote: false) self.avatarImageView.reset() + self.updateBadges(userRole: .student, isPinned: false) + self.updateVotes(likesCount: 0, dislikesCount: 0, canVote: false, voteValue: nil) self.updateTextContent(text: "", isWebViewSupportNeeded: false) } - private func updateBadge(text: String, isHidden: Bool) { - self.badgeLabel.text = text - self.badgeLabel.isHidden = isHidden - self.badgeLabelHeightConstraint?.update(offset: isHidden ? 0 : self.appearance.badgeLabelHeight) - self.nameLabelTopConstraint?.update(offset: isHidden ? 0 : self.appearance.nameLabelInsets.top) + private func updateBadges(userRole: UserRole, isPinned: Bool) { + switch userRole { + case .student: + self.userRoleBadgeLabel.text = "" + self.userRoleBadgeLabel.isHidden = true + case .teacher: + self.userRoleBadgeLabel.text = NSLocalizedString("CourseStaff", comment: "") + self.userRoleBadgeLabel.isHidden = false + case .staff: + self.userRoleBadgeLabel.text = NSLocalizedString("Staff", comment: "") + self.userRoleBadgeLabel.isHidden = false + } + + self.isPinnedImageButton.isHidden = !isPinned + + self.badgesStackViewHeightConstraint?.update( + offset: self.isBadgesHidden ? 0 : self.appearance.badgesStackViewHeight + ) + self.nameLabelTopConstraint?.update(offset: self.isBadgesHidden ? 0 : self.appearance.nameLabelInsets.top) } - private func updateVotes(likes: Int, dislikes: Int, voteValue: VoteValue?, canVote: Bool) { - self.likeImageButton.title = "\(likes)" - self.dislikeImageButton.title = "\(dislikes)" + private func updateVotes(likesCount: Int, dislikesCount: Int, canVote: Bool, voteValue: VoteValue?) { + self.likeImageButton.title = "\(likesCount)" + self.dislikeImageButton.title = "\(dislikesCount)" if let voteValue = voteValue { if voteValue == .epic { - self.likeImageButton.tintColor = self.appearance.likeImageFilledTintColor + self.likeImageButton.tintColor = self.appearance.voteImageFilledTintColor + self.dislikeImageButton.tintColor = self.appearance.voteImageNormalTintColor } else { - self.dislikeImageButton.tintColor = self.appearance.likeImageFilledTintColor + self.dislikeImageButton.tintColor = self.appearance.voteImageFilledTintColor + self.likeImageButton.tintColor = self.appearance.voteImageNormalTintColor } + } else if canVote { + self.likeImageButton.tintColor = self.appearance.voteImageNormalTintColor + self.dislikeImageButton.tintColor = self.appearance.voteImageNormalTintColor } else { - self.likeImageButton.tintColor = self.appearance.likeImageNormalTintColor - self.dislikeImageButton.tintColor = self.appearance.likeImageNormalTintColor + self.likeImageButton.tintColor = self.appearance.voteImageDisabledTintColor + self.dislikeImageButton.tintColor = self.appearance.voteImageDisabledTintColor } self.likeImageButton.isEnabled = canVote @@ -372,7 +436,7 @@ extension DiscussionsCellView: ProgrammaticallyInitializableViewProtocol { func addSubviews() { self.addSubview(self.avatarImageView) self.addSubview(self.avatarOverlayButton) - self.addSubview(self.badgeLabel) + self.addSubview(self.badgesStackView) self.addSubview(self.dotsMenuImageButton) self.addSubview(self.nameLabel) self.addSubview(self.textContentStackView) @@ -392,11 +456,11 @@ extension DiscussionsCellView: ProgrammaticallyInitializableViewProtocol { make.edges.equalTo(self.avatarImageView) } - self.badgeLabel.translatesAutoresizingMaskIntoConstraints = false - self.badgeLabel.snp.makeConstraints { make in - make.leading.equalTo(self.avatarImageView.snp.trailing).offset(self.appearance.badgeLabelInsets.left) + self.badgesStackView.translatesAutoresizingMaskIntoConstraints = false + self.badgesStackView.snp.makeConstraints { make in + make.leading.equalTo(self.avatarImageView.snp.trailing).offset(self.appearance.badgeStackViewInsets.left) make.top.equalTo(self.avatarImageView.snp.top) - self.badgeLabelHeightConstraint = make.height.equalTo(self.appearance.badgeLabelHeight).constraint + self.badgesStackViewHeightConstraint = make.height.equalTo(self.appearance.badgesStackViewHeight).constraint } self.dotsMenuImageButton.translatesAutoresizingMaskIntoConstraints = false @@ -410,7 +474,7 @@ extension DiscussionsCellView: ProgrammaticallyInitializableViewProtocol { self.nameLabel.snp.makeConstraints { make in make.leading.equalTo(self.avatarImageView.snp.trailing).offset(self.appearance.nameLabelInsets.left) self.nameLabelTopConstraint = make.top - .equalTo(self.badgeLabel.snp.bottom) + .equalTo(self.userRoleBadgeLabel.snp.bottom) .offset(self.appearance.nameLabelInsets.top) .constraint make.trailing.equalToSuperview().offset(-self.appearance.nameLabelInsets.right) diff --git a/Stepic/Sources/Modules/Discussions/Views/Cell/DiscussionsTableViewCell.swift b/Stepic/Sources/Modules/Discussions/Views/Cell/DiscussionsTableViewCell.swift index c01e08bb97..96068a2c8b 100644 --- a/Stepic/Sources/Modules/Discussions/Views/Cell/DiscussionsTableViewCell.swift +++ b/Stepic/Sources/Modules/Discussions/Views/Cell/DiscussionsTableViewCell.swift @@ -1,6 +1,8 @@ import SnapKit import UIKit +// MARK: Appearance - + extension DiscussionsTableViewCell { enum Appearance { static let separatorColor = UIColor(hex: 0xE7E7E7) @@ -8,8 +10,9 @@ extension DiscussionsTableViewCell { static let selectedBackgroundColor = UIColor(hex: 0xE9EBFA) static let defaultBackgroundColor = UIColor.white - static let leadingSpaceDiscussion: CGFloat = 0 - static let leadingSpaceReply: CGFloat = 40 + static let leadingOffsetDiscussion: CGFloat = 0 + static let leadingOffsetReply: CGFloat = 18 + static let leadingOffsetCellView: CGFloat = DiscussionsCellView.Appearance().avatarImageViewInsets.left } } @@ -40,11 +43,12 @@ final class DiscussionsTableViewCell: UITableViewCell, Reusable { self?.onContentLoaded?() } cellView.onNewHeightUpdate = { [weak self] in - if let strongSelf = self { - strongSelf.onNewHeightUpdate?( - strongSelf.calculateCellHeight(maxPreferredWidth: strongSelf.cellView.bounds.width) - ) + guard let strongSelf = self else { + return } + + let newHeight = strongSelf.calculateCellHeight(maxPreferredWidth: strongSelf.cellView.bounds.width) + strongSelf.onNewHeightUpdate?(newHeight) } return cellView }() @@ -55,13 +59,13 @@ final class DiscussionsTableViewCell: UITableViewCell, Reusable { return view }() - // Dynamic cell/separator leading space - private var cellLeadingConstraint: Constraint? + // Dynamic cell/separator leading offset + private var cellViewLeadingConstraint: Constraint? private var separatorLeadingConstraint: Constraint? // Dynamic separator height private var separatorHeightConstraint: Constraint? - private var separatorType: ViewModel.SeparatorType = .small + private var separatorStyle: ViewModel.SeparatorStyle = .small var onDotsMenuClick: (() -> Void)? var onReplyClick: (() -> Void)? @@ -69,6 +73,7 @@ final class DiscussionsTableViewCell: UITableViewCell, Reusable { var onDislikeClick: (() -> Void)? var onAvatarClick: (() -> Void)? var onLinkClick: ((URL) -> Void)? + // Content callbacks var onContentLoaded: (() -> Void)? var onNewHeightUpdate: ((CGFloat) -> Void)? @@ -82,22 +87,31 @@ final class DiscussionsTableViewCell: UITableViewCell, Reusable { override func prepareForReuse() { super.prepareForReuse() - self.configure(optionalViewModel: nil) + self.resetViews() } // MARK: - Public API func configure(viewModel: ViewModel) { - self.configure(optionalViewModel: viewModel) + self.updateLeadingOffsets( + commentType: viewModel.commentType, + hasReplies: viewModel.comment.hasReplies, + separatorFollowsDepth: viewModel.separatorFollowsDepth + ) + self.updateSeparator(newStyle: viewModel.separatorStyle) + self.cellView.configure(viewModel: viewModel.comment) + self.backgroundColor = viewModel.isSelected + ? Appearance.selectedBackgroundColor + : Appearance.defaultBackgroundColor } func calculateCellHeight(maxPreferredWidth: CGFloat) -> CGFloat { - let leadingInset = self.cellLeadingConstraint?.layoutConstraints.first?.constant ?? 0 + let leadingOffset = self.cellViewLeadingConstraint?.layoutConstraints.first?.constant ?? 0 - let cellViewWidth = maxPreferredWidth - leadingInset + let cellViewWidth = maxPreferredWidth - leadingOffset let cellViewHeight = self.cellView.calculateContentHeight(maxPreferredWidth: cellViewWidth) - return cellViewHeight + self.separatorType.height + return cellViewHeight + self.separatorStyle.height } // MARK: - Private API @@ -111,9 +125,9 @@ final class DiscussionsTableViewCell: UITableViewCell, Reusable { self.cellView.translatesAutoresizingMaskIntoConstraints = false self.cellView.snp.makeConstraints { make in - self.cellLeadingConstraint = make.leading + self.cellViewLeadingConstraint = make.leading .equalToSuperview() - .offset(Appearance.leadingSpaceDiscussion) + .offset(Appearance.leadingOffsetDiscussion) .constraint make.top.trailing.equalToSuperview() } @@ -122,51 +136,48 @@ final class DiscussionsTableViewCell: UITableViewCell, Reusable { self.separatorView.snp.makeConstraints { make in self.separatorLeadingConstraint = make.leading .equalToSuperview() - .offset(Appearance.leadingSpaceDiscussion) + .offset(Appearance.leadingOffsetDiscussion) .constraint make.top.equalTo(self.cellView.snp.bottom) make.trailing.equalToSuperview() make.bottom.equalToSuperview().priority(999) - self.separatorHeightConstraint = make.height.equalTo(self.separatorType.height).constraint + self.separatorHeightConstraint = make.height.equalTo(self.separatorStyle.height).constraint } } - private func configure(optionalViewModel: ViewModel?) { - if let viewModel = optionalViewModel { - self.backgroundColor = viewModel.isSelected - ? Appearance.selectedBackgroundColor - : Appearance.defaultBackgroundColor - self.updateLeadingInsets( - commentType: viewModel.commentType, - separatorFollowsDepth: viewModel.separatorFollowsDepth - ) - self.updateSeparatorType(separatorType: viewModel.separatorType) - self.cellView.configure(viewModel: viewModel.comment) - } else { - self.backgroundColor = Appearance.defaultBackgroundColor - self.updateLeadingInsets(commentType: .discussion, separatorFollowsDepth: false) - self.updateSeparatorType(separatorType: .small) - self.cellView.configure(viewModel: nil) - } + private func resetViews() { + self.updateLeadingOffsets(commentType: .discussion, hasReplies: false, separatorFollowsDepth: false) + self.updateSeparator(newStyle: .small) + self.backgroundColor = Appearance.defaultBackgroundColor + self.cellView.configure(viewModel: nil) } - private func updateLeadingInsets(commentType: ViewModel.CommentType, separatorFollowsDepth: Bool) { - let leadingSpaceValue = commentType == .discussion - ? Appearance.leadingSpaceDiscussion - : Appearance.leadingSpaceReply - self.cellLeadingConstraint?.update(offset: leadingSpaceValue) - self.separatorLeadingConstraint?.update( - offset: separatorFollowsDepth ? leadingSpaceValue : Appearance.leadingSpaceDiscussion - ) - } + private func updateLeadingOffsets( + commentType: ViewModel.CommentType, + hasReplies: Bool, + separatorFollowsDepth: Bool + ) { + let cellViewLeadingOffset = commentType == .discussion + ? Appearance.leadingOffsetDiscussion + : Appearance.leadingOffsetReply + self.cellViewLeadingConstraint?.update(offset: cellViewLeadingOffset) + + let separatorLeadingOffset: CGFloat = { + if commentType == .discussion && hasReplies { + return Appearance.leadingOffsetReply + Appearance.leadingOffsetCellView + } + return separatorFollowsDepth + ? (cellViewLeadingOffset + Appearance.leadingOffsetCellView) + : Appearance.leadingOffsetDiscussion + }() - private func updateSeparatorType(separatorType: ViewModel.SeparatorType) { - if separatorType != self.separatorType { - self.separatorType = separatorType - self.separatorHeightConstraint?.update(offset: self.separatorType.height) - } + self.separatorLeadingConstraint?.update(offset: separatorLeadingOffset) + } - self.separatorView.isHidden = self.separatorType == .none + private func updateSeparator(newStyle style: ViewModel.SeparatorStyle) { + self.separatorStyle = style + self.separatorHeightConstraint?.update(offset: style.height) + self.separatorView.isHidden = style == .none } // MARK: - Types @@ -174,16 +185,16 @@ final class DiscussionsTableViewCell: UITableViewCell, Reusable { struct ViewModel { let comment: DiscussionsCommentViewModel let commentType: CommentType - let separatorType: SeparatorType - let separatorFollowsDepth: Bool let isSelected: Bool + let separatorStyle: SeparatorStyle + let separatorFollowsDepth: Bool enum CommentType { case discussion case reply } - enum SeparatorType { + enum SeparatorStyle { case small case large case none @@ -191,9 +202,9 @@ final class DiscussionsTableViewCell: UITableViewCell, Reusable { var height: CGFloat { switch self { case .small: - return 0.5 + return 1.0 / UIScreen.main.scale case .large: - return 4.0 + return 8.0 / UIScreen.main.scale case .none: return 0.0 } diff --git a/Stepic/Sources/Modules/Discussions/Views/DiscussionsTableViewDataSource.swift b/Stepic/Sources/Modules/Discussions/Views/DiscussionsTableViewDataSource.swift index 22abd6700c..db8070f4d0 100644 --- a/Stepic/Sources/Modules/Discussions/Views/DiscussionsTableViewDataSource.swift +++ b/Stepic/Sources/Modules/Discussions/Views/DiscussionsTableViewDataSource.swift @@ -1,5 +1,7 @@ import UIKit +// MARK: DiscussionsTableViewDataSourceDelegate: class - + protocol DiscussionsTableViewDataSourceDelegate: class { func discussionsTableViewDataSource( _ tableViewDataSource: DiscussionsTableViewDataSource, @@ -138,18 +140,6 @@ extension DiscussionsTableViewDataSource: UITableViewDataSource { let commentType: DiscussionsTableViewCell.ViewModel.CommentType = indexPath.row == DiscussionsTableViewDataSource.parentDiscussionRowIndex ? .discussion : .reply - let separatorType: DiscussionsTableViewCell.ViewModel.SeparatorType = { - if indexPath.row == tableView.numberOfRows(inSection: indexPath.section) - 1 { - if discussionViewModel.repliesLeftToLoadCount > 0 { - return .none - } else if indexPath.section == tableView.numberOfSections - 1 { - return .small - } - return .large - } - return .small - }() - let commentViewModel = commentType == .discussion ? discussionViewModel.comment : discussionViewModel.replies[indexPath.row - DiscussionsTableViewDataSource.parentDiscussionInset] @@ -201,6 +191,17 @@ extension DiscussionsTableViewDataSource: UITableViewDataSource { } } + let separatorStyle: DiscussionsTableViewCell.ViewModel.SeparatorStyle = { + if indexPath.row == tableView.numberOfRows(inSection: indexPath.section) - 1 { + if discussionViewModel.repliesLeftToLoadCount > 0 { + return .none + } else if indexPath.section == tableView.numberOfSections - 1 { + return .small + } + return .large + } + return .small + }() let isLastComment = indexPath.row == tableView.numberOfRows(inSection: indexPath.section) - self.loadMoreRepliesInset(section: indexPath.section) - 1 @@ -208,9 +209,9 @@ extension DiscussionsTableViewDataSource: UITableViewDataSource { viewModel: .init( comment: commentViewModel, commentType: commentType, - separatorType: separatorType, - separatorFollowsDepth: !isLastComment, - isSelected: commentViewModel.isSelected + isSelected: commentViewModel.isSelected, + separatorStyle: separatorStyle, + separatorFollowsDepth: !isLastComment ) ) diff --git a/Stepic/Sources/Modules/Discussions/Views/DiscussionsView.swift b/Stepic/Sources/Modules/Discussions/Views/DiscussionsView.swift index e3359b87df..6fd41433b3 100644 --- a/Stepic/Sources/Modules/Discussions/Views/DiscussionsView.swift +++ b/Stepic/Sources/Modules/Discussions/Views/DiscussionsView.swift @@ -127,7 +127,7 @@ final class DiscussionsView: UIView { func showLoading() { self.isSkeletonVisible = true self.tableView.skeleton.viewBuilder = { - CourseInfoTabReviewsSkeletonView() + DiscussionsSkeletonView() } self.tableView.skeleton.show() } diff --git a/Stepic/Sources/Modules/NewStep/NewStepDataFlow.swift b/Stepic/Sources/Modules/NewStep/NewStepDataFlow.swift index 1d51d7031d..b1245f54fc 100644 --- a/Stepic/Sources/Modules/NewStep/NewStepDataFlow.swift +++ b/Stepic/Sources/Modules/NewStep/NewStepDataFlow.swift @@ -80,6 +80,35 @@ enum NewStep { } } + /// Update discussions button (on appear) + enum DiscussionsButtonUpdate { + struct Request { } + + struct Response { + let step: Step + } + + struct ViewModel { + let title: String + let isEnabled: Bool + } + } + + /// Prsent discussions module (list or with write comment on top on empty discussions empty state) + enum DiscussionsPresentation { + struct Request { } + + struct Response { + let step: Step + } + + struct ViewModel { + let discussionProxyID: DiscussionProxy.IdType + let stepID: Step.IdType + let embeddedInWriteComment: Bool + } + } + // MARK: Enums enum ViewControllerState { diff --git a/Stepic/Sources/Modules/NewStep/NewStepInteractor.swift b/Stepic/Sources/Modules/NewStep/NewStepInteractor.swift index aebea4efb9..092e3d6f06 100644 --- a/Stepic/Sources/Modules/NewStep/NewStepInteractor.swift +++ b/Stepic/Sources/Modules/NewStep/NewStepInteractor.swift @@ -7,6 +7,8 @@ protocol NewStepInteractorProtocol { func doStepNavigationRequest(request: NewStep.StepNavigationRequest.Request) func doStepViewRequest(request: NewStep.StepViewRequest.Request) func doStepDoneRequest(request: NewStep.StepDoneRequest.Request) + func doDiscussionsButtonUpdate(request: NewStep.DiscussionsButtonUpdate.Request) + func doDiscussionsPresentation(request: NewStep.DiscussionsPresentation.Request) } final class NewStepInteractor: NewStepInteractorProtocol { @@ -118,6 +120,24 @@ final class NewStepInteractor: NewStepInteractorProtocol { self.moduleOutput?.handleStepDone(id: self.stepID) } + func doDiscussionsButtonUpdate(request: NewStep.DiscussionsButtonUpdate.Request) { + self.provider.fetchCachedStep(id: self.stepID).done { cachedStep in + if let cachedStep = cachedStep { + self.presenter.presentDiscussionsButtonUpdate(response: .init(step: cachedStep)) + } + }.cauterize() + } + + func doDiscussionsPresentation(request: NewStep.DiscussionsPresentation.Request) { + self.provider.fetchCachedStep(id: self.stepID).done { cachedStep in + if let cachedStep = cachedStep { + self.presenter.presentDiscussions(response: .init(step: cachedStep)) + } + }.cauterize() + } + + // MARK: - Types + enum Error: Swift.Error { case fetchFailed } diff --git a/Stepic/Sources/Modules/NewStep/NewStepPresenter.swift b/Stepic/Sources/Modules/NewStep/NewStepPresenter.swift index a18eee8579..a6d387b175 100644 --- a/Stepic/Sources/Modules/NewStep/NewStepPresenter.swift +++ b/Stepic/Sources/Modules/NewStep/NewStepPresenter.swift @@ -5,6 +5,8 @@ protocol NewStepPresenterProtocol { func presentStep(response: NewStep.StepLoad.Response) func presentStepTextUpdate(response: NewStep.StepTextUpdate.Response) func presentControlsUpdate(response: NewStep.ControlsUpdate.Response) + func presentDiscussionsButtonUpdate(response: NewStep.DiscussionsButtonUpdate.Response) + func presentDiscussions(response: NewStep.DiscussionsPresentation.Response) } final class NewStepPresenter: NewStepPresenterProtocol { @@ -32,7 +34,7 @@ final class NewStepPresenter: NewStepPresenterProtocol { } func presentStepTextUpdate(response: NewStep.StepTextUpdate.Response) { - let htmlString = NewStepPresenter.makeProcessedContentHTMLString( + let htmlString = self.makeProcessedContentHTMLString( response.text, fontSize: response.fontSize ) @@ -50,20 +52,33 @@ final class NewStepPresenter: NewStepPresenterProtocol { self.viewController?.displayControlsUpdate(viewModel: viewModel) } + func presentDiscussionsButtonUpdate(response: NewStep.DiscussionsButtonUpdate.Response) { + self.viewController?.displayDiscussionsButtonUpdate( + viewModel: .init( + title: self.makeDiscussionsLabelTitle(step: response.step), + isEnabled: response.step.discussionProxyId != nil + ) + ) + } + + func presentDiscussions(response: NewStep.DiscussionsPresentation.Response) { + guard let discussionProxyID = response.step.discussionProxyId else { + return + } + + self.viewController?.displayDiscussions( + viewModel: .init( + discussionProxyID: discussionProxyID, + stepID: response.step.id, + embeddedInWriteComment: (response.step.discussionsCount ?? 0) == 0 + ) + ) + } + // MARK: Private API private func makeViewModel(step: Step, fontSize: FontSize) -> Guarantee { return Guarantee { seal in - let discussionsLabelTitle: String = { - if let discussionsCount = step.discussionsCount, discussionsCount > 0 { - return String( - format: NSLocalizedString("DiscussionsButtonTitle", comment: ""), - FormatterHelper.longNumber(discussionsCount) - ) - } - return NSLocalizedString("NoDiscussionsButtonTitle", comment: "") - }() - let contentType: NewStepViewModel.ContentType = { switch step.block.type { case .video: @@ -76,7 +91,7 @@ final class NewStepPresenter: NewStepPresenterProtocol { } return .video(viewModel: nil) default: - let htmlString = NewStepPresenter.makeProcessedContentHTMLString( + let htmlString = self.makeProcessedContentHTMLString( step.block.text ?? "", fontSize: fontSize ) @@ -92,22 +107,40 @@ final class NewStepPresenter: NewStepPresenterProtocol { quizType = NewStep.QuizType(blockName: step.block.name) } + let discussionsLabelTitle = self.makeDiscussionsLabelTitle(step: step) let urlPath = "\(StepicApplicationsInfo.stepicURL)/lesson/\(step.lessonId)/step/\(step.position)?from_mobile_app=true" let viewModel = NewStepViewModel( content: contentType, quizType: quizType, discussionsLabelTitle: discussionsLabelTitle, + isDiscussionsEnabled: step.discussionProxyId != nil, discussionProxyID: step.discussionProxyId, stepURLPath: urlPath, lessonID: step.lessonId, step: step ) + seal(viewModel) } } - private static func makeProcessedContentHTMLString(_ text: String, fontSize: FontSize) -> String { + private func makeDiscussionsLabelTitle(step: Step) -> String { + if step.discussionProxyId == nil { + return NSLocalizedString("DisabledDiscussionsButtonTitle", comment: "") + } + + if let discussionsCount = step.discussionsCount, discussionsCount > 0 { + return String( + format: NSLocalizedString("DiscussionsButtonTitle", comment: ""), + FormatterHelper.longNumber(discussionsCount) + ) + } + + return NSLocalizedString("NoDiscussionsButtonTitle", comment: "") + } + + private func makeProcessedContentHTMLString(_ text: String, fontSize: FontSize) -> String { var injections = ContentProcessor.defaultInjections injections.append(FontSizeInjection(fontSize: fontSize)) diff --git a/Stepic/Sources/Modules/NewStep/NewStepProvider.swift b/Stepic/Sources/Modules/NewStep/NewStepProvider.swift index 3288e61998..116d07e1c4 100644 --- a/Stepic/Sources/Modules/NewStep/NewStepProvider.swift +++ b/Stepic/Sources/Modules/NewStep/NewStepProvider.swift @@ -1,11 +1,16 @@ import Foundation import PromiseKit +// MARK: NewStepProviderProtocol - + protocol NewStepProviderProtocol { func fetchStep(id: Step.IdType) -> Promise> + func fetchCachedStep(id: Step.IdType) -> Promise func fetchCurrentFontSize() -> Guarantee } +// MARK: - NewStepProvider: NewStepProviderProtocol - + final class NewStepProvider: NewStepProviderProtocol { private let stepsPersistenceService: StepsPersistenceServiceProtocol private let stepsNetworkService: StepsNetworkServiceProtocol @@ -45,6 +50,16 @@ final class NewStepProvider: NewStepProviderProtocol { } } + func fetchCachedStep(id: Step.IdType) -> Promise { + return Promise { seal in + self.stepsPersistenceService.fetch(ids: [id]).done { cachedSteps in + seal.fulfill(cachedSteps.first) + }.catch { _ in + seal.reject(Error.fetchFailed) + } + } + } + func fetchCurrentFontSize() -> Guarantee { return Guarantee { seal in seal(self.stepFontSizeService.globalStepFontSize) diff --git a/Stepic/Sources/Modules/NewStep/NewStepView.swift b/Stepic/Sources/Modules/NewStep/NewStepView.swift index d9afccc2c7..c9955cfdd4 100644 --- a/Stepic/Sources/Modules/NewStep/NewStepView.swift +++ b/Stepic/Sources/Modules/NewStep/NewStepView.swift @@ -138,8 +138,7 @@ final class NewStepView: UIView { self.stepTextView.loadHTMLText(htmlString) } - self.stepControlsView.isDiscussionsButtonHidden = viewModel.discussionProxyID == nil - self.stepControlsView.discussionsTitle = viewModel.discussionsLabelTitle + self.updateDiscussionButton(title: viewModel.discussionsLabelTitle, isEnabled: viewModel.isDiscussionsEnabled) guard let quizView = quizView else { return @@ -172,6 +171,11 @@ final class NewStepView: UIView { } } + func updateDiscussionButton(title: String, isEnabled: Bool) { + self.stepControlsView.discussionsTitle = title + self.stepControlsView.isDiscussionsButtonEnabled = isEnabled + } + // MARK: Private API private func positionVideoPreview() { diff --git a/Stepic/Sources/Modules/NewStep/NewStepViewController.swift b/Stepic/Sources/Modules/NewStep/NewStepViewController.swift index c857902348..53a6034384 100644 --- a/Stepic/Sources/Modules/NewStep/NewStepViewController.swift +++ b/Stepic/Sources/Modules/NewStep/NewStepViewController.swift @@ -1,12 +1,18 @@ import Agrume import UIKit +// MARK: NewStepViewControllerProtocol: class - + protocol NewStepViewControllerProtocol: class { func displayStep(viewModel: NewStep.StepLoad.ViewModel) func displayStepTextUpdate(viewModel: NewStep.StepTextUpdate.ViewModel) func displayControlsUpdate(viewModel: NewStep.ControlsUpdate.ViewModel) + func displayDiscussionsButtonUpdate(viewModel: NewStep.DiscussionsButtonUpdate.ViewModel) + func displayDiscussions(viewModel: NewStep.DiscussionsPresentation.ViewModel) } +// MARK: - NewStepViewController: UIViewController, ControllerWithStepikPlaceholder - + final class NewStepViewController: UIViewController, ControllerWithStepikPlaceholder { private static let stepPassedDelay: TimeInterval = 1.0 @@ -70,6 +76,14 @@ final class NewStepViewController: UIViewController, ControllerWithStepikPlaceho } } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + if !self.isFirstAppearance { + self.interactor.doDiscussionsButtonUpdate(request: .init()) + } + } + override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) @@ -178,6 +192,48 @@ extension NewStepViewController: NewStepViewControllerProtocol { ) self.canNavigateToNextStep = viewModel.canNavigateToNextStep } + + func displayDiscussionsButtonUpdate(viewModel: NewStep.DiscussionsButtonUpdate.ViewModel) { + self.newStepView?.updateDiscussionButton(title: viewModel.title, isEnabled: viewModel.isEnabled) + } + + func displayDiscussions(viewModel: NewStep.DiscussionsPresentation.ViewModel) { + let discussionsAssembly = DiscussionsAssembly( + discussionProxyID: viewModel.discussionProxyID, + stepID: viewModel.stepID + ) + let discussionsViewController = discussionsAssembly.makeModule() + + if viewModel.embeddedInWriteComment { + let writeCommentAssembly = WriteCommentAssembly( + targetID: viewModel.stepID, + parentID: nil, + presentationContext: .create, + output: discussionsAssembly.moduleInput + ) + let writeCommentNavigationController = StyledNavigationController( + rootViewController: writeCommentAssembly.makeModule() + ) + + self.navigationController?.present( + writeCommentNavigationController, + animated: true, + completion: { [weak self] in + guard let strongSelf = self, + let navigationController = strongSelf.navigationController else { + return + } + + navigationController.setViewControllers( + navigationController.viewControllers + [discussionsViewController], + animated: false + ) + } + ) + } else { + self.push(module: discussionsViewController) + } + } } // MARK: - NewStepViewController: NewStepViewDelegate - @@ -218,13 +274,7 @@ extension NewStepViewController: NewStepViewDelegate { } func newStepViewDidRequestDiscussions(_ view: NewStepView) { - guard case .result(let viewModel) = self.state, - let discussionProxyID = viewModel.discussionProxyID else { - return - } - - let assembly = DiscussionsAssembly(discussionProxyID: discussionProxyID, stepID: viewModel.step.id) - self.push(module: assembly.makeModule()) + self.interactor.doDiscussionsPresentation(request: .init()) } func newStepView(_ view: NewStepView, didRequestOpenURL url: URL) { diff --git a/Stepic/Sources/Modules/NewStep/NewStepViewModel.swift b/Stepic/Sources/Modules/NewStep/NewStepViewModel.swift index 3e9856c229..ac550cf9e8 100644 --- a/Stepic/Sources/Modules/NewStep/NewStepViewModel.swift +++ b/Stepic/Sources/Modules/NewStep/NewStepViewModel.swift @@ -4,6 +4,7 @@ struct NewStepViewModel { let content: ContentType let quizType: NewStep.QuizType? let discussionsLabelTitle: String + let isDiscussionsEnabled: Bool let discussionProxyID: DiscussionProxy.IdType? let stepURLPath: String let lessonID: Lesson.IdType diff --git a/Stepic/Sources/Modules/NewStep/Views/StepControlsView.swift b/Stepic/Sources/Modules/NewStep/Views/StepControlsView.swift index 7ebdb04250..54ead6bfa6 100644 --- a/Stepic/Sources/Modules/NewStep/Views/StepControlsView.swift +++ b/Stepic/Sources/Modules/NewStep/Views/StepControlsView.swift @@ -74,6 +74,12 @@ final class StepControlsView: UIView { } } + var isDiscussionsButtonEnabled: Bool = true { + didSet { + self.discussionsButton.isEnabled = self.isDiscussionsButtonEnabled + } + } + var isDiscussionsButtonHidden: Bool = false { didSet { self.updateDiscussionsButton() diff --git a/Stepic/Sources/Views/ImageButton.swift b/Stepic/Sources/Views/ImageButton.swift index 13f6b748f5..36e62e39e4 100644 --- a/Stepic/Sources/Views/ImageButton.swift +++ b/Stepic/Sources/Views/ImageButton.swift @@ -69,6 +69,9 @@ final class ImageButton: UIControl { } } + // To be able to prevent alpha being changed on isEnabled state changes. + var disabledAlpha: CGFloat = 0.5 + // To store private titleLabel // but sometimes we want to get direct reference to title view var titleContentView: UIView { @@ -93,7 +96,7 @@ final class ImageButton: UIControl { self.alpha = 0.3 } else { UIView.animate(withDuration: 0.25) { - self.alpha = self.isEnabled ? 1.0 : 0.5 + self.alpha = self.isEnabled ? 1.0 : self.disabledAlpha } } } @@ -101,7 +104,7 @@ final class ImageButton: UIControl { override var isEnabled: Bool { didSet { - self.alpha = self.isEnabled ? 1.0 : 0.5 + self.alpha = self.isEnabled ? 1.0 : self.disabledAlpha } } diff --git a/Stepic/StepikPlaceholderStyle+Placeholders.swift b/Stepic/StepikPlaceholderStyle+Placeholders.swift index 4381609117..6e57cd3fe1 100644 --- a/Stepic/StepikPlaceholderStyle+Placeholders.swift +++ b/Stepic/StepikPlaceholderStyle+Placeholders.swift @@ -93,8 +93,8 @@ extension StepikPlaceholder.Style { static let emptyDiscussions = StepikPlaceholderStyle( id: "emptyDiscussions", image: PlaceholderImage(image: #imageLiteral(resourceName: "new-empty-empty"), scale: 0.99), - text: NSLocalizedString("NoDiscussionsTitle", comment: ""), - buttonTitle: nil + text: NSLocalizedString("PlaceholderNoDiscussionsTitle", comment: ""), + buttonTitle: NSLocalizedString("PlaceholderNoDiscussionsButtonTitle", comment: "") ) static let emptyDiscussionsLoading = StepikPlaceholderStyle( id: "emptyDiscussionsLoading", diff --git a/Stepic/en.lproj/Localizable.strings b/Stepic/en.lproj/Localizable.strings index 47216995b3..b5247986d9 100644 --- a/Stepic/en.lproj/Localizable.strings +++ b/Stepic/en.lproj/Localizable.strings @@ -121,7 +121,8 @@ Discussions = "Discussions"; ShowMoreReplies = "More replies"; ShowMoreDiscussions = "More discussions"; RefreshingDiscussions = "Refreshing discussions..."; -NoDiscussionsTitle = "No discussions. Start the first one!"; +PlaceholderNoDiscussionsTitle = "No discussions. Start the first one!"; +PlaceholderNoDiscussionsButtonTitle = "Leave a comment"; Syllabus = "Syllabus"; Course = "Course"; NextLesson = "Next lesson"; @@ -712,8 +713,9 @@ WriteCourseReviewActionNotAllowedDescription = "To write a review, complete more /* New lesson & step */ NextLessonNavigation = "Next lesson"; PreviousLessonNavigation = "Previous lesson"; -DiscussionsButtonTitle = "Show discussions (%@)"; +DiscussionsButtonTitle = "Show comments (%@)"; NoDiscussionsButtonTitle = "Leave a comment"; +DisabledDiscussionsButtonTitle = "Comments disabled"; LessonTooltipPointsWithScoreTitle = "You got: %@ out of %@ for step"; LessonTooltipPointsTitle = "You will get: %@ for step"; LessonTooltipTimeToCompleteTitle = "%@ for lesson"; @@ -776,7 +778,7 @@ CodeQuizFullscreenTabCodeTitle = "Code"; CodeQuizFullscreenTabRunTitle = "Run"; /* Discussions */ -DiscussionsTitle = "Discussions"; +DiscussionsTitle = "Comments"; DiscussionsAlertActionEditTitle = "Edit"; DiscussionsAlertActionDeleteTitle = "Delete"; DiscussionsAlertActionLikeTitle = "Like"; @@ -788,6 +790,7 @@ DiscussionsSortTypeLastDiscussions = "Last discussions"; DiscussionsSortTypeMostLikedDiscussions = "Most liked"; DiscussionsSortTypeMostActiveDiscussions = "Most active"; DiscussionsSortTypeRecentActivityDiscussions = "Recent activity"; +DiscussionsIsPinnedBadgeTitle = "Pinned"; /* Write comment */ WriteCommentTitle = "Comment"; diff --git a/Stepic/ru.lproj/Localizable.strings b/Stepic/ru.lproj/Localizable.strings index 4cc7dc79c5..51732ce03e 100644 --- a/Stepic/ru.lproj/Localizable.strings +++ b/Stepic/ru.lproj/Localizable.strings @@ -121,7 +121,8 @@ Discussions = "Комментарии"; ShowMoreReplies = "Больше ответов"; ShowMoreDiscussions = "Больше комментариев"; RefreshingDiscussions = "Обновляем комментарии..."; -NoDiscussionsTitle = "Обсуждений нет. Начните первое!"; +PlaceholderNoDiscussionsTitle = "Обсуждений нет. Начните первое!"; +PlaceholderNoDiscussionsButtonTitle = "Написать комментарий"; Syllabus = "Модули"; Course = "Курс"; NextLesson = "Следующий урок"; @@ -715,6 +716,7 @@ NextLessonNavigation = "Следующий урок"; PreviousLessonNavigation = "Предыдущий урок"; DiscussionsButtonTitle = "Показать комментарии (%@)"; NoDiscussionsButtonTitle = "Напишите комментарий"; +DisabledDiscussionsButtonTitle = "Комментарии отключены"; LessonTooltipPointsWithScoreTitle = "Вы получили: %@ из %@ за шаг"; LessonTooltipPointsTitle = "Вы получите: %@ за шаг"; LessonTooltipTimeToCompleteTitle = "%@ на урок"; @@ -789,6 +791,7 @@ DiscussionsSortTypeLastDiscussions = "Новые обсуждения"; DiscussionsSortTypeMostLikedDiscussions = "Самые популярные"; DiscussionsSortTypeMostActiveDiscussions = "Самые обсуждаемые"; DiscussionsSortTypeRecentActivityDiscussions = "Свежие обновления"; +DiscussionsIsPinnedBadgeTitle = "Закреплён"; /* Write comment */ WriteCommentTitle = "Комментарий"; From 6c0c7f1b8880dbdfc222bbe2de5005c6da8d016f Mon Sep 17 00:00:00 2001 From: Ivan Magda Date: Fri, 22 Nov 2019 21:29:15 +0300 Subject: [PATCH 04/16] Fix code editor autocomplete (#568) * Update text view's selected text range before calling delegate's did change method --- Stepic/CodePlaygroundManager.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Stepic/CodePlaygroundManager.swift b/Stepic/CodePlaygroundManager.swift index ef9046ba4b..f780f78f1b 100644 --- a/Stepic/CodePlaygroundManager.swift +++ b/Stepic/CodePlaygroundManager.swift @@ -300,10 +300,11 @@ final class CodePlaygroundManager { var text = textView.text! text.insert(contentsOf: symbols.characters, at: text.index(text.startIndex, offsetBy: cursorPosition)) textView.text = text + // Import here to update selectedTextRange before calling textViewDidChange #APPS-2352 + textView.selectedTextRange = textRangeFrom(position: cursorPosition + symbols.count, textView: textView) // Manually call textViewDidChange, becuase when manually setting the text of a UITextView with code, // the textViewDidChange: method does not get called. textView.delegate?.textViewDidChange?(textView) - textView.selectedTextRange = textRangeFrom(position: cursorPosition + symbols.count, textView: textView) } } From d53f1b7f7b625fc3c86cfe684dd65e227d4dd0a3 Mon Sep 17 00:00:00 2001 From: Ivan Magda Date: Sat, 23 Nov 2019 16:05:24 +0300 Subject: [PATCH 05/16] Edit step analytics (#569) * Add analytics --- .../Analytics/AmplitudeAnalyticsEvents.swift | 22 +++++++++++++ .../Modules/EditStep/EditStepAssembly.swift | 3 +- .../Modules/EditStep/EditStepInteractor.swift | 32 +++++++++++++++++++ .../Modules/EditStep/EditStepProvider.swift | 19 ++++++++++- 4 files changed, 74 insertions(+), 2 deletions(-) diff --git a/Stepic/Analytics/AmplitudeAnalyticsEvents.swift b/Stepic/Analytics/AmplitudeAnalyticsEvents.swift index 8d45950e87..32e3053dc6 100644 --- a/Stepic/Analytics/AmplitudeAnalyticsEvents.swift +++ b/Stepic/Analytics/AmplitudeAnalyticsEvents.swift @@ -126,6 +126,28 @@ struct AmplitudeAnalyticsEvents { ] ) } + + static func stepEditOpened(stepID: Step.IdType, type: String, position: Int) -> AnalyticsEvent { + return AnalyticsEvent( + name: "Step edit opened", + parameters: [ + "step": stepID, + "type": type, + "number": position + ] + ) + } + + static func stepEditCompleted(stepID: Step.IdType, type: String, position: Int) -> AnalyticsEvent { + return AnalyticsEvent( + name: "Step edit completed", + parameters: [ + "step": stepID, + "type": type, + "number": position + ] + ) + } } struct Downloads { diff --git a/Stepic/Sources/Modules/EditStep/EditStepAssembly.swift b/Stepic/Sources/Modules/EditStep/EditStepAssembly.swift index d51b2ac7b6..0cf17a834c 100644 --- a/Stepic/Sources/Modules/EditStep/EditStepAssembly.swift +++ b/Stepic/Sources/Modules/EditStep/EditStepAssembly.swift @@ -11,7 +11,8 @@ final class EditStepAssembly: Assembly { func makeModule() -> UIViewController { let provider = EditStepProvider( - stepSourcesNetworkService: StepSourcesNetworkService(stepSourcesAPI: StepSourcesAPI()) + stepSourcesNetworkService: StepSourcesNetworkService(stepSourcesAPI: StepSourcesAPI()), + stepsPersistenceService: StepsPersistenceService() ) let presenter = EditStepPresenter() let interactor = EditStepInteractor(stepID: self.stepID, presenter: presenter, provider: provider) diff --git a/Stepic/Sources/Modules/EditStep/EditStepInteractor.swift b/Stepic/Sources/Modules/EditStep/EditStepInteractor.swift index 6b3b11691f..3cc8cffefa 100644 --- a/Stepic/Sources/Modules/EditStep/EditStepInteractor.swift +++ b/Stepic/Sources/Modules/EditStep/EditStepInteractor.swift @@ -30,6 +30,8 @@ final class EditStepInteractor: EditStepInteractorProtocol { self.stepID = stepID self.presenter = presenter self.provider = provider + + self.reportAnalyticsEvent(.opened) } // MARK: EditStepInteractorProtocol @@ -76,6 +78,8 @@ final class EditStepInteractor: EditStepInteractorProtocol { self.provider.updateStepSource(updatingStepSource).done { stepSource in EditStepInteractor.logger.info("edit step interactor :: finish updating step source = \(stepSource.id)") + self.reportAnalyticsEvent(.completed) + self.currentStepSource = stepSource self.currentText = stepSource.text @@ -99,9 +103,37 @@ final class EditStepInteractor: EditStepInteractorProtocol { ) } + private func reportAnalyticsEvent(_ event: AnalyticsEvent) { + self.provider.fetchCachedStep(stepID: self.stepID).done { step in + guard let step = step else { + return + } + + switch event { + case .opened: + AmplitudeAnalyticsEvents.Steps.stepEditOpened( + stepID: step.id, + type: step.block.type.rawValue, + position: step.position + ).send() + case .completed: + AmplitudeAnalyticsEvents.Steps.stepEditCompleted( + stepID: step.id, + type: step.block.type.rawValue, + position: step.position + ).send() + } + }.cauterize() + } + // MARK: - Types enum Error: Swift.Error { case noStepSource } + + private enum AnalyticsEvent { + case opened + case completed + } } diff --git a/Stepic/Sources/Modules/EditStep/EditStepProvider.swift b/Stepic/Sources/Modules/EditStep/EditStepProvider.swift index 952ce66860..eb9943992c 100644 --- a/Stepic/Sources/Modules/EditStep/EditStepProvider.swift +++ b/Stepic/Sources/Modules/EditStep/EditStepProvider.swift @@ -4,15 +4,21 @@ import PromiseKit protocol EditStepProviderProtocol { func fetchStepSource(stepID: Step.IdType) -> Promise func updateStepSource(_ stepSource: StepSource) -> Promise + func fetchCachedStep(stepID: Step.IdType) -> Promise } // MARK: - EditStepProvider: EditStepProviderProtocol - final class EditStepProvider: EditStepProviderProtocol { private let stepSourcesNetworkService: StepSourcesNetworkService + private let stepsPersistenceService: StepsPersistenceServiceProtocol - init(stepSourcesNetworkService: StepSourcesNetworkService) { + init( + stepSourcesNetworkService: StepSourcesNetworkService, + stepsPersistenceService: StepsPersistenceServiceProtocol + ) { self.stepSourcesNetworkService = stepSourcesNetworkService + self.stepsPersistenceService = stepsPersistenceService } func fetchStepSource(stepID: Step.IdType) -> Promise { @@ -35,10 +41,21 @@ final class EditStepProvider: EditStepProviderProtocol { } } + func fetchCachedStep(stepID: Step.IdType) -> Promise { + return Promise { seal in + self.stepsPersistenceService.fetch(ids: [stepID]).done { steps in + seal.fulfill(steps.first) + }.catch { _ in + seal.reject(Error.stepFetchFailed) + } + } + } + // MARK: Types enum Error: Swift.Error { case networkFetchFailed case networkUpdateFailed + case stepFetchFailed } } From 30812e0aac24490f4dc95817f27c4a0616c1e830 Mon Sep 17 00:00:00 2001 From: Ivan Magda Date: Sat, 23 Nov 2019 18:11:09 +0300 Subject: [PATCH 06/16] Course reviews analytics (#570) * Update analytics files structure * Debug log Firebase and AppMetrica events * Report edit step analytics to Firebase and AppMetrica * Add course reviews analytics --- Stepic.xcodeproj/project.pbxproj | 14 ++- Stepic/Analytics/AnalyticsReporter.swift | 22 ++-- .../AmplitudeAnalyticsEvents.swift | 113 +++++++++++++++-- .../{ => Events}/AnalyticsEvents.swift | 117 ++++++++++++++++-- .../NotificationAlertsAnalytics.swift | 0 .../CourseInfoTabReviewsInteractor.swift | 103 ++++++++++++++- .../CourseInfoTabReviewsProvider.swift | 9 ++ .../Downloads/DownloadsInteractor.swift | 2 +- .../Modules/EditStep/EditStepInteractor.swift | 6 + 9 files changed, 348 insertions(+), 38 deletions(-) rename Stepic/Analytics/{ => Events}/AmplitudeAnalyticsEvents.swift (86%) rename Stepic/Analytics/{ => Events}/AnalyticsEvents.swift (85%) rename Stepic/Analytics/{ => Events}/NotificationAlertsAnalytics.swift (100%) diff --git a/Stepic.xcodeproj/project.pbxproj b/Stepic.xcodeproj/project.pbxproj index f06ca94c40..cbee41a2b8 100644 --- a/Stepic.xcodeproj/project.pbxproj +++ b/Stepic.xcodeproj/project.pbxproj @@ -3529,13 +3529,11 @@ 2C0176C32188A49100DDB9D0 /* Analytics */ = { isa = PBXGroup; children = ( - 081A14FE20D963090016364E /* AmplitudeAnalyticsEvents.swift */, 087235ED20FE2F2F00B4D5B1 /* AnalyticsEvent.swift */, - 08D035201D65A252003515C6 /* AnalyticsEvents.swift */, 08D035241D65B5E5003515C6 /* AnalyticsReporter.swift */, 08E7CA0F20DAF6E0004F8563 /* AnalyticsUserProperties.swift */, 089877A4214047BB0065DFA2 /* AnalyticsUserPropertiesServiceProtocol.swift */, - 2C0176C02188953700DDB9D0 /* NotificationAlertsAnalytics.swift */, + 2C99D087238967FD00078D2B /* Events */, 62E98F39D7A7B4486E71DF3E /* SplitTests */, ); path = Analytics; @@ -4180,6 +4178,16 @@ name = PlaceholderCell; sourceTree = ""; }; + 2C99D087238967FD00078D2B /* Events */ = { + isa = PBXGroup; + children = ( + 081A14FE20D963090016364E /* AmplitudeAnalyticsEvents.swift */, + 08D035201D65A252003515C6 /* AnalyticsEvents.swift */, + 2C0176C02188953700DDB9D0 /* NotificationAlertsAnalytics.swift */, + ); + path = Events; + sourceTree = ""; + }; 2C9E3F381F7A7B2D00DDF1AA /* Notification */ = { isa = PBXGroup; children = ( diff --git a/Stepic/Analytics/AnalyticsReporter.swift b/Stepic/Analytics/AnalyticsReporter.swift index b3bd37826a..46e22bcd27 100644 --- a/Stepic/Analytics/AnalyticsReporter.swift +++ b/Stepic/Analytics/AnalyticsReporter.swift @@ -1,36 +1,36 @@ -// -// AnalyticsReporter.swift -// Stepic -// -// Created by Alexander Karpov on 18.08.16. -// Copyright © 2016 Alex Karpov. All rights reserved. -// - import Amplitude_iOS import FirebaseAnalytics import Foundation import YandexMobileMetrica final class AnalyticsReporter { + private init() { } + static func reportEvent(_ event: String, parameters: [String: Any]? = nil) { let params = parameters as? [String: NSObject] - reportFirebaseEvent(event, parameters: params) - reportAppMetricaEvent(event, parameters: params) + self.reportFirebaseEvent(event, parameters: params) + self.reportAppMetricaEvent(event, parameters: params) } static func reportAmplitudeEvent(_ event: String, parameters: [String: Any]? = nil) { Amplitude.instance().logEvent(event, withEventProperties: parameters) #if DEBUG - print("Logging amplitude event \(event), parameters: \(String(describing: parameters))") + print("Logging Amplitude event \(event), parameters: \(String(describing: parameters))") #endif } private static func reportFirebaseEvent(_ event: String, parameters: [String: NSObject]?) { Analytics.logEvent(event, parameters: parameters) + #if DEBUG + print("Logging Firebase event \(event), parameters: \(String(describing: parameters))") + #endif } private static func reportAppMetricaEvent(_ event: String, parameters: [String: NSObject]?) { YMMYandexMetrica.reportEvent(event, parameters: parameters, onFailure: nil) + #if DEBUG + print("Logging AppMetrica event \(event), parameters: \(String(describing: parameters))") + #endif } } diff --git a/Stepic/Analytics/AmplitudeAnalyticsEvents.swift b/Stepic/Analytics/Events/AmplitudeAnalyticsEvents.swift similarity index 86% rename from Stepic/Analytics/AmplitudeAnalyticsEvents.swift rename to Stepic/Analytics/Events/AmplitudeAnalyticsEvents.swift index 32e3053dc6..0912bf6671 100644 --- a/Stepic/Analytics/AmplitudeAnalyticsEvents.swift +++ b/Stepic/Analytics/Events/AmplitudeAnalyticsEvents.swift @@ -1,14 +1,8 @@ -// -// AmplitudeAnalyticsEvents.swift -// Stepic -// -// Created by Ostrenkiy on 19.06.2018. -// Copyright © 2018 Alex Karpov. All rights reserved. -// - import Foundation struct AmplitudeAnalyticsEvents { + // MARK: - Launch - + struct Launch { static var firstTime = AnalyticsEvent(name: "Launch first time") @@ -26,6 +20,8 @@ struct AmplitudeAnalyticsEvents { } } + // MARK: - Onboarding - + struct Onboarding { static func screenOpened(screen: Int) -> AnalyticsEvent { return AnalyticsEvent( @@ -48,6 +44,8 @@ struct AmplitudeAnalyticsEvents { static let completed = AnalyticsEvent(name: "Onboarding completed") } + // MARK: - SignIn - + struct SignIn { static func loggedIn(source: String) -> AnalyticsEvent { return AnalyticsEvent( @@ -59,6 +57,8 @@ struct AmplitudeAnalyticsEvents { } } + // MARK: - SignUp - + struct SignUp { static func registered(source: String) -> AnalyticsEvent { return AnalyticsEvent( @@ -70,6 +70,8 @@ struct AmplitudeAnalyticsEvents { } } + // MARK: - Course - + struct Course { static func joined(source: String, courseID: Int, courseTitle: String) -> AnalyticsEvent { return AnalyticsEvent( @@ -104,6 +106,8 @@ struct AmplitudeAnalyticsEvents { } } + // MARK: - Steps - + struct Steps { static func submissionMade(step: Int, type: String, language: String? = nil) -> AnalyticsEvent { return AnalyticsEvent( @@ -127,7 +131,7 @@ struct AmplitudeAnalyticsEvents { ) } - static func stepEditOpened(stepID: Step.IdType, type: String, position: Int) -> AnalyticsEvent { + static func stepEditOpened(stepID: Int, type: String, position: Int) -> AnalyticsEvent { return AnalyticsEvent( name: "Step edit opened", parameters: [ @@ -138,7 +142,7 @@ struct AmplitudeAnalyticsEvents { ) } - static func stepEditCompleted(stepID: Step.IdType, type: String, position: Int) -> AnalyticsEvent { + static func stepEditCompleted(stepID: Int, type: String, position: Int) -> AnalyticsEvent { return AnalyticsEvent( name: "Step edit completed", parameters: [ @@ -150,6 +154,8 @@ struct AmplitudeAnalyticsEvents { } } + // MARK: - Downloads - + struct Downloads { static func started(content: Content) -> AnalyticsEvent { return AnalyticsEvent( @@ -204,6 +210,8 @@ struct AmplitudeAnalyticsEvents { } } + // MARK: - Search - + struct Search { static var started = AnalyticsEvent(name: "Course search started") @@ -219,6 +227,8 @@ struct AmplitudeAnalyticsEvents { } } + // MARK: - Notifications - + struct Notifications { static var screenOpened = AnalyticsEvent(name: "Notifications screen opened") @@ -312,10 +322,14 @@ struct AmplitudeAnalyticsEvents { } } + // MARK: - Home - + struct Home { static var opened = AnalyticsEvent(name: "Home screen opened") } + // MARK: - Catalog - + struct Catalog { static var opened = AnalyticsEvent(name: "Catalog screen opened") @@ -332,6 +346,8 @@ struct AmplitudeAnalyticsEvents { } } + // MARK: - CourseList - + struct CourseList { static func opened(ID: String) -> AnalyticsEvent { return AnalyticsEvent( @@ -343,6 +359,8 @@ struct AmplitudeAnalyticsEvents { } } + // MARK: - Profile - + struct Profile { static func opened(state: String) -> AnalyticsEvent { return AnalyticsEvent( @@ -357,10 +375,14 @@ struct AmplitudeAnalyticsEvents { static var editSaved = AnalyticsEvent(name: "Profile edit saved") } + // MARK: - Certificates - + struct Certificates { static var opened = AnalyticsEvent(name: "Certificates screen opened") } + // MARK: - Achievements - + struct Achievements { static func opened(isPersonal: Bool) -> AnalyticsEvent { return AnalyticsEvent( @@ -396,6 +418,8 @@ struct AmplitudeAnalyticsEvents { } } + // MARK: - Settings - + struct Settings { static var opened = AnalyticsEvent(name: "Settings screen opened") @@ -404,6 +428,8 @@ struct AmplitudeAnalyticsEvents { } } + // MARK: - CoursePreview - + struct CoursePreview { static func opened(courseID: Int, courseTitle: String) -> AnalyticsEvent { return AnalyticsEvent( @@ -416,6 +442,8 @@ struct AmplitudeAnalyticsEvents { } } + // MARK: - Sections - + struct Sections { static func opened(courseID: Int, courseTitle: String) -> AnalyticsEvent { return AnalyticsEvent( @@ -428,6 +456,8 @@ struct AmplitudeAnalyticsEvents { } } + // MARK: - Lessons - + struct Lessons { static func opened(sectionID: Int?) -> AnalyticsEvent { return AnalyticsEvent( @@ -439,6 +469,8 @@ struct AmplitudeAnalyticsEvents { } } + // MARK: - CourseReviews - + struct CourseReviews { static func opened(courseID: Int, courseTitle: String) -> AnalyticsEvent { return AnalyticsEvent( @@ -449,8 +481,61 @@ struct AmplitudeAnalyticsEvents { ] ) } + + static func writePressed(courseID: Int, courseTitle: String) -> AnalyticsEvent { + return AnalyticsEvent( + name: "Create course review pressed", + parameters: [ + "course": courseID, + "title": courseTitle + ] + ) + } + + static func editPressed(courseID: Int, courseTitle: String) -> AnalyticsEvent { + return AnalyticsEvent( + name: "Edit course review pressed", + parameters: [ + "course": courseID, + "title": courseTitle + ] + ) + } + + static func created(courseID: Int, rating: Int) -> AnalyticsEvent { + return AnalyticsEvent( + name: "Course review created", + parameters: [ + "course": courseID, + "rating": rating + ] + ) + } + + static func updated(courseID: Int, fromRating: Int, toRating: Int) -> AnalyticsEvent { + return AnalyticsEvent( + name: "Course review updated", + parameters: [ + "course": courseID, + "from_rating": fromRating, + "to_rating": toRating + ] + ) + } + + static func deleted(courseID: Int, rating: Int) -> AnalyticsEvent { + return AnalyticsEvent( + name: "Course review deleted", + parameters: [ + "course": courseID, + "rating": rating + ] + ) + } } + // MARK: - Discussions - + struct Discussions { enum DiscussionsSource: String { case discussion @@ -468,6 +553,8 @@ struct AmplitudeAnalyticsEvents { } } + // MARK: - Stories - + struct Stories { static func storyOpened(id: Int) -> AnalyticsEvent { return AnalyticsEvent( @@ -513,6 +600,8 @@ struct AmplitudeAnalyticsEvents { } } + // MARK: - PersonalDeadlines - + struct PersonalDeadlines { static func created(weeklyLoadHours: Int) -> AnalyticsEvent { return AnalyticsEvent( @@ -526,6 +615,8 @@ struct AmplitudeAnalyticsEvents { static var buttonClicked = AnalyticsEvent(name: "Personal deadline schedule button pressed") } + // MARK: - Video - + struct Video { static var continuedInBackground = AnalyticsEvent(name: "Video played in background") @@ -540,6 +631,8 @@ struct AmplitudeAnalyticsEvents { } } + // MARK: - AdaptiveRating - + struct AdaptiveRating { static func opened(course: Int) -> AnalyticsEvent { return AnalyticsEvent( diff --git a/Stepic/Analytics/AnalyticsEvents.swift b/Stepic/Analytics/Events/AnalyticsEvents.swift similarity index 85% rename from Stepic/Analytics/AnalyticsEvents.swift rename to Stepic/Analytics/Events/AnalyticsEvents.swift index c6575a5527..5f60a10f7b 100644 --- a/Stepic/Analytics/AnalyticsEvents.swift +++ b/Stepic/Analytics/Events/AnalyticsEvents.swift @@ -1,79 +1,97 @@ -// -// AnalyticsEvents.swift -// Stepic -// -// Created by Alexander Karpov on 18.08.16. -// Copyright © 2016 Alex Karpov. All rights reserved. -// - import Foundation +/// Describes Firebase and AppMetrica analytics events. struct AnalyticsEvents { + // MARK: - Logout - + struct Logout { static let clicked = "clicked_logout" } + // MARK: - SignIn - + struct SignIn { static let onSocialAuth = "clicked_SignIn_on_launch_screen" static let onEmailAuth = "clicked_SignIn_on_email_auth_screen" static let onSignInScreen = "click_sign_in_with_interaction_type" static let nextButton = "click_sign_in_next_sign_in_screen" + struct Fields { static let tap = "tap_on_fields_login" static let typing = "typing_text_fields_login" } + struct Social { static let clicked = "social_login" static let codeReceived = "Api:auth with social account" } } + // MARK: - SignUp - + struct SignUp { static let onSocialAuth = "clicked_SignUp_on_launch_screen" static let onEmailAuth = "clicked_SignUp_on_email_auth_screen" static let onSignUpScreen = "click_registration_with_interaction_type" static let nextButton = "click_registration_send_ime" + struct Fields { static let tap = "tap_on_fields_registration" static let typing = "typing_text_fields_registration" } } + // MARK: - Login - + struct Login { static let success = "success_login" } + // MARK: - Syllabus - + struct Syllabus { static let shared = "share_syllabus_clicked" } + // MARK: - Units - + struct Units { static let shared = "share_units_clicked" } + // MARK: - Search - + struct Search { static let selected = "search_selected" static let cancelled = "search_cancelled" } + // MARK: - Section - + struct Section { static let cache = "clicked_cache_section" static let cancel = "clicked_cancel_section" static let delete = "clicked_delete_cached_section" } + // MARK: - Unit - + struct Unit { static let cache = "clicked_cache_unit" static let cancel = "clicked_cancel_unit" static let delete = "clicked_delete_cached_unit" } + // MARK: - Downloads - + struct Downloads { static let clear = "clicked_clear_cache" static let acceptedClear = "clicked_accepted_clear_cache" } - struct CourseOverview { + // MARK: - Course - + + struct Course { static let shared = "share_course_clicked" struct JoinPressed { @@ -86,9 +104,22 @@ struct AnalyticsEvents { static let shown = "course_detail_video_shown" } - static let delete = "clicked_delete_cached_course" + struct Downloads { + static let deleted = "clicked_delete_cached_course" + } + + struct Reviews { + static let opened = "course_reviews_screen_opened" + static let clickedCreate = "create_course_review_pressed" + static let clickedEdit = "edit_course_review_pressed" + static let created = "course_review_created" + static let updated = "course_review_updated" + static let deleted = "course_review_deleted" + } } + // MARK: - Step - + struct Step { struct Submission { static let submit = "clicked_submit" @@ -99,14 +130,31 @@ struct AnalyticsEvents { static let hasRestrictions = "step_with_submission_restriction" static let opened = "step_type_opened" + + struct Edit { + static let opened = "step_edit_opened" + static let completed = "step_edit_completed" + + static func makeParams(stepID: Int, type: String, position: Int) -> [String: Any] { + return [ + "step": stepID, + "type": type, + "number": position + ] + } + } } + // MARK: - VideoPlayer - + struct VideoPlayer { static let opened = "video_player_opened" static let rateChanged = "video_rate_changed" static let qualityChanged = "video_quality_changed" } + // MARK: - VideoDownload - + struct VideoDownload { static let started = "video_download_started" static let succeeded = "video_download_succeeded" @@ -120,6 +168,8 @@ struct AnalyticsEvents { } } + // MARK: - Discussion - + struct Discussion { static let liked = "discussion_liked" static let unliked = "discussion_unliked" @@ -127,6 +177,8 @@ struct AnalyticsEvents { static let unabused = "discussion_unabused" } + // MARK: - DeepLink - + struct DeepLink { static let step = "deeplink_step" static let syllabus = "deeplink_syllabus" @@ -135,6 +187,8 @@ struct AnalyticsEvents { static let discussion = "deeplink_discussion" } + // MARK: - Tabs - + struct Tabs { static let myCoursesClicked = "main_choice_my_courses" static let findCoursesClicked = "main_choice_find_courses" @@ -145,6 +199,8 @@ struct AnalyticsEvents { static let catalogClicked = "main_choice_catalog" } + // MARK: - Streaks - + struct Streaks { static let preferencesOn = "streak_notification_pref_on" static let preferencesOff = "streak_notification_pref_off" @@ -161,16 +217,19 @@ struct AnalyticsEvents { static func fail(_ index: Int) -> String { return "streak_suggestion_\(index)_fail" } + static func success(_ index: Int) -> String { return "streak_suggestion_\(index)_success" } } + static let notificationOpened = "streak_notification_opened" struct LocalNotification { static let shown = "streak_local_notification_shown" static let opened = "streak_local_notification_opened" } + struct ImproveAlert { static let notificationOffered = "streak_improve_alert_notifications_offered" static let timeSelected = "streak_improve_alert_time_selected" @@ -178,11 +237,15 @@ struct AnalyticsEvents { } } + // MARK: - App - + struct App { static let opened = "app_opened" static let firstLaunch = "first_launch_after_install" } + // MARK: - Errors - + struct Errors { static let tokenRefresh = "error_token_refresh" static let unregisterDeviceInvalidCredentials = "error_unregister_device_credentials" @@ -192,20 +255,27 @@ struct AnalyticsEvents { static let unknownNetworkError = "unknown_network_error" } + // MARK: - Continue - + struct Continue { static let sectionsOpened = "continue_section_opened" static let stepOpened = "continue_step_opened" } + // MARK: - Rate - + struct Rate { static let rated = "app_rate" + struct Positive { static let later = "app_rate_positive_later" static let appstore = "app_rate_positive_appstore" } + struct Negative { static let later = "app_rate_negative_later" static let email = "app_rate_negative_email" + struct Email { static let cancelled = "app_rate_negative_email_cancelled" static let success = "app_rate_negative_email_success" @@ -213,11 +283,15 @@ struct AnalyticsEvents { } } + // MARK: - Certificates - + struct Certificates { static let opened = "certificates_opened_certificate" static let shared = "certificates_pressed_share_certificate" } + // MARK: - PeekNPop - + struct PeekNPop { struct Course { static let peeked = "3dtouch_course_peeked" @@ -238,6 +312,8 @@ struct AnalyticsEvents { } } + // MARK: - Code - + struct Code { static let languageChosen = "code_language_chosen" static let fullscreenPressed = "code_fullscreen_pressed" @@ -247,31 +323,42 @@ struct AnalyticsEvents { static let hideKeyboard = "code_hide_keyboard" } + // MARK: - Profile - + struct Profile { static let clickSettings = "main_choice_settings" static let interactionWithPinsMap = "pins_map_interaction" + struct Settings { static let socialNetworkClick = "settings_click_social_network" } } + // MARK: - Notifications - + struct Notifications { static let markAllAsRead = "notifications_mark_all_as_read" static let markAsRead = "notifications_mark_as_read" } + // MARK: - NotificationRequest - + struct NotificationRequest { static func shown(context: NotificationRequestAlertContext) -> String { return "notification_alert_context_\(context.rawValue)_shown" } + static func accepted(context: NotificationRequestAlertContext) -> String { return "notification_alert_context_\(context.rawValue)_accepted" } + static func rejected(context: NotificationRequestAlertContext) -> String { return "notification_alert_context_\(context.rawValue)_rejected" } } + // MARK: - Onboarding - + struct Onboarding { static let onboardingClosed = "onboarding_closed" static let onboardingScreenOpened = "onboarding_screen_opened" @@ -279,20 +366,26 @@ struct AnalyticsEvents { static let onboardingComplete = "onboarding_complete" } + // MARK: - Adaptive - + struct Adaptive { static let onboardingFinished = "adaptive_onboarding_finished" + struct Step { static let submission = "adaptive_submission_created" static let correctAnswer = "adaptive_correct_answer" static let wrongAnswer = "adaptive_wrong_answer" static let retry = "adaptive_retry_answer" } + struct Reaction { static let easy = "adaptive_reaction_easy" static let hard = "adaptive_reaction_hard" } } + // MARK: - PersonalDeadlines - + struct PersonalDeadlines { struct Widget { static let shown = "personal_deadlines_widget_shown" @@ -308,16 +401,20 @@ struct AnalyticsEvents { struct EditSchedule { static let changePressed = "personal_deadline_change_pressed" + struct Time { static let opened = "personal_deadline_time_opened" static let closed = "personal_deadline_time_closed" static let saved = "personal_deadline_time_saved" } } + static let deleted = "personal_deadline_deleted" static let notSupportedNotification = "personal_deadline_not_supported_notification_scheduled" } + // MARK: - Settings - + struct Settings { static let fontSizeSelected = "font_size_selected" } diff --git a/Stepic/Analytics/NotificationAlertsAnalytics.swift b/Stepic/Analytics/Events/NotificationAlertsAnalytics.swift similarity index 100% rename from Stepic/Analytics/NotificationAlertsAnalytics.swift rename to Stepic/Analytics/Events/NotificationAlertsAnalytics.swift diff --git a/Stepic/Sources/Modules/CourseInfoSubmodules/CourseInfoTabReviews/CourseInfoTabReviewsInteractor.swift b/Stepic/Sources/Modules/CourseInfoSubmodules/CourseInfoTabReviews/CourseInfoTabReviewsInteractor.swift index a4e5e05052..2803674a40 100644 --- a/Stepic/Sources/Modules/CourseInfoSubmodules/CourseInfoTabReviews/CourseInfoTabReviewsInteractor.swift +++ b/Stepic/Sources/Modules/CourseInfoSubmodules/CourseInfoTabReviews/CourseInfoTabReviewsInteractor.swift @@ -15,11 +15,18 @@ final class CourseInfoTabReviewsInteractor: CourseInfoTabReviewsInteractorProtoc private let provider: CourseInfoTabReviewsProviderProtocol private var currentCourse: Course? - private var currentUserReview: CourseReview? + private var currentUserReview: CourseReview? { + didSet { + self.currentUserReviewScore = self.currentUserReview?.score + } + } private var isOnline = false private var paginationState = PaginationState(page: 1, hasNext: true) private var shouldOpenedAnalyticsEventSend = false + // Need for updated analytics event. + private var currentUserReviewScore: Int? + // Semaphore to prevent concurrent fetching private let fetchSemaphore = DispatchSemaphore(value: 1) private lazy var fetchBackgroundQueue = DispatchQueue( @@ -106,6 +113,12 @@ final class CourseInfoTabReviewsInteractor: CourseInfoTabReviewsInteractorProtoc review: self.currentUserReview ) ) + + if self.currentUserReview == nil { + self.reportAnalyticsEvent(.create(course)) + } else { + self.reportAnalyticsEvent(.edit(course)) + } } func doCourseReviewDelete(request: CourseInfoTabReviews.DeleteReview.Request) { @@ -113,6 +126,11 @@ final class CourseInfoTabReviewsInteractor: CourseInfoTabReviewsInteractorProtoc return } + var deletingScore: Int? + self.provider.fetchCachedCourseReview(courseReviewID: request.uniqueIdentifier).done { deletingCourseReview in + deletingScore = deletingCourseReview?.score + } + let isCurrentUserReviewDeleting = self.currentUserReview?.id == request.uniqueIdentifier self.provider.delete(id: request.uniqueIdentifier).done { @@ -120,6 +138,10 @@ final class CourseInfoTabReviewsInteractor: CourseInfoTabReviewsInteractorProtoc self.currentUserReview = nil } + if let deletingScore = deletingScore { + self.reportAnalyticsEvent(.deleted(courseID: course.id, score: deletingScore)) + } + self.presenter.presentCourseReviewDelete( response: CourseInfoTabReviews.DeleteReview.Response( isDeleted: true, @@ -189,10 +211,73 @@ final class CourseInfoTabReviewsInteractor: CourseInfoTabReviewsInteractorProtoc } } + private func reportAnalyticsEvent(_ event: AnalyticsEvent) { + switch event { + case .opened(let course): + AmplitudeAnalyticsEvents.CourseReviews.opened(courseID: course.id, courseTitle: course.title).send() + AnalyticsReporter.reportEvent( + AnalyticsEvents.Course.Reviews.opened, parameters: ["course": course.id, "title": course.title] + ) + case .create(let course): + AmplitudeAnalyticsEvents.CourseReviews.writePressed(courseID: course.id, courseTitle: course.title).send() + AnalyticsReporter.reportEvent( + AnalyticsEvents.Course.Reviews.clickedCreate, parameters: ["course": course.id, "title": course.title] + ) + case .edit(let course): + AmplitudeAnalyticsEvents.CourseReviews.editPressed(courseID: course.id, courseTitle: course.title).send() + AnalyticsReporter.reportEvent( + AnalyticsEvents.Course.Reviews.clickedEdit, parameters: ["course": course.id, "title": course.title] + ) + case .created(let courseReview): + AmplitudeAnalyticsEvents.CourseReviews.created( + courseID: courseReview.courseID, rating: courseReview.score + ).send() + AnalyticsReporter.reportEvent( + AnalyticsEvents.Course.Reviews.created, + parameters: [ + "course": courseReview.courseID, + "rating": courseReview.score + ] + ) + case .updated(let courseID, let fromScore, let toScore): + AmplitudeAnalyticsEvents.CourseReviews.updated( + courseID: courseID, fromRating: fromScore, toRating: toScore + ).send() + AnalyticsReporter.reportEvent( + AnalyticsEvents.Course.Reviews.updated, + parameters: [ + "course": courseID, + "from_rating": fromScore, + "to_rating": toScore + ] + ) + case .deleted(let courseID, let score): + AmplitudeAnalyticsEvents.CourseReviews.deleted(courseID: courseID, rating: score).send() + AnalyticsReporter.reportEvent( + AnalyticsEvents.Course.Reviews.deleted, + parameters: [ + "course": courseID, + "rating": score + ] + ) + } + } + + // MARK: - Types + enum Error: Swift.Error { case undefinedCourse case fetchFailed } + + private enum AnalyticsEvent { + case opened(Course) + case create(Course) + case edit(Course) + case created(CourseReview) + case updated(courseID: Course.IdType, fromScore: Int, toScore: Int) + case deleted(courseID: Course.IdType, score: Int) + } } // MARK: - CourseInfoTabReviewsInteractor: CourseInfoTabReviewsInputProtocol - @@ -200,7 +285,7 @@ final class CourseInfoTabReviewsInteractor: CourseInfoTabReviewsInteractorProtoc extension CourseInfoTabReviewsInteractor: CourseInfoTabReviewsInputProtocol { func handleControllerAppearance() { if let course = self.currentCourse { - AmplitudeAnalyticsEvents.CourseReviews.opened(courseID: course.id, courseTitle: course.title).send() + self.reportAnalyticsEvent(.opened(course)) self.shouldOpenedAnalyticsEventSend = false } else { self.shouldOpenedAnalyticsEventSend = true @@ -214,7 +299,7 @@ extension CourseInfoTabReviewsInteractor: CourseInfoTabReviewsInputProtocol { self.doCourseReviewsFetch(request: .init()) if self.shouldOpenedAnalyticsEventSend { - AmplitudeAnalyticsEvents.CourseReviews.opened(courseID: course.id, courseTitle: course.title).send() + self.reportAnalyticsEvent(.opened(course)) self.shouldOpenedAnalyticsEventSend = false } } @@ -224,6 +309,8 @@ extension CourseInfoTabReviewsInteractor: CourseInfoTabReviewsInputProtocol { extension CourseInfoTabReviewsInteractor: WriteCourseReviewOutputProtocol { func handleCourseReviewCreated(_ courseReview: CourseReview) { + self.reportAnalyticsEvent(.created(courseReview)) + self.currentUserReview = courseReview self.presenter.presentReviewCreated( response: CourseInfoTabReviews.ReviewCreated.Response(review: courseReview) @@ -231,6 +318,16 @@ extension CourseInfoTabReviewsInteractor: WriteCourseReviewOutputProtocol { } func handleCourseReviewUpdated(_ courseReview: CourseReview) { + if let currentUserReviewScore = self.currentUserReviewScore { + self.reportAnalyticsEvent( + .updated( + courseID: courseReview.courseID, + fromScore: currentUserReviewScore, + toScore: courseReview.score + ) + ) + } + self.currentUserReview = courseReview self.presenter.presentReviewUpdated( response: CourseInfoTabReviews.ReviewUpdated.Response(review: courseReview) diff --git a/Stepic/Sources/Modules/CourseInfoSubmodules/CourseInfoTabReviews/CourseInfoTabReviewsProvider.swift b/Stepic/Sources/Modules/CourseInfoSubmodules/CourseInfoTabReviews/CourseInfoTabReviewsProvider.swift index c73ce81863..c463dc866a 100644 --- a/Stepic/Sources/Modules/CourseInfoSubmodules/CourseInfoTabReviews/CourseInfoTabReviewsProvider.swift +++ b/Stepic/Sources/Modules/CourseInfoSubmodules/CourseInfoTabReviews/CourseInfoTabReviewsProvider.swift @@ -4,6 +4,7 @@ import PromiseKit protocol CourseInfoTabReviewsProviderProtocol: class { func fetchCached(course: Course) -> Promise<([CourseReview], Meta)> func fetchRemote(course: Course, page: Int) -> Promise<([CourseReview], Meta)> + func fetchCachedCourseReview(courseReviewID: CourseReview.IdType) -> Guarantee func fetchCurrentUserReviewCached(course: Course) -> Promise func fetchCurrentUserReviewRemote(course: Course) -> Promise @@ -59,6 +60,14 @@ final class CourseInfoTabReviewsProvider: CourseInfoTabReviewsProviderProtocol { } } + func fetchCachedCourseReview(courseReviewID: CourseReview.IdType) -> Guarantee { + return Guarantee { seal in + self.courseReviewsPersistenceService.fetch(ids: [courseReviewID]).done { courseReviews in + seal(courseReviews.first) + } + } + } + func fetchCurrentUserReviewCached(course: Course) -> Promise { guard let currentUserID = self.userAccountService.currentUser?.id else { return Promise(error: Error.persistenceFetchFailed) diff --git a/Stepic/Sources/Modules/Downloads/DownloadsInteractor.swift b/Stepic/Sources/Modules/Downloads/DownloadsInteractor.swift index bd96d50775..eb9da18d7b 100644 --- a/Stepic/Sources/Modules/Downloads/DownloadsInteractor.swift +++ b/Stepic/Sources/Modules/Downloads/DownloadsInteractor.swift @@ -38,7 +38,7 @@ final class DownloadsInteractor: DownloadsInteractorProtocol { ) } - AnalyticsReporter.reportEvent(AnalyticsEvents.CourseOverview.delete, parameters: ["source": "downloads"]) + AnalyticsReporter.reportEvent(AnalyticsEvents.Course.Downloads.deleted, parameters: ["source": "downloads"]) AmplitudeAnalyticsEvents.Downloads.deleted(content: .course, source: .downloads).send() self.provider.deleteSteps(steps).done { succeededIDs, failedIDs in diff --git a/Stepic/Sources/Modules/EditStep/EditStepInteractor.swift b/Stepic/Sources/Modules/EditStep/EditStepInteractor.swift index 3cc8cffefa..ecade18a3e 100644 --- a/Stepic/Sources/Modules/EditStep/EditStepInteractor.swift +++ b/Stepic/Sources/Modules/EditStep/EditStepInteractor.swift @@ -109,6 +109,10 @@ final class EditStepInteractor: EditStepInteractorProtocol { return } + let params = AnalyticsEvents.Step.Edit.makeParams( + stepID: step.id, type: step.block.type.rawValue, position: step.position + ) + switch event { case .opened: AmplitudeAnalyticsEvents.Steps.stepEditOpened( @@ -116,12 +120,14 @@ final class EditStepInteractor: EditStepInteractorProtocol { type: step.block.type.rawValue, position: step.position ).send() + AnalyticsReporter.reportEvent(AnalyticsEvents.Step.Edit.opened, parameters: params) case .completed: AmplitudeAnalyticsEvents.Steps.stepEditCompleted( stepID: step.id, type: step.block.type.rawValue, position: step.position ).send() + AnalyticsReporter.reportEvent(AnalyticsEvents.Step.Edit.completed, parameters: params) } }.cauterize() } From 52d449724559ee7ebe7ee1b0a49f39bac1093669 Mon Sep 17 00:00:00 2001 From: Ivan Magda Date: Sun, 24 Nov 2019 15:58:12 +0300 Subject: [PATCH 07/16] Enable main thread checker Detect invalid use of AppKit, UIKit, and other APIs from a background thread. --- Stepic.xcodeproj/xcshareddata/xcschemes/Stepic.xcscheme | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Stepic.xcodeproj/xcshareddata/xcschemes/Stepic.xcscheme b/Stepic.xcodeproj/xcshareddata/xcschemes/Stepic.xcscheme index 64fbb42ba5..655e3ed5ae 100644 --- a/Stepic.xcodeproj/xcshareddata/xcschemes/Stepic.xcscheme +++ b/Stepic.xcodeproj/xcshareddata/xcschemes/Stepic.xcscheme @@ -80,13 +80,13 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - disableMainThreadChecker = "YES" language = "ru" region = "RU" launchStyle = "0" useCustomWorkingDirectory = "NO" ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" + stopOnEveryMainThreadCheckerIssue = "YES" debugServiceExtension = "internal" allowLocationSimulation = "YES"> Date: Mon, 25 Nov 2019 16:30:02 +0300 Subject: [PATCH 08/16] Auto-scrolling for wide HTML content (#571) * Fetch scroll width * Auto-scrolling for wide content --- .../ProcessedContentTextView.swift | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/Stepic/Sources/Frameworks/ContentProcessor/ProcessedContentTextView.swift b/Stepic/Sources/Frameworks/ContentProcessor/ProcessedContentTextView.swift index 7a8306a3ff..e997ab2754 100644 --- a/Stepic/Sources/Frameworks/ContentProcessor/ProcessedContentTextView.swift +++ b/Stepic/Sources/Frameworks/ContentProcessor/ProcessedContentTextView.swift @@ -3,6 +3,8 @@ import SnapKit import UIKit import WebKit +// MARK: ProcessedContentTextViewDelegate: class - + protocol ProcessedContentTextViewDelegate: class { func processedContentTextViewDidLoadContent(_ view: ProcessedContentTextView) func processedContentTextView(_ view: ProcessedContentTextView, didReportNewHeight height: Int) @@ -14,6 +16,8 @@ extension ProcessedContentTextViewDelegate { func processedContentTextView(_ view: ProcessedContentTextView, didReportNewHeight height: Int) { } } +// MARK: - Appearance - + extension ProcessedContentTextView { struct Appearance { var insets = LayoutInsets(top: 10, left: 16, bottom: 4, right: 16) @@ -21,6 +25,8 @@ extension ProcessedContentTextView { } } +// MARK: - ProcessedContentTextView: UIView - + final class ProcessedContentTextView: UIView { private static let reloadTimeStandardInterval: TimeInterval = 0.5 private static let reloadTimeout: TimeInterval = 10.0 @@ -90,6 +96,12 @@ final class ProcessedContentTextView: UIView { ) } + /// A Boolean value that determines whether auto-scrolling is enabled. + /// + /// If the value of this property is `true`, auto-scrolling is enabled and view will enable scrolling for wider content than webView's size, + /// and if it is `false`, auto-scrolling is disabled. + var isAutoScrollingEnabled = true + var isScrollEnabled: Bool { get { return self.webView.scrollView.isScrollEnabled @@ -211,8 +223,42 @@ final class ProcessedContentTextView: UIView { self?.fetchHeightWithInterval(count + 1) } } + + private func getContentWidth() -> Guarantee { + return Guarantee { seal in + self.webView.evaluateJavaScript("document.body.scrollWidth;") { res, _ in + if let width = res as? Int { + seal(width) + return + } + seal(0) + } + } + } + + private func fetchWidthWithInterval(_ count: Int = 0) { + let currentTime = TimeInterval(count) * ProcessedContentTextView.reloadTimeStandardInterval + guard currentTime <= ProcessedContentTextView.reloadTimeout else { + return + } + + DispatchQueue.main.asyncAfter(deadline: .now() + currentTime) { [weak self] in + guard let strongSelf = self else { + return + } + + strongSelf.getContentWidth().done { width in + if strongSelf.isAutoScrollingEnabled { + strongSelf.isScrollEnabled = CGFloat(width) > strongSelf.webView.bounds.size.width + } + } + strongSelf.fetchWidthWithInterval(count + 1) + } + } } +// MARK: - ProcessedContentTextView: ProgrammaticallyInitializableViewProtocol - + extension ProcessedContentTextView: ProgrammaticallyInitializableViewProtocol { func addSubviews() { self.addSubview(self.webView) @@ -230,6 +276,8 @@ extension ProcessedContentTextView: ProgrammaticallyInitializableViewProtocol { } } +// MARK: - ProcessedContentTextView: WKNavigationDelegate - + extension ProcessedContentTextView: WKNavigationDelegate { // swiftlint:disable:next implicitly_unwrapped_optional func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { @@ -252,6 +300,7 @@ extension ProcessedContentTextView: WKNavigationDelegate { } self.fetchHeightWithInterval() + self.fetchWidthWithInterval() } } @@ -288,6 +337,8 @@ extension ProcessedContentTextView: WKNavigationDelegate { } } +// MARK: - ProcessedContentTextView: UIScrollViewDelegate - + extension ProcessedContentTextView: UIScrollViewDelegate { // swiftlint:disable:next identifier_name func viewForZooming(in: UIScrollView) -> UIView? { From 72161e5fde004b8b2966f344bad5d87d767467b9 Mon Sep 17 00:00:00 2001 From: Ivan Magda Date: Mon, 25 Nov 2019 18:14:54 +0300 Subject: [PATCH 09/16] Copy comment's text to the pasteboard (#572) * Add copy action * Update write comment view's layout --- .../Discussions/DiscussionsPresenter.swift | 3 ++- .../DiscussionsViewController.swift | 10 ++++++++ .../Discussions/DiscussionsViewModel.swift | 3 ++- .../Views/Cell/DiscussionsCellView.swift | 2 +- .../WriteComment/WriteCommentView.swift | 24 +++++++++++++------ .../WriteCommentViewController.swift | 7 ------ Stepic/en.lproj/Localizable.strings | 1 + Stepic/ru.lproj/Localizable.strings | 1 + 8 files changed, 34 insertions(+), 17 deletions(-) diff --git a/Stepic/Sources/Modules/Discussions/DiscussionsPresenter.swift b/Stepic/Sources/Modules/Discussions/DiscussionsPresenter.swift index ef1ed23a1a..7d3c475441 100644 --- a/Stepic/Sources/Modules/Discussions/DiscussionsPresenter.swift +++ b/Stepic/Sources/Modules/Discussions/DiscussionsPresenter.swift @@ -231,7 +231,8 @@ final class DiscussionsPresenter: DiscussionsPresenterProtocol { isPinned: comment.isPinned, isSelected: isSelected, username: username, - text: text, + rawText: comment.text, + processedText: text, isWebViewSupportNeeded: isWebViewSupportNeeded, formattedDate: formattedDate, likesCount: comment.epicCount, diff --git a/Stepic/Sources/Modules/Discussions/DiscussionsViewController.swift b/Stepic/Sources/Modules/Discussions/DiscussionsViewController.swift index ae373a5733..7075eb0684 100644 --- a/Stepic/Sources/Modules/Discussions/DiscussionsViewController.swift +++ b/Stepic/Sources/Modules/Discussions/DiscussionsViewController.swift @@ -403,6 +403,16 @@ extension DiscussionsViewController: DiscussionsTableViewDataSourceDelegate { ) { let alert = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) + alert.addAction( + UIAlertAction( + title: NSLocalizedString("Copy", comment: ""), + style: .default, + handler: { _ in + UIPasteboard.general.string = viewModel.rawText + } + ) + ) + alert.addAction( UIAlertAction( title: NSLocalizedString("Reply", comment: ""), diff --git a/Stepic/Sources/Modules/Discussions/DiscussionsViewModel.swift b/Stepic/Sources/Modules/Discussions/DiscussionsViewModel.swift index 0331184065..cd9d856219 100644 --- a/Stepic/Sources/Modules/Discussions/DiscussionsViewModel.swift +++ b/Stepic/Sources/Modules/Discussions/DiscussionsViewModel.swift @@ -21,7 +21,8 @@ struct DiscussionsCommentViewModel { let isPinned: Bool let isSelected: Bool let username: String - let text: String + let rawText: String + let processedText: String let isWebViewSupportNeeded: Bool let formattedDate: String let likesCount: Int diff --git a/Stepic/Sources/Modules/Discussions/Views/Cell/DiscussionsCellView.swift b/Stepic/Sources/Modules/Discussions/Views/Cell/DiscussionsCellView.swift index 112d80c542..49396b2b0b 100644 --- a/Stepic/Sources/Modules/Discussions/Views/Cell/DiscussionsCellView.swift +++ b/Stepic/Sources/Modules/Discussions/Views/Cell/DiscussionsCellView.swift @@ -284,7 +284,7 @@ final class DiscussionsCellView: UIView { canVote: viewModel.canVote, voteValue: viewModel.voteValue ) - self.updateTextContent(text: viewModel.text, isWebViewSupportNeeded: viewModel.isWebViewSupportNeeded) + self.updateTextContent(text: viewModel.processedText, isWebViewSupportNeeded: viewModel.isWebViewSupportNeeded) if let url = viewModel.avatarImageURL { self.avatarImageView.set(with: url) diff --git a/Stepic/Sources/Modules/WriteComment/WriteCommentView.swift b/Stepic/Sources/Modules/WriteComment/WriteCommentView.swift index 62e49ad489..14d483c1ac 100644 --- a/Stepic/Sources/Modules/WriteComment/WriteCommentView.swift +++ b/Stepic/Sources/Modules/WriteComment/WriteCommentView.swift @@ -1,21 +1,27 @@ import SnapKit import UIKit +// MARK: WriteCommentViewDelegate: class - + protocol WriteCommentViewDelegate: class { func writeCommentView(_ view: WriteCommentView, didUpdateText text: String) } +// MARK: - Appearance - + extension WriteCommentView { struct Appearance { let backgroundColor = UIColor.white - let textViewInsets = LayoutInsets(top: 16, left: 16, bottom: 16, right: 16) + let textViewTextInsets = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 16) let textViewFont = UIFont.systemFont(ofSize: 16) let textViewTextColor = UIColor.mainDark let textViewPlaceholderColor = UIColor.mainDark.withAlphaComponent(0.4) } } +// MARK: - WriteCommentView: UIView - + final class WriteCommentView: UIView { let appearance: Appearance @@ -27,8 +33,10 @@ final class WriteCommentView: UIView { textView.textColor = self.appearance.textViewTextColor textView.placeholderColor = self.appearance.textViewPlaceholderColor textView.placeholder = NSLocalizedString("WriteCommentPlaceholder", comment: "") - textView.textInsets = .zero - + textView.textInsets = self.appearance.textViewTextInsets + // Enable scrolling + textView.isScrollEnabled = true + textView.isUserInteractionEnabled = true // Disable features textView.dataDetectorTypes = [] @@ -69,6 +77,8 @@ final class WriteCommentView: UIView { } } +// MARK: - WriteCommentView: ProgrammaticallyInitializableViewProtocol - + extension WriteCommentView: ProgrammaticallyInitializableViewProtocol { func setupView() { self.backgroundColor = self.appearance.backgroundColor @@ -81,10 +91,10 @@ extension WriteCommentView: ProgrammaticallyInitializableViewProtocol { func makeConstraints() { self.textView.translatesAutoresizingMaskIntoConstraints = false self.textView.snp.makeConstraints { make in - make.leading.equalTo(self.safeAreaLayoutGuide.snp.leading).offset(self.appearance.textViewInsets.left) - make.top.equalToSuperview().offset(self.appearance.textViewInsets.top) - make.trailing.equalTo(self.safeAreaLayoutGuide.snp.trailing).offset(-self.appearance.textViewInsets.right) - make.bottom.equalTo(self.safeAreaLayoutGuide.snp.bottom).offset(-self.appearance.textViewInsets.bottom) + make.leading.equalTo(self.safeAreaLayoutGuide.snp.leading) + make.top.equalToSuperview() + make.trailing.equalTo(self.safeAreaLayoutGuide.snp.trailing) + make.bottom.equalTo(self.safeAreaLayoutGuide.snp.bottom) } } } diff --git a/Stepic/Sources/Modules/WriteComment/WriteCommentViewController.swift b/Stepic/Sources/Modules/WriteComment/WriteCommentViewController.swift index 8c36938e34..0dd1e21045 100644 --- a/Stepic/Sources/Modules/WriteComment/WriteCommentViewController.swift +++ b/Stepic/Sources/Modules/WriteComment/WriteCommentViewController.swift @@ -1,4 +1,3 @@ -import IQKeyboardManagerSwift import UIKit protocol WriteCommentViewControllerProtocol: class { @@ -69,11 +68,6 @@ final class WriteCommentViewController: UIViewController { self.interactor.doCommentLoad(request: .init()) } - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - IQKeyboardManager.shared.enable = false - } - override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) _ = self.writeCommentView?.becomeFirstResponder() @@ -82,7 +76,6 @@ final class WriteCommentViewController: UIViewController { override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) self.view.endEditing(true) - IQKeyboardManager.shared.enable = true } // MARK: - Private API diff --git a/Stepic/en.lproj/Localizable.strings b/Stepic/en.lproj/Localizable.strings index b5247986d9..b771b1c778 100644 --- a/Stepic/en.lproj/Localizable.strings +++ b/Stepic/en.lproj/Localizable.strings @@ -17,6 +17,7 @@ Cancel = "Cancel"; Remove = "Remove"; Delete = "Delete"; Yes = "Yes"; +Copy = "Copy"; Reload = "Reload"; ConnectionErrorTitle = "Connection error"; ConnectionErrorSubtitle = "Enable internet connection and retry"; diff --git a/Stepic/ru.lproj/Localizable.strings b/Stepic/ru.lproj/Localizable.strings index 51732ce03e..cdd36f2e05 100644 --- a/Stepic/ru.lproj/Localizable.strings +++ b/Stepic/ru.lproj/Localizable.strings @@ -17,6 +17,7 @@ Cancel = "Отмена"; Remove = "Удалить"; Delete = "Удалить"; Yes = "Да"; +Copy = "Скопировать"; Reload = "Обновить"; ConnectionErrorTitle = "Ошибка соединения"; ConnectionErrorSubtitle = "Подключитесь к интернету и повторите попытку"; From 5b00ed6f37dbbf8ef3ae0aeb8cb89eff2f417083 Mon Sep 17 00:00:00 2001 From: Ivan Magda Date: Mon, 25 Nov 2019 22:51:49 +0300 Subject: [PATCH 10/16] Discussions handle click on LaTeX (#573) * Handle click on content text view * Open images in fullscreen --- .../DiscussionsViewController.swift | 9 +++ .../Views/Cell/DiscussionsCellView.swift | 64 ++++++++++++++++++- .../Views/Cell/DiscussionsTableViewCell.swift | 8 +++ .../DiscussionsTableViewDataSource.swift | 15 +++++ 4 files changed, 95 insertions(+), 1 deletion(-) diff --git a/Stepic/Sources/Modules/Discussions/DiscussionsViewController.swift b/Stepic/Sources/Modules/Discussions/DiscussionsViewController.swift index 7075eb0684..c20f95812e 100644 --- a/Stepic/Sources/Modules/Discussions/DiscussionsViewController.swift +++ b/Stepic/Sources/Modules/Discussions/DiscussionsViewController.swift @@ -1,3 +1,4 @@ +import Agrume import SVProgressHUD import UIKit @@ -380,6 +381,14 @@ extension DiscussionsViewController: DiscussionsTableViewDataSourceDelegate { ) } + func discussionsTableViewDataSource( + _ tableViewDataSource: DiscussionsTableViewDataSource, + didRequestOpenImage url: URL + ) { + let agrume = Agrume(url: url) + agrume.show(from: self) + } + func discussionsTableViewDataSource( _ tableViewDataSource: DiscussionsTableViewDataSource, didSelectLoadMoreRepliesForDiscussion discussion: DiscussionsDiscussionViewModel diff --git a/Stepic/Sources/Modules/Discussions/Views/Cell/DiscussionsCellView.swift b/Stepic/Sources/Modules/Discussions/Views/Cell/DiscussionsCellView.swift index 49396b2b0b..39f2f4f6cf 100644 --- a/Stepic/Sources/Modules/Discussions/Views/Cell/DiscussionsCellView.swift +++ b/Stepic/Sources/Modules/Discussions/Views/Cell/DiscussionsCellView.swift @@ -3,6 +3,7 @@ import UIKit // MARK: Appearance - +// swiftlint:disable file_length extension DiscussionsCellView { struct Appearance { let avatarImageViewInsets = LayoutInsets(top: 16, left: 16) @@ -150,6 +151,13 @@ final class DiscussionsCellView: UIView { view.delegate = self view.isHidden = true + let tapGestureRecognizer = UITapGestureRecognizer( + target: self, + action: #selector(self.textContentWebBasedTextViewClicked(_:)) + ) + tapGestureRecognizer.delegate = self + view.addGestureRecognizer(tapGestureRecognizer) + return view }() @@ -243,12 +251,17 @@ final class DiscussionsCellView: UIView { return self.userRoleBadgeLabel.isHidden && self.isPinnedImageButton.isHidden } + private var didClickOnLinkOrImage = false + private var pendingTextViewClickWorkItem: DispatchWorkItem? + var onDotsMenuClick: (() -> Void)? var onReplyClick: (() -> Void)? var onLikeClick: (() -> Void)? var onDislikeClick: (() -> Void)? var onAvatarClick: (() -> Void)? var onLinkClick: ((URL) -> Void)? + var onImageClick: ((URL) -> Void)? + var onTextContentClick: (() -> Void)? // Content height updates callbacks var onContentLoaded: (() -> Void)? var onNewHeightUpdate: (() -> Void)? @@ -428,6 +441,27 @@ final class DiscussionsCellView: UIView { private func avatarOverlayButtonClicked() { self.onAvatarClick?() } + + @objc + private func textContentWebBasedTextViewClicked(_ sender: UITapGestureRecognizer) { + let workItem = DispatchWorkItem { [weak self] in + guard let strongSelf = self else { + return + } + + if !strongSelf.didClickOnLinkOrImage { + strongSelf.onTextContentClick?() + } + } + + self.pendingTextViewClickWorkItem?.cancel() + self.pendingTextViewClickWorkItem = workItem + + DispatchQueue.main.asyncAfter( + deadline: .now() + DiscussionsCellView.processedContentTextViewClickDelay, + execute: workItem + ) + } } // MARK: - DiscussionsCellView: ProgrammaticallyInitializableViewProtocol - @@ -504,6 +538,9 @@ extension DiscussionsCellView: ProgrammaticallyInitializableViewProtocol { // MARK: - DiscussionsCellView: ProcessedContentTextViewDelegate - extension DiscussionsCellView: ProcessedContentTextViewDelegate { + private static let processedContentTextViewClickDelay = DispatchTimeInterval.milliseconds(5) + private static let resetClickOnLinkOrImageDelay = DispatchTimeInterval.milliseconds(10) + func processedContentTextViewDidLoadContent(_ view: ProcessedContentTextView) { if self.textContentWebBasedTextView.isHidden { return @@ -526,9 +563,34 @@ extension DiscussionsCellView: ProcessedContentTextViewDelegate { } } - func processedContentTextView(_ view: ProcessedContentTextView, didOpenImage url: URL) { } + func processedContentTextView(_ view: ProcessedContentTextView, didOpenImage url: URL) { + self.didClickOnLinkOrImage = true + self.asyncResetClickOnLinkOrImage() + + self.onImageClick?(url) + } func processedContentTextView(_ view: ProcessedContentTextView, didOpenLink url: URL) { + self.didClickOnLinkOrImage = true + self.asyncResetClickOnLinkOrImage() + self.onLinkClick?(url) } + + private func asyncResetClickOnLinkOrImage() { + DispatchQueue.main.asyncAfter(deadline: .now() + DiscussionsCellView.resetClickOnLinkOrImageDelay) { + self.didClickOnLinkOrImage = false + } + } +} + +// MARK: - DiscussionsCellView: UIGestureRecognizerDelegate - + +extension DiscussionsCellView: UIGestureRecognizerDelegate { + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + return true + } } diff --git a/Stepic/Sources/Modules/Discussions/Views/Cell/DiscussionsTableViewCell.swift b/Stepic/Sources/Modules/Discussions/Views/Cell/DiscussionsTableViewCell.swift index 96068a2c8b..11fd9db4fc 100644 --- a/Stepic/Sources/Modules/Discussions/Views/Cell/DiscussionsTableViewCell.swift +++ b/Stepic/Sources/Modules/Discussions/Views/Cell/DiscussionsTableViewCell.swift @@ -39,6 +39,12 @@ final class DiscussionsTableViewCell: UITableViewCell, Reusable { cellView.onLinkClick = { [weak self] url in self?.onLinkClick?(url) } + cellView.onImageClick = { [weak self] url in + self?.onImageClick?(url) + } + cellView.onTextContentClick = { [weak self] in + self?.onTextContentClick?() + } cellView.onContentLoaded = { [weak self] in self?.onContentLoaded?() } @@ -73,6 +79,8 @@ final class DiscussionsTableViewCell: UITableViewCell, Reusable { var onDislikeClick: (() -> Void)? var onAvatarClick: (() -> Void)? var onLinkClick: ((URL) -> Void)? + var onImageClick: ((URL) -> Void)? + var onTextContentClick: (() -> Void)? // Content callbacks var onContentLoaded: (() -> Void)? var onNewHeightUpdate: ((CGFloat) -> Void)? diff --git a/Stepic/Sources/Modules/Discussions/Views/DiscussionsTableViewDataSource.swift b/Stepic/Sources/Modules/Discussions/Views/DiscussionsTableViewDataSource.swift index db8070f4d0..629182d7f2 100644 --- a/Stepic/Sources/Modules/Discussions/Views/DiscussionsTableViewDataSource.swift +++ b/Stepic/Sources/Modules/Discussions/Views/DiscussionsTableViewDataSource.swift @@ -38,6 +38,10 @@ protocol DiscussionsTableViewDataSourceDelegate: class { _ tableViewDataSource: DiscussionsTableViewDataSource, didRequestOpenURL url: URL ) + func discussionsTableViewDataSource( + _ tableViewDataSource: DiscussionsTableViewDataSource, + didRequestOpenImage url: URL + ) } // MARK: - DiscussionsTableViewDataSource: NSObject - @@ -190,6 +194,17 @@ extension DiscussionsTableViewDataSource: UITableViewDataSource { strongSelf.delegate?.discussionsTableViewDataSource(strongSelf, didRequestOpenURL: url) } } + cell.onImageClick = { [weak self] url in + if let strongSelf = self { + strongSelf.delegate?.discussionsTableViewDataSource(strongSelf, didRequestOpenImage: url) + } + } + cell.onTextContentClick = { [weak tableView] in + if let strongTableView = tableView { + strongTableView.selectRow(at: indexPath, animated: true, scrollPosition: .none) + strongTableView.delegate?.tableView?(strongTableView, didSelectRowAt: indexPath) + } + } let separatorStyle: DiscussionsTableViewCell.ViewModel.SeparatorStyle = { if indexPath.row == tableView.numberOfRows(inSection: indexPath.section) - 1 { From 3478986c1448a3d3443f259854aa433ed9187f24 Mon Sep 17 00:00:00 2001 From: Ivan Magda Date: Mon, 25 Nov 2019 22:58:11 +0300 Subject: [PATCH 11/16] Update pull_request_template.txt --- .github/pull_request_template.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/pull_request_template.txt b/.github/pull_request_template.txt index de7c73b343..68afb1b802 100644 --- a/.github/pull_request_template.txt +++ b/.github/pull_request_template.txt @@ -1,3 +1,3 @@ -**Задача**: [#](https://vyahhi.myjetbrains.com/youtrack/issue/) +**Задача**: [#APPS-](https://vyahhi.myjetbrains.com/youtrack/issue/APPS-) **Описание**: From 39451b37a4f52b408db55d3aa1e269694acb0ccb Mon Sep 17 00:00:00 2001 From: Stepik Bot Date: Tue, 26 Nov 2019 03:03:09 +0000 Subject: [PATCH 12/16] "Set version to 1.103 & bump build" --- Stepic.xcodeproj/project.pbxproj | 4 ++-- Stepic/Info.plist | 4 ++-- StepicTests/Info.plist | 4 ++-- StickerPackExtension/Info.plist | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Stepic.xcodeproj/project.pbxproj b/Stepic.xcodeproj/project.pbxproj index cbee41a2b8..5289913e48 100644 --- a/Stepic.xcodeproj/project.pbxproj +++ b/Stepic.xcodeproj/project.pbxproj @@ -7488,7 +7488,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 162; + CURRENT_PROJECT_VERSION = 163; DEVELOPMENT_TEAM = UJ4KC2QN7B; ENABLE_BITCODE = YES; INFOPLIST_FILE = Stepic/Info.plist; @@ -7518,7 +7518,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 162; + CURRENT_PROJECT_VERSION = 163; DEVELOPMENT_TEAM = UJ4KC2QN7B; ENABLE_BITCODE = YES; INFOPLIST_FILE = Stepic/Info.plist; diff --git a/Stepic/Info.plist b/Stepic/Info.plist index baa4a7feb5..bb0f15fbb1 100644 --- a/Stepic/Info.plist +++ b/Stepic/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.102 + 1.103 CFBundleSignature ???? CFBundleURLTypes @@ -56,7 +56,7 @@ CFBundleVersion - 162 + 163 Fabric APIKey diff --git a/StepicTests/Info.plist b/StepicTests/Info.plist index 573c70bac7..cf382645a9 100644 --- a/StepicTests/Info.plist +++ b/StepicTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.102 + 1.103 CFBundleSignature ???? CFBundleVersion - 162 + 163 diff --git a/StickerPackExtension/Info.plist b/StickerPackExtension/Info.plist index ec812de9a0..2e344d75ca 100644 --- a/StickerPackExtension/Info.plist +++ b/StickerPackExtension/Info.plist @@ -17,9 +17,9 @@ CFBundlePackageType XPC! CFBundleShortVersionString - 1.102 + 1.103 CFBundleVersion - 162 + 163 NSExtension NSExtensionPointIdentifier From d33068bc832a3aaa72466b61c2e2b269fc6cabf6 Mon Sep 17 00:00:00 2001 From: Ivan Magda Date: Tue, 26 Nov 2019 21:19:41 +0300 Subject: [PATCH 13/16] Hide edit step action for video steps --- .../NewLesson/NewLessonPresenter.swift | 1 + .../NewLesson/NewLessonViewController.swift | 30 +++++++++++++++---- .../NewLesson/NewLessonViewModel.swift | 1 + 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/Stepic/Sources/Modules/NewLesson/NewLessonPresenter.swift b/Stepic/Sources/Modules/NewLesson/NewLessonPresenter.swift index 0fe902a53f..f4da995441 100644 --- a/Stepic/Sources/Modules/NewLesson/NewLessonPresenter.swift +++ b/Stepic/Sources/Modules/NewLesson/NewLessonPresenter.swift @@ -112,6 +112,7 @@ final class NewLessonPresenter: NewLessonPresenterProtocol { }() return .init( id: step.id, + type: step.block.type, iconImage: iconImage ?? UIImage(), isPassed: progresses[safe: index]?.isPassed ?? false ) diff --git a/Stepic/Sources/Modules/NewLesson/NewLessonViewController.swift b/Stepic/Sources/Modules/NewLesson/NewLessonViewController.swift index 2e90860312..2038d93c48 100644 --- a/Stepic/Sources/Modules/NewLesson/NewLessonViewController.swift +++ b/Stepic/Sources/Modules/NewLesson/NewLessonViewController.swift @@ -179,7 +179,7 @@ final class NewLessonViewController: TabmanViewController, ControllerWithStepikP direction: direction, animated: animated ) - self.updateInfoBarButtonItem() + self.updateRightBarButtonItems() } // MARK: Private API @@ -221,10 +221,6 @@ final class NewLessonViewController: TabmanViewController, ControllerWithStepikP self.stepControllers = Array(repeating: nil, count: data.steps.count) self.stepModulesInputs = Array(repeating: nil, count: data.steps.count) - self.navigationItem.rightBarButtonItems = data.canEdit - ? self.teacherRightBarButtonItems - : self.studentRightBarButtonItems - if let styledNavigationController = self.navigationController as? StyledNavigationController { styledNavigationController.changeShadowViewAlpha(0.0, sender: self) } @@ -315,6 +311,24 @@ final class NewLessonViewController: TabmanViewController, ControllerWithStepikP } } + private func updateRightBarButtonItems() { + defer { + self.updateInfoBarButtonItem() + } + + guard case .result(let data) = self.state, + let currentIndex = self.currentIndex, + let step = data.steps[safe: currentIndex] else { + return + } + + let showEditStep = data.canEdit && step.type != .video + + self.navigationItem.rightBarButtonItems = showEditStep + ? self.teacherRightBarButtonItems + : self.studentRightBarButtonItems + } + private func updateInfoBarButtonItem() { let isEnabled: Bool = { guard case .result(let data) = self.state, @@ -328,6 +342,8 @@ final class NewLessonViewController: TabmanViewController, ControllerWithStepikP self.infoBarButtonItem.isEnabled = isEnabled } + // MARK: Actions + @objc private func infoButtonClicked() { guard case .result(let data) = self.state else { @@ -413,6 +429,8 @@ final class NewLessonViewController: TabmanViewController, ControllerWithStepikP } } +// MARK: - NewLessonViewController: PageboyViewControllerDataSource - + extension NewLessonViewController: PageboyViewControllerDataSource { func numberOfViewControllers(in pageboyViewController: PageboyViewController) -> Int { if case .result(let data) = self.state { @@ -447,6 +465,8 @@ extension NewLessonViewController: PageboyViewControllerDataSource { } } +// MARK: - NewLessonViewController: TMBarDataSource - + extension NewLessonViewController: TMBarDataSource { func barItem(for bar: TMBar, at index: Int) -> TMBarItemable { guard case .result(let data) = self.state, let stepDescription = data.steps[safe: index] else { diff --git a/Stepic/Sources/Modules/NewLesson/NewLessonViewModel.swift b/Stepic/Sources/Modules/NewLesson/NewLessonViewModel.swift index 32359732c0..dcaf509512 100644 --- a/Stepic/Sources/Modules/NewLesson/NewLessonViewModel.swift +++ b/Stepic/Sources/Modules/NewLesson/NewLessonViewModel.swift @@ -3,6 +3,7 @@ import Foundation struct NewLessonViewModel { struct StepDescription { let id: Step.IdType + let type: Block.BlockType let iconImage: UIImage let isPassed: Bool } From 860cb25030d075777790f55cf84293b661b0c9d2 Mon Sep 17 00:00:00 2001 From: Ivan Magda Date: Tue, 26 Nov 2019 21:33:03 +0300 Subject: [PATCH 14/16] Remove dots menu --- Stepic/DiscussionsSkeletonView.swift | 18 ------------- .../DiscussionsViewController.swift | 8 ------ .../Views/Cell/DiscussionsCellView.swift | 27 ------------------- .../Views/Cell/DiscussionsTableViewCell.swift | 4 --- .../DiscussionsTableViewDataSource.swift | 14 ---------- 5 files changed, 71 deletions(-) diff --git a/Stepic/DiscussionsSkeletonView.swift b/Stepic/DiscussionsSkeletonView.swift index 4b6a56ac4a..a8178f9622 100644 --- a/Stepic/DiscussionsSkeletonView.swift +++ b/Stepic/DiscussionsSkeletonView.swift @@ -12,9 +12,6 @@ extension DiscussionsSkeletonView { let badgeViewInsets = UIEdgeInsets(top: 0, left: 16, bottom: 0, right: 0) let badgeViewSize = CGSize(width: 80, height: 12) - let dotsMenuViewInsets = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 16) - let dotsMenuViewSize = CGSize(width: 24, height: 12) - let nameLabelHeight: CGFloat = 14.0 let nameLabelInsets = UIEdgeInsets(top: 8, left: 16, bottom: 8, right: 16) @@ -43,13 +40,6 @@ final class DiscussionsSkeletonView: UIView { return view }() - private lazy var dotsMenuView: UIView = { - let view = UIView() - view.clipsToBounds = true - view.layer.cornerRadius = self.appearance.labelCornerRadius - return view - }() - private lazy var nameLabelView: UIView = { let view = UIView() view.clipsToBounds = true @@ -94,7 +84,6 @@ extension DiscussionsSkeletonView: ProgrammaticallyInitializableViewProtocol { func addSubviews() { self.addSubview(self.avatarView) self.addSubview(self.badgeView) - self.addSubview(self.dotsMenuView) self.addSubview(self.nameLabelView) self.addSubview(self.descriptionLabel1View) self.addSubview(self.descriptionLabel2View) @@ -116,13 +105,6 @@ extension DiscussionsSkeletonView: ProgrammaticallyInitializableViewProtocol { make.size.equalTo(self.appearance.badgeViewSize) } - self.dotsMenuView.translatesAutoresizingMaskIntoConstraints = false - self.dotsMenuView.snp.makeConstraints { make in - make.top.equalTo(self.avatarView.snp.top) - make.trailing.equalToSuperview().offset(-self.appearance.dotsMenuViewInsets.right) - make.size.equalTo(self.appearance.dotsMenuViewSize) - } - self.nameLabelView.translatesAutoresizingMaskIntoConstraints = false self.nameLabelView.snp.makeConstraints { make in make.height.equalTo(self.appearance.nameLabelHeight) diff --git a/Stepic/Sources/Modules/Discussions/DiscussionsViewController.swift b/Stepic/Sources/Modules/Discussions/DiscussionsViewController.swift index c20f95812e..b130b27d70 100644 --- a/Stepic/Sources/Modules/Discussions/DiscussionsViewController.swift +++ b/Stepic/Sources/Modules/Discussions/DiscussionsViewController.swift @@ -352,14 +352,6 @@ extension DiscussionsViewController: DiscussionsTableViewDataSourceDelegate { self.interactor.doCommentAbuse(request: .init(commentID: comment.id)) } - func discussionsTableViewDataSource( - _ tableViewDataSource: DiscussionsTableViewDataSource, - didSelectDotsMenu comment: DiscussionsCommentViewModel, - cell: UITableViewCell - ) { - self.presentCommentActionSheet(comment, sourceView: cell, sourceRect: cell.bounds) - } - func discussionsTableViewDataSource( _ tableViewDataSource: DiscussionsTableViewDataSource, didSelectAvatar comment: DiscussionsCommentViewModel diff --git a/Stepic/Sources/Modules/Discussions/Views/Cell/DiscussionsCellView.swift b/Stepic/Sources/Modules/Discussions/Views/Cell/DiscussionsCellView.swift index 39f2f4f6cf..ccc67d4518 100644 --- a/Stepic/Sources/Modules/Discussions/Views/Cell/DiscussionsCellView.swift +++ b/Stepic/Sources/Modules/Discussions/Views/Cell/DiscussionsCellView.swift @@ -26,10 +26,6 @@ extension DiscussionsCellView { let badgesStackViewSpacing: CGFloat = 8 let badgeStackViewInsets = LayoutInsets(left: 16) - let dotsMenuImageSize = CGSize(width: 20, height: 20) - let dotsMenuImageTintColor = UIColor.mainDark.withAlphaComponent(0.5) - let dotsMenuImageInsets = LayoutInsets(top: 16, right: 16) - let nameLabelInsets = LayoutInsets(top: 8, left: 16, right: 16) let nameLabelFont = UIFont.systemFont(ofSize: 14, weight: .bold) let nameLabelTextColor = UIColor.mainDark @@ -123,15 +119,6 @@ final class DiscussionsCellView: UIView { return stackView }() - private lazy var dotsMenuImageButton: ImageButton = { - let imageButton = ImageButton() - imageButton.imageSize = self.appearance.dotsMenuImageSize - imageButton.tintColor = self.appearance.dotsMenuImageTintColor - imageButton.image = UIImage(named: "horizontal-dots-icon")?.withRenderingMode(.alwaysTemplate) - imageButton.addTarget(self, action: #selector(self.dotsMenuDidClick), for: .touchUpInside) - return imageButton - }() - private lazy var nameLabel: UILabel = { let label = UILabel() label.font = self.appearance.nameLabelFont @@ -254,7 +241,6 @@ final class DiscussionsCellView: UIView { private var didClickOnLinkOrImage = false private var pendingTextViewClickWorkItem: DispatchWorkItem? - var onDotsMenuClick: (() -> Void)? var onReplyClick: (() -> Void)? var onLikeClick: (() -> Void)? var onDislikeClick: (() -> Void)? @@ -417,11 +403,6 @@ final class DiscussionsCellView: UIView { // MARK: Actions - @objc - private func dotsMenuDidClick() { - self.onDotsMenuClick?() - } - @objc private func replyDidClick() { self.onReplyClick?() @@ -471,7 +452,6 @@ extension DiscussionsCellView: ProgrammaticallyInitializableViewProtocol { self.addSubview(self.avatarImageView) self.addSubview(self.avatarOverlayButton) self.addSubview(self.badgesStackView) - self.addSubview(self.dotsMenuImageButton) self.addSubview(self.nameLabel) self.addSubview(self.textContentStackView) self.addSubview(self.bottomControlsStackView) @@ -497,13 +477,6 @@ extension DiscussionsCellView: ProgrammaticallyInitializableViewProtocol { self.badgesStackViewHeightConstraint = make.height.equalTo(self.appearance.badgesStackViewHeight).constraint } - self.dotsMenuImageButton.translatesAutoresizingMaskIntoConstraints = false - self.dotsMenuImageButton.snp.makeConstraints { make in - make.top.equalToSuperview().offset(self.appearance.dotsMenuImageInsets.top) - make.trailing.equalToSuperview().offset(-self.appearance.dotsMenuImageInsets.right) - make.size.equalTo(self.appearance.dotsMenuImageSize) - } - self.nameLabel.translatesAutoresizingMaskIntoConstraints = false self.nameLabel.snp.makeConstraints { make in make.leading.equalTo(self.avatarImageView.snp.trailing).offset(self.appearance.nameLabelInsets.left) diff --git a/Stepic/Sources/Modules/Discussions/Views/Cell/DiscussionsTableViewCell.swift b/Stepic/Sources/Modules/Discussions/Views/Cell/DiscussionsTableViewCell.swift index 11fd9db4fc..191a96c23b 100644 --- a/Stepic/Sources/Modules/Discussions/Views/Cell/DiscussionsTableViewCell.swift +++ b/Stepic/Sources/Modules/Discussions/Views/Cell/DiscussionsTableViewCell.swift @@ -21,9 +21,6 @@ extension DiscussionsTableViewCell { final class DiscussionsTableViewCell: UITableViewCell, Reusable { private lazy var cellView: DiscussionsCellView = { let cellView = DiscussionsCellView() - cellView.onDotsMenuClick = { [weak self] in - self?.onDotsMenuClick?() - } cellView.onReplyClick = { [weak self] in self?.onReplyClick?() } @@ -73,7 +70,6 @@ final class DiscussionsTableViewCell: UITableViewCell, Reusable { private var separatorHeightConstraint: Constraint? private var separatorStyle: ViewModel.SeparatorStyle = .small - var onDotsMenuClick: (() -> Void)? var onReplyClick: (() -> Void)? var onLikeClick: (() -> Void)? var onDislikeClick: (() -> Void)? diff --git a/Stepic/Sources/Modules/Discussions/Views/DiscussionsTableViewDataSource.swift b/Stepic/Sources/Modules/Discussions/Views/DiscussionsTableViewDataSource.swift index 629182d7f2..c78b5e2e43 100644 --- a/Stepic/Sources/Modules/Discussions/Views/DiscussionsTableViewDataSource.swift +++ b/Stepic/Sources/Modules/Discussions/Views/DiscussionsTableViewDataSource.swift @@ -15,11 +15,6 @@ protocol DiscussionsTableViewDataSourceDelegate: class { _ tableViewDataSource: DiscussionsTableViewDataSource, didDislikeComment comment: DiscussionsCommentViewModel ) - func discussionsTableViewDataSource( - _ tableViewDataSource: DiscussionsTableViewDataSource, - didSelectDotsMenu comment: DiscussionsCommentViewModel, - cell: UITableViewCell - ) func discussionsTableViewDataSource( _ tableViewDataSource: DiscussionsTableViewDataSource, didSelectAvatar comment: DiscussionsCommentViewModel @@ -160,15 +155,6 @@ extension DiscussionsTableViewDataSource: UITableViewDataSource { strongSelf.updateCellHeight(newHeight, commentID: commentID, tableView: strongTableView) } } - cell.onDotsMenuClick = { [weak self, weak cell] in - if let strongSelf = self, let strongCell = cell { - strongSelf.delegate?.discussionsTableViewDataSource( - strongSelf, - didSelectDotsMenu: commentViewModel, - cell: strongCell - ) - } - } cell.onReplyClick = { [weak self] in if let strongSelf = self { strongSelf.delegate?.discussionsTableViewDataSource(strongSelf, didReplyForComment: commentViewModel) From 93536ce62b687c5c89cd948a0a86955dc877d358 Mon Sep 17 00:00:00 2001 From: Ivan Magda Date: Wed, 27 Nov 2019 02:01:57 +0300 Subject: [PATCH 15/16] Update fastlane to 2.137.0 --- Gemfile | 2 +- Gemfile.lock | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Gemfile b/Gemfile index 99a01e28c1..d85a2a5fa9 100644 --- a/Gemfile +++ b/Gemfile @@ -1,4 +1,4 @@ source "https://rubygems.org" -gem "fastlane", "2.134.0" +gem "fastlane", "2.137.0" gem "cocoapods", "1.8.4" diff --git a/Gemfile.lock b/Gemfile.lock index 91e3567de1..22d35a1186 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -41,7 +41,7 @@ GEM fuzzy_match (~> 2.0.4) nap (~> 1.0) cocoapods-deintegrate (1.0.4) - cocoapods-downloader (1.2.2) + cocoapods-downloader (1.3.0) cocoapods-plugins (1.0.0) nap cocoapods-search (1.0.0) @@ -63,7 +63,7 @@ GEM dotenv (2.7.5) emoji_regex (1.0.1) escape (0.0.4) - excon (0.67.0) + excon (0.69.1) faraday (0.17.0) multipart-post (>= 1.2, < 3) faraday-cookie_jar (0.0.6) @@ -72,7 +72,7 @@ GEM faraday_middleware (0.13.1) faraday (>= 0.7.4, < 1.0) fastimage (2.1.7) - fastlane (2.134.0) + fastlane (2.137.0) CFPropertyList (>= 2.3, < 4.0.0) addressable (>= 2.3, < 3.0.0) babosa (>= 1.0.2, < 2.0.0) @@ -120,9 +120,9 @@ GEM representable (~> 3.0) retriable (>= 2.0, < 4.0) signet (~> 0.9) - google-cloud-core (1.3.2) + google-cloud-core (1.4.1) google-cloud-env (~> 1.0) - google-cloud-env (1.2.1) + google-cloud-env (1.3.0) faraday (~> 0.11) google-cloud-storage (1.16.0) digest-crc (~> 0.4) @@ -144,12 +144,12 @@ GEM concurrent-ruby (~> 1.0) json (2.2.0) jwt (2.1.0) - memoist (0.16.0) + memoist (0.16.1) mime-types (3.3) mime-types-data (~> 3.2015) mime-types-data (3.2019.1009) mini_magick (4.9.5) - minitest (5.12.2) + minitest (5.13.0) molinillo (0.6.6) multi_json (1.14.1) multi_xml (0.6.0) @@ -211,7 +211,7 @@ PLATFORMS DEPENDENCIES cocoapods (= 1.8.4) - fastlane (= 2.134.0) + fastlane (= 2.137.0) BUNDLED WITH 2.0.2 From 6e2643d5a302429a4d8044b3c6042c6ceaef43c7 Mon Sep 17 00:00:00 2001 From: Ivan Magda Date: Wed, 27 Nov 2019 02:02:45 +0300 Subject: [PATCH 16/16] Bump build --- Stepic.xcodeproj/project.pbxproj | 4 ++-- Stepic/Info.plist | 2 +- StepicTests/Info.plist | 2 +- StickerPackExtension/Info.plist | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Stepic.xcodeproj/project.pbxproj b/Stepic.xcodeproj/project.pbxproj index 5289913e48..b6d9ce32bd 100644 --- a/Stepic.xcodeproj/project.pbxproj +++ b/Stepic.xcodeproj/project.pbxproj @@ -7488,7 +7488,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 163; + CURRENT_PROJECT_VERSION = 164; DEVELOPMENT_TEAM = UJ4KC2QN7B; ENABLE_BITCODE = YES; INFOPLIST_FILE = Stepic/Info.plist; @@ -7518,7 +7518,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 163; + CURRENT_PROJECT_VERSION = 164; DEVELOPMENT_TEAM = UJ4KC2QN7B; ENABLE_BITCODE = YES; INFOPLIST_FILE = Stepic/Info.plist; diff --git a/Stepic/Info.plist b/Stepic/Info.plist index bb0f15fbb1..b7f2260dd7 100644 --- a/Stepic/Info.plist +++ b/Stepic/Info.plist @@ -56,7 +56,7 @@ CFBundleVersion - 163 + 164 Fabric APIKey diff --git a/StepicTests/Info.plist b/StepicTests/Info.plist index cf382645a9..2a17f1d36b 100644 --- a/StepicTests/Info.plist +++ b/StepicTests/Info.plist @@ -19,6 +19,6 @@ CFBundleSignature ???? CFBundleVersion - 163 + 164 diff --git a/StickerPackExtension/Info.plist b/StickerPackExtension/Info.plist index 2e344d75ca..8b5b267e52 100644 --- a/StickerPackExtension/Info.plist +++ b/StickerPackExtension/Info.plist @@ -19,7 +19,7 @@ CFBundleShortVersionString 1.103 CFBundleVersion - 163 + 164 NSExtension NSExtensionPointIdentifier