diff --git a/iosHyperskillApp/iosHyperskillApp.xcodeproj/project.pbxproj b/iosHyperskillApp/iosHyperskillApp.xcodeproj/project.pbxproj index 99b9415f02..5da0d9b2fb 100644 --- a/iosHyperskillApp/iosHyperskillApp.xcodeproj/project.pbxproj +++ b/iosHyperskillApp/iosHyperskillApp.xcodeproj/project.pbxproj @@ -30,6 +30,9 @@ 2C05AC622A0EDFC20039C7EF /* ProjectSelectionListGridCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C05AC612A0EDFC20039C7EF /* ProjectSelectionListGridCellView.swift */; }; 2C05AC642A0EDFD80039C7EF /* ProjectSelectionListGridCellBadgesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C05AC632A0EDFD80039C7EF /* ProjectSelectionListGridCellBadgesView.swift */; }; 2C069EB128F03782009A3DA1 /* AnalyticExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C069EB028F03782009A3DA1 /* AnalyticExtensions.swift */; }; + 2C078CE52AE26CB400D97E24 /* FillBlanksQuizTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C078CE42AE26CB400D97E24 /* FillBlanksQuizTitleView.swift */; }; + 2C078CE72AE26E2000D97E24 /* UIKitSeparatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C078CE62AE26E2000D97E24 /* UIKitSeparatorView.swift */; }; + 2C078CE92AE29D0600D97E24 /* StepQuizFillBlanksViewDataMapperCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C078CE82AE29D0600D97E24 /* StepQuizFillBlanksViewDataMapperCache.swift */; }; 2C079681285CEEFE00EE0487 /* StepQuizMatchingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C079680285CEEFE00EE0487 /* StepQuizMatchingViewModel.swift */; }; 2C079683285CEF0900EE0487 /* StepQuizMatchingAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C079682285CEF0900EE0487 /* StepQuizMatchingAssembly.swift */; }; 2C079685285CFFEE00EE0487 /* StepQuizSortingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C079684285CFFEE00EE0487 /* StepQuizSortingViewModel.swift */; }; @@ -198,6 +201,7 @@ 2C5CBBE52948FA7400113007 /* StepQuizSQLAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C5CBBE42948FA7400113007 /* StepQuizSQLAssembly.swift */; }; 2C5CBBE72948FC7A00113007 /* StepQuizSQLView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C5CBBE62948FC7A00113007 /* StepQuizSQLView.swift */; }; 2C5EC2C82AC41CAF0098D126 /* StepQuizCodeEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C5EC2C72AC41CAF0098D126 /* StepQuizCodeEditorView.swift */; }; + 2C5F19152AE667C90039414D /* LeftAlignedCollectionViewFlowLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C5F19142AE667C90039414D /* LeftAlignedCollectionViewFlowLayout.swift */; }; 2C5F4A5A2971C71200677530 /* GamificationToolbarContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C5F4A592971C71200677530 /* GamificationToolbarContent.swift */; }; 2C62AD582AB43A8F00F3DD5B /* BadgeRankView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C62AD572AB43A8F00F3DD5B /* BadgeRankView.swift */; }; 2C6672062A527C0D0040EA2F /* ProgressScreenSectionTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C6672052A527C0D0040EA2F /* ProgressScreenSectionTitleView.swift */; }; @@ -225,6 +229,18 @@ 2C7994AF2A1299B800874C16 /* TrackSelectionListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C7994AE2A1299B800874C16 /* TrackSelectionListView.swift */; }; 2C7994B12A129D6100874C16 /* TrackSelectionListSkeletonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C7994B02A129D6100874C16 /* TrackSelectionListSkeletonView.swift */; }; 2C7A1B1F2922EB070018D72C /* Hyperskill-Mobile_shared.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C7A1B1E2922EB070018D72C /* Hyperskill-Mobile_shared.swift */; settings = {COMPILER_FLAGS = "-w -Xanalyzer -analyzer-disable-all-checks"; }; }; + 2C7CB66B2ADFB947006F78DA /* StepQuizFillBlanksAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C7CB66A2ADFB947006F78DA /* StepQuizFillBlanksAssembly.swift */; }; + 2C7CB66D2ADFB951006F78DA /* StepQuizFillBlanksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C7CB66C2ADFB951006F78DA /* StepQuizFillBlanksViewModel.swift */; }; + 2C7CB66F2ADFB96F006F78DA /* StepQuizFillBlanksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C7CB66E2ADFB96F006F78DA /* StepQuizFillBlanksView.swift */; }; + 2C7CB6762ADFCFCC006F78DA /* StepQuizFillBlanksViewData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C7CB6752ADFCFCC006F78DA /* StepQuizFillBlanksViewData.swift */; }; + 2C7CB6782ADFD0E8006F78DA /* StepQuizFillBlanksViewDataMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C7CB6772ADFD0E8006F78DA /* StepQuizFillBlanksViewDataMapper.swift */; }; + 2C7CB67B2ADFD9B4006F78DA /* FillBlanksTextCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C7CB67A2ADFD9B4006F78DA /* FillBlanksTextCollectionViewCell.swift */; }; + 2C7CB67E2ADFDA62006F78DA /* FillBlanksQuizInputContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C7CB67D2ADFDA62006F78DA /* FillBlanksQuizInputContainerView.swift */; }; + 2C7CB6802ADFDADD006F78DA /* FillBlanksInputCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C7CB67F2ADFDADD006F78DA /* FillBlanksInputCollectionViewCell.swift */; }; + 2C7CB6822ADFDB45006F78DA /* UIFont+SizeOfString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C7CB6812ADFDB45006F78DA /* UIFont+SizeOfString.swift */; }; + 2C7CB6842ADFF1A6006F78DA /* FillBlanksQuizCollectionViewAdapter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C7CB6832ADFF1A6006F78DA /* FillBlanksQuizCollectionViewAdapter.swift */; }; + 2C7CB6862ADFF389006F78DA /* FillBlanksQuizView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C7CB6852ADFF389006F78DA /* FillBlanksQuizView.swift */; }; + 2C7CB6882ADFF57D006F78DA /* FillBlanksQuizViewWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C7CB6872ADFF57D006F78DA /* FillBlanksQuizViewWrapper.swift */; }; 2C80D4FD288C4D0D00B2CD1E /* StepQuizCodeFullScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C80D4FC288C4D0D00B2CD1E /* StepQuizCodeFullScreenViewModel.swift */; }; 2C80D4FF288C4D4400B2CD1E /* StepQuizCodeFullScreenOutputProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C80D4FE288C4D4400B2CD1E /* StepQuizCodeFullScreenOutputProtocol.swift */; }; 2C80D503288C5EBB00B2CD1E /* StepQuizCodeNavigationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C80D502288C5EBB00B2CD1E /* StepQuizCodeNavigationState.swift */; }; @@ -631,6 +647,9 @@ 2C05AC612A0EDFC20039C7EF /* ProjectSelectionListGridCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectSelectionListGridCellView.swift; sourceTree = ""; }; 2C05AC632A0EDFD80039C7EF /* ProjectSelectionListGridCellBadgesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProjectSelectionListGridCellBadgesView.swift; sourceTree = ""; }; 2C069EB028F03782009A3DA1 /* AnalyticExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticExtensions.swift; sourceTree = ""; }; + 2C078CE42AE26CB400D97E24 /* FillBlanksQuizTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FillBlanksQuizTitleView.swift; sourceTree = ""; }; + 2C078CE62AE26E2000D97E24 /* UIKitSeparatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIKitSeparatorView.swift; sourceTree = ""; }; + 2C078CE82AE29D0600D97E24 /* StepQuizFillBlanksViewDataMapperCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizFillBlanksViewDataMapperCache.swift; sourceTree = ""; }; 2C079680285CEEFE00EE0487 /* StepQuizMatchingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizMatchingViewModel.swift; sourceTree = ""; }; 2C079682285CEF0900EE0487 /* StepQuizMatchingAssembly.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizMatchingAssembly.swift; sourceTree = ""; }; 2C079684285CFFEE00EE0487 /* StepQuizSortingViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizSortingViewModel.swift; sourceTree = ""; }; @@ -806,6 +825,7 @@ 2C5CBBE42948FA7400113007 /* StepQuizSQLAssembly.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizSQLAssembly.swift; sourceTree = ""; }; 2C5CBBE62948FC7A00113007 /* StepQuizSQLView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizSQLView.swift; sourceTree = ""; }; 2C5EC2C72AC41CAF0098D126 /* StepQuizCodeEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizCodeEditorView.swift; sourceTree = ""; }; + 2C5F19142AE667C90039414D /* LeftAlignedCollectionViewFlowLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeftAlignedCollectionViewFlowLayout.swift; sourceTree = ""; }; 2C5F4A592971C71200677530 /* GamificationToolbarContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GamificationToolbarContent.swift; sourceTree = ""; }; 2C62AD572AB43A8F00F3DD5B /* BadgeRankView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeRankView.swift; sourceTree = ""; }; 2C6672052A527C0D0040EA2F /* ProgressScreenSectionTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressScreenSectionTitleView.swift; sourceTree = ""; }; @@ -833,6 +853,18 @@ 2C7994AE2A1299B800874C16 /* TrackSelectionListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackSelectionListView.swift; sourceTree = ""; }; 2C7994B02A129D6100874C16 /* TrackSelectionListSkeletonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackSelectionListSkeletonView.swift; sourceTree = ""; }; 2C7A1B1E2922EB070018D72C /* Hyperskill-Mobile_shared.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Hyperskill-Mobile_shared.swift"; sourceTree = ""; }; + 2C7CB66A2ADFB947006F78DA /* StepQuizFillBlanksAssembly.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizFillBlanksAssembly.swift; sourceTree = ""; }; + 2C7CB66C2ADFB951006F78DA /* StepQuizFillBlanksViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizFillBlanksViewModel.swift; sourceTree = ""; }; + 2C7CB66E2ADFB96F006F78DA /* StepQuizFillBlanksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizFillBlanksView.swift; sourceTree = ""; }; + 2C7CB6752ADFCFCC006F78DA /* StepQuizFillBlanksViewData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizFillBlanksViewData.swift; sourceTree = ""; }; + 2C7CB6772ADFD0E8006F78DA /* StepQuizFillBlanksViewDataMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizFillBlanksViewDataMapper.swift; sourceTree = ""; }; + 2C7CB67A2ADFD9B4006F78DA /* FillBlanksTextCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FillBlanksTextCollectionViewCell.swift; sourceTree = ""; }; + 2C7CB67D2ADFDA62006F78DA /* FillBlanksQuizInputContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FillBlanksQuizInputContainerView.swift; sourceTree = ""; }; + 2C7CB67F2ADFDADD006F78DA /* FillBlanksInputCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FillBlanksInputCollectionViewCell.swift; sourceTree = ""; }; + 2C7CB6812ADFDB45006F78DA /* UIFont+SizeOfString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFont+SizeOfString.swift"; sourceTree = ""; }; + 2C7CB6832ADFF1A6006F78DA /* FillBlanksQuizCollectionViewAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FillBlanksQuizCollectionViewAdapter.swift; sourceTree = ""; }; + 2C7CB6852ADFF389006F78DA /* FillBlanksQuizView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FillBlanksQuizView.swift; sourceTree = ""; }; + 2C7CB6872ADFF57D006F78DA /* FillBlanksQuizViewWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FillBlanksQuizViewWrapper.swift; sourceTree = ""; }; 2C80D4FC288C4D0D00B2CD1E /* StepQuizCodeFullScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizCodeFullScreenViewModel.swift; sourceTree = ""; }; 2C80D4FE288C4D4400B2CD1E /* StepQuizCodeFullScreenOutputProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizCodeFullScreenOutputProtocol.swift; sourceTree = ""; }; 2C80D502288C5EBB00B2CD1E /* StepQuizCodeNavigationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizCodeNavigationState.swift; sourceTree = ""; }; @@ -2105,6 +2137,14 @@ path = StepQuizSQL; sourceTree = ""; }; + 2C5F19162AE6857F0039414D /* CollectionViewLayouts */ = { + isa = PBXGroup; + children = ( + 2C5F19142AE667C90039414D /* LeftAlignedCollectionViewFlowLayout.swift */, + ); + path = CollectionViewLayouts; + sourceTree = ""; + }; 2C5F4A582971C6C500677530 /* GamificationToolbar */ = { isa = PBXGroup; children = ( @@ -2212,6 +2252,58 @@ path = sharedSwift; sourceTree = ""; }; + 2C7CB6692ADFB91C006F78DA /* StepQuizFillBlanks */ = { + isa = PBXGroup; + children = ( + 2C7CB66A2ADFB947006F78DA /* StepQuizFillBlanksAssembly.swift */, + 2C7CB66C2ADFB951006F78DA /* StepQuizFillBlanksViewModel.swift */, + 2C7CB6742ADFCB1E006F78DA /* ViewData */, + 2C7CB6702ADFB985006F78DA /* Views */, + ); + path = StepQuizFillBlanks; + sourceTree = ""; + }; + 2C7CB6702ADFB985006F78DA /* Views */ = { + isa = PBXGroup; + children = ( + 2C7CB6872ADFF57D006F78DA /* FillBlanksQuizViewWrapper.swift */, + 2C7CB66E2ADFB96F006F78DA /* StepQuizFillBlanksView.swift */, + 2C7CB6792ADFD985006F78DA /* UIKit */, + ); + path = Views; + sourceTree = ""; + }; + 2C7CB6742ADFCB1E006F78DA /* ViewData */ = { + isa = PBXGroup; + children = ( + 2C7CB6752ADFCFCC006F78DA /* StepQuizFillBlanksViewData.swift */, + 2C7CB6772ADFD0E8006F78DA /* StepQuizFillBlanksViewDataMapper.swift */, + 2C078CE82AE29D0600D97E24 /* StepQuizFillBlanksViewDataMapperCache.swift */, + ); + path = ViewData; + sourceTree = ""; + }; + 2C7CB6792ADFD985006F78DA /* UIKit */ = { + isa = PBXGroup; + children = ( + 2C7CB6832ADFF1A6006F78DA /* FillBlanksQuizCollectionViewAdapter.swift */, + 2C7CB67D2ADFDA62006F78DA /* FillBlanksQuizInputContainerView.swift */, + 2C078CE42AE26CB400D97E24 /* FillBlanksQuizTitleView.swift */, + 2C7CB6852ADFF389006F78DA /* FillBlanksQuizView.swift */, + 2C7CB67C2ADFD9BF006F78DA /* Cells */, + ); + path = UIKit; + sourceTree = ""; + }; + 2C7CB67C2ADFD9BF006F78DA /* Cells */ = { + isa = PBXGroup; + children = ( + 2C7CB67F2ADFDADD006F78DA /* FillBlanksInputCollectionViewCell.swift */, + 2C7CB67A2ADFD9B4006F78DA /* FillBlanksTextCollectionViewCell.swift */, + ); + path = Cells; + sourceTree = ""; + }; 2C82BA302844AFED004C9013 /* PlaceholderView */ = { isa = PBXGroup; children = ( @@ -2483,6 +2575,7 @@ 2C5B2A24286596A80097B270 /* UICollectionView+RegisterReusable.swift */, 2C58DE282803D197002A2774 /* UIColor+DynamicColor.swift */, 2C20FBAF284F1D8B006D879E /* UIColor+Hex.swift */, + 2C7CB6812ADFDB45006F78DA /* UIFont+SizeOfString.swift */, 2CDF14D728EF1E080060D972 /* UINavigationControllerExtensions.swift */, 2C5B2A22286596400097B270 /* UITableView+RegisterReusable.swift */, 2CC78D0D28C75A3D0006EF91 /* UIViewControllerExtensions.swift */, @@ -2691,6 +2784,8 @@ 2CBD1918291D399500F5FB0B /* UIKitBounceButton.swift */, 2CE1E188292CCB450041FE14 /* UIKitIntrospectionView.swift */, 2CBD191C291D3BF400F5FB0B /* UIKitRoundedRectangleButton.swift */, + 2C078CE62AE26E2000D97E24 /* UIKitSeparatorView.swift */, + 2C5F19162AE6857F0039414D /* CollectionViewLayouts */, ); path = UIKit; sourceTree = ""; @@ -2937,6 +3032,7 @@ E9802D03281BB5A500CF3AC1 /* StepQuizChoice */, 2C96742C288823EB0091B6C9 /* StepQuizCode */, 2CBFB94828897D970044D1BA /* StepQuizCodeFullScreen */, + 2C7CB6692ADFB91C006F78DA /* StepQuizFillBlanks */, E9F27D7629064456007F16D7 /* StepQuizHints */, E9D2D66E284E0A5D000757AC /* StepQuizMatching */, E96D49382A9CCE9A00BD78FE /* StepQuizParsons */, @@ -3918,6 +4014,7 @@ 2CBC97D22A555F190078E445 /* StageImplementProjectCompletedModalViewController.swift in Sources */, E9950E9328893F1700C4D962 /* ProfileDailyStudyRemindersView.swift in Sources */, 2C8E66D52878771B00D3928D /* ProfilePresentationDescription.swift in Sources */, + 2C5F19152AE667C90039414D /* LeftAlignedCollectionViewFlowLayout.swift in Sources */, E993E9A928426FF2005988EC /* StepQuizSortingViewData.swift in Sources */, E9F59B90289FE053001CEA02 /* ProfileSettingsViewModel.swift in Sources */, 2C967434288824450091B6C9 /* StepQuizCodeView.swift in Sources */, @@ -3989,6 +4086,7 @@ 2C0DB90A2864515B001EA35E /* CodeEditorViewDelegate.swift in Sources */, 2C336D132865C47900C91342 /* ApplicationTheme.swift in Sources */, E9AB310F29DECC7500645376 /* StudyPlanSectionHeaderStatisticsView.swift in Sources */, + 2C7CB66D2ADFB951006F78DA /* StepQuizFillBlanksViewModel.swift in Sources */, E99B21812887E535006A6154 /* StepQuizSkeletonViewFactory.swift in Sources */, E9B55A5D29C978E40066900E /* ProblemsLimitReachedModalViewController.swift in Sources */, 2CFD7C6A2925447600902748 /* StepQuizFeatureStateKsExtensions.swift in Sources */, @@ -4026,20 +4124,25 @@ 2C023C86285D927A00D2D5A9 /* StepQuizTableAssembly.swift in Sources */, 2C20FBC4284F67F3006D879E /* ProcessedContentWebView.swift in Sources */, 2C4F639B2A101DCE00D4EE39 /* ProjectSelectionListGridView.swift in Sources */, + 2C7CB6842ADFF1A6006F78DA /* FillBlanksQuizCollectionViewAdapter.swift in Sources */, 2CA5F8EC2994C3AB0013B854 /* DebugView.swift in Sources */, 2C005DCC27EF5B0300DC6503 /* GoogleServiceInfo.swift in Sources */, 2CBD191D291D3BF400F5FB0B /* UIKitRoundedRectangleButton.swift in Sources */, + 2C078CE92AE29D0600D97E24 /* StepQuizFillBlanksViewDataMapperCache.swift in Sources */, E9A6250F28ABAE83009423EE /* OnboardingViewModel.swift in Sources */, 2C0F3CFC2A80A47600947C35 /* BadgeDetailsModalView.swift in Sources */, E9F504D029128B5300B788C7 /* StepQuizHintsAssembly.swift in Sources */, E97BEA1E2977D26F00348EEC /* TopicCompletedModalViewController.swift in Sources */, 2CEEE03128916A3100282849 /* ProblemOfDayAssembly.swift in Sources */, + 2C7CB66F2ADFB96F006F78DA /* StepQuizFillBlanksView.swift in Sources */, 2C8E4FB628490C020011ADFA /* PanModalPresenter.swift in Sources */, 2C55133B28B8DFE8009F7627 /* Debouncer.swift in Sources */, + 2C7CB6882ADFF57D006F78DA /* FillBlanksQuizViewWrapper.swift in Sources */, E9A6250D28ABAE30009423EE /* OnboardingAssembly.swift in Sources */, 2C336D172865C9CD00C91342 /* UIView+TraitCollection.swift in Sources */, 2CD48D872858639500CFCC4A /* StepQuizViewModel.swift in Sources */, E9B55A5929C8A0760066900E /* ProblemsLimitView.swift in Sources */, + 2C7CB6862ADFF389006F78DA /* FillBlanksQuizView.swift in Sources */, E94D238D28057F440003273F /* AuthCredentialsView.swift in Sources */, 2CEEE03728917F1100282849 /* TimeIntervalExtensions.swift in Sources */, 2CE31F4827F1BB79008EEE66 /* AuthSocialAssembly.swift in Sources */, @@ -4047,6 +4150,7 @@ 2C5261A52993CBF100B4E8F6 /* TopicProgressExtensions.swift in Sources */, 2C0DB90728644F2C001EA35E /* CodeEditorView.swift in Sources */, 2CDA98452944590800ADE539 /* ProfileStatisticsView.swift in Sources */, + 2C7CB6822ADFDB45006F78DA /* UIFont+SizeOfString.swift in Sources */, 2CC4AAF1280DB513002276A0 /* WebOAuthService.swift in Sources */, 2CF2DA3A27EC5B2D0055426D /* Assembly.swift in Sources */, 2C27C77C28772F8A006A641A /* ImageDecoders+SVG.swift in Sources */, @@ -4086,6 +4190,7 @@ E9523BF029DA933C0013A661 /* StudyPlanViewModel.swift in Sources */, 2CCF3B5828004FC40075D12C /* UserAgentBuilder.swift in Sources */, 2C963BC52812D1A70036DD53 /* HomeView.swift in Sources */, + 2C078CE52AE26CB400D97E24 /* FillBlanksQuizTitleView.swift in Sources */, 2C93C2D8292EBBB5004D1861 /* AuthSocialFeatureStateKsExtensions.swift in Sources */, E9101713283296F3002E70F5 /* RadioButton.swift in Sources */, 2C68FD7C2ABC1FF700D9EBE2 /* NotificationsOnboardingContentView.swift in Sources */, @@ -4130,6 +4235,7 @@ 2C7994AD2A12940D00874C16 /* TrackSelectionListGridView.swift in Sources */, 2C20FBA8284F193A006D879E /* ContentProcessingRule.swift in Sources */, E9AB311429DED7FE00645376 /* StudyPlanSectionItemIconView.swift in Sources */, + 2C7CB66B2ADFB947006F78DA /* StepQuizFillBlanksAssembly.swift in Sources */, 2CBC97CA2A5553330078E445 /* StageImplementStageCompletedModalViewController.swift in Sources */, 2C20FBC9284F6F97006D879E /* UnitConverters.swift in Sources */, 2C336D152865C49D00C91342 /* ApplicationThemeService.swift in Sources */, @@ -4153,6 +4259,7 @@ 2CD48D892858657100CFCC4A /* StepQuizView.swift in Sources */, 2CD4148729A8D92000ACA855 /* CodeInputPasteControl.swift in Sources */, 2C8E4FA12848BF1F0011ADFA /* StepQuizTableSelectColumnsViewController.swift in Sources */, + 2C078CE72AE26E2000D97E24 /* UIKitSeparatorView.swift in Sources */, 2C1F5877280D2B4800372A37 /* ApplicationInfo.swift in Sources */, 2C5CA23A2A201A3900DBF2F9 /* ProjectSelectionDetailsLearningOutcomesView.swift in Sources */, 2C20FBBE284F658E006D879E /* ProcessedContentTextView.swift in Sources */, @@ -4163,6 +4270,7 @@ E94BB0442A9DEEFC00736B7C /* StepQuizParsonsSkeletonView.swift in Sources */, 2CB45764288ED6D4007C2D77 /* StepQuizActionButtonCodeQuizDelegate.swift in Sources */, 2C5B2A25286596A80097B270 /* UICollectionView+RegisterReusable.swift in Sources */, + 2C7CB6802ADFDADD006F78DA /* FillBlanksInputCollectionViewCell.swift in Sources */, 2CA542252ACAE18500EF24B5 /* IntrospectScrollView.swift in Sources */, E900D1052843573A00A77BBC /* StepQuizSortingIcon.swift in Sources */, 2CCCA39B2862E3BB00D98089 /* StepQuizStringDataType.swift in Sources */, @@ -4249,6 +4357,7 @@ 2C8E66D9287896CF00D3928D /* TabNavigationLazyView.swift in Sources */, 2CA8E095281039EB00154088 /* BounceButtonStyle.swift in Sources */, 2CEEE03328916A3D00282849 /* ProblemOfDayViewModel.swift in Sources */, + 2C7CB6762ADFCFCC006F78DA /* StepQuizFillBlanksViewData.swift in Sources */, 2C05AC5F2A0ED9710039C7EF /* BadgeView+ConcreateTypes.swift in Sources */, 2C1061AC285C3C4300EBD614 /* StepQuizChoiceViewModel.swift in Sources */, 2C0EB9502A151B56006DC84B /* TrackSelectionListViewModel.swift in Sources */, @@ -4285,9 +4394,11 @@ 2C1061A4285C34C900EBD614 /* StepQuizChildQuizOutputProtocol.swift in Sources */, 2C20FBA4284F165A006D879E /* ProcessedContent.swift in Sources */, 2C66720D2A5299C30040EA2F /* ProgressScreenCardSkeletonView.swift in Sources */, + 2C7CB67E2ADFDA62006F78DA /* FillBlanksQuizInputContainerView.swift in Sources */, 2C55E1902A056AFC00FE58D7 /* ProblemsLimitSkeletonView.swift in Sources */, E94BB0482A9DF9DD00736B7C /* StepQuizParsonsView.swift in Sources */, E99CCB0B287E945300898BBF /* HomeViewModel.swift in Sources */, + 2C7CB6782ADFD0E8006F78DA /* StepQuizFillBlanksViewDataMapper.swift in Sources */, 2C0FA879292FD73400A37636 /* ProfileSettingsFeatureStateKsExtensions.swift in Sources */, 2C1061AA285C3C3300EBD614 /* StepQuizChoiceAssembly.swift in Sources */, 2CF72AA828477E0600E1C192 /* StepQuizTableRowView.swift in Sources */, @@ -4348,6 +4459,7 @@ 2C9ECBA3284736090015CFD2 /* StepViewDataMapper.swift in Sources */, 2CAE8CF2280525C900E6C83D /* StepView.swift in Sources */, 2C023C8B285DCA2100D2D5A9 /* ReplyExtensions.swift in Sources */, + 2C7CB67B2ADFD9B4006F78DA /* FillBlanksTextCollectionViewCell.swift in Sources */, E9FB89B02893EA900011EFFB /* UserNotificationsCenterDelegate.swift in Sources */, E9FAF38F299F61AE001FC596 /* View+MeasureSize.swift in Sources */, 2C96744428883E710091B6C9 /* BlockOptionsExtensions.swift in Sources */, diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Extensions/UIKit/UIFont+SizeOfString.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Extensions/UIKit/UIFont+SizeOfString.swift new file mode 100644 index 0000000000..144e6b836f --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Extensions/UIKit/UIFont+SizeOfString.swift @@ -0,0 +1,12 @@ +import UIKit + +extension UIFont { + func sizeOfString(string: String, constrainedToWidth width: Double) -> CGSize { + NSString(string: string).boundingRect( + with: CGSize(width: width, height: Double.greatestFiniteMagnitude), + options: NSStringDrawingOptions.usesLineFragmentOrigin, + attributes: [NSAttributedString.Key.font: self], + context: nil + ).size + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Models/Constants/Strings.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Models/Constants/Strings.swift index 165181ee79..509a69c862 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Models/Constants/Strings.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Models/Constants/Strings.swift @@ -203,6 +203,12 @@ enum Strings { } } + // MARK: - StepQuizFillBlanks- + + enum StepQuizFillBlanks { + static let title = sharedStrings.step_quiz_fill_blanks_title.localized() + } + // MARK: - StageImplement - enum StageImplement { diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/ChildProtocols/StepQuizChildQuizAssembly.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/ChildProtocols/StepQuizChildQuizAssembly.swift index 3fa565503a..3c7849d633 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/ChildProtocols/StepQuizChildQuizAssembly.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/ChildProtocols/StepQuizChildQuizAssembly.swift @@ -108,6 +108,15 @@ enum StepQuizChildQuizViewFactory { moduleOutput: moduleOutput ) .makeModule() + case .fillBlanks: + StepQuizFillBlanksAssembly( + step: step, + dataset: dataset, + reply: reply, + provideModuleInputCallback: provideModuleInputCallback, + moduleOutput: moduleOutput + ) + .makeModule() case .unsupported(let blockName): fatalError("Unsupported quiz = \(blockName)") } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/ChildProtocols/StepQuizChildQuizType.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/ChildProtocols/StepQuizChildQuizType.swift index ccb2f28d69..9edd4adc15 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/ChildProtocols/StepQuizChildQuizType.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/ChildProtocols/StepQuizChildQuizType.swift @@ -13,6 +13,7 @@ enum StepQuizChildQuizType { case number case math case parsons + case fillBlanks case unsupported(blockName: String) var isCodeRelated: Bool { @@ -49,6 +50,8 @@ enum StepQuizChildQuizType { self = .math case BlockName.shared.PARSONS: self = .parsons + case BlockName.shared.FILL_BLANKS: + self = .fillBlanks default: self = .unsupported(blockName: step.block.name) } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/ViewData/StepQuizViewDataMapper.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/ViewData/StepQuizViewDataMapper.swift index 1685f1bdcf..212b326f0d 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/ViewData/StepQuizViewDataMapper.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/ViewData/StepQuizViewDataMapper.swift @@ -12,13 +12,23 @@ final class StepQuizViewDataMapper { } func mapStepDataToViewData(step: Step, state: StepQuizFeatureStepQuizStateKs) -> StepQuizViewData { - let quizType: StepQuizChildQuizType = { - if state == .unsupported { - return .unsupported(blockName: step.block.name) + let attemptLoadedState: StepQuizFeatureStepQuizStateAttemptLoaded? = { + switch state { + case .attemptLoading(let attemptLoadingState): + return attemptLoadingState.oldState + case .attemptLoaded(let attemptLoadedState): + return attemptLoadedState + default: + return nil } - return StepQuizChildQuizType(step: step) }() + let quizType = resolveQuizType( + step: step, + state: state, + attemptLoadedState: attemptLoadedState + ) + if case .unsupported = quizType { return StepQuizViewData( formattedStats: nil, @@ -35,17 +45,6 @@ final class StepQuizViewDataMapper { millisSinceLastCompleted: step.millisSinceLastCompleted ) - let attemptLoadedState: StepQuizFeatureStepQuizStateAttemptLoaded? = { - switch state { - case .attemptLoading(let attemptLoadingState): - return attemptLoadingState.oldState - case .attemptLoaded(let attemptLoadedState): - return attemptLoadedState - default: - return nil - } - }() - let quizName: String? = { guard let dataset = attemptLoadedState?.attempt.dataset else { return nil @@ -91,4 +90,35 @@ final class StepQuizViewDataMapper { stepHasHints: stepHasHints ) } + + private func resolveQuizType( + step: Step, + state: StepQuizFeatureStepQuizStateKs, + attemptLoadedState: StepQuizFeatureStepQuizStateAttemptLoaded? + ) -> StepQuizChildQuizType { + let unsupportedChildQuizType = StepQuizChildQuizType.unsupported(blockName: step.block.name) + + if state == .unsupported { + return unsupportedChildQuizType + } + + let childQuizType = StepQuizChildQuizType(step: step) + + if case .fillBlanks = childQuizType { + guard let dataset = attemptLoadedState?.attempt.dataset else { + return childQuizType + } + + do { + try FillBlanksResolver.shared.resolve(dataset: dataset) + } catch { + #if DEBUG + print("StepQuizViewDataMapper: failed to resolve fill blanks quiz type, error = \(error)") + #endif + return unsupportedChildQuizType + } + } + + return childQuizType + } } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/Views/StepQuizSkeletonViewFactory.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/Views/StepQuizSkeletonViewFactory.swift index bb43a1613d..75b92c585c 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/Views/StepQuizSkeletonViewFactory.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuiz/Views/StepQuizSkeletonViewFactory.swift @@ -20,6 +20,9 @@ enum StepQuizSkeletonViewFactory { StepQuizStringSkeletonView() case .parsons: StepQuizParsonsSkeletonView() + case .fillBlanks: + #warning("TODO: FillBlanks skeleton view") + StepQuizParsonsSkeletonView() case .unsupported: SkeletonRoundedView() .frame(height: 100) diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/StepQuizFillBlanksAssembly.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/StepQuizFillBlanksAssembly.swift new file mode 100644 index 0000000000..5beefa3215 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/StepQuizFillBlanksAssembly.swift @@ -0,0 +1,48 @@ +import Highlightr +import shared +import SwiftUI + +final class StepQuizFillBlanksAssembly: StepQuizChildQuizAssembly { + var moduleInput: StepQuizChildQuizInputProtocol? + weak var moduleOutput: StepQuizChildQuizOutputProtocol? + + private let provideModuleInputCallback: (StepQuizChildQuizInputProtocol?) -> Void + + private let step: Step + private let dataset: Dataset + private let reply: Reply? + + init( + step: Step, + dataset: Dataset, + reply: Reply?, + provideModuleInputCallback: @escaping (StepQuizChildQuizInputProtocol?) -> Void, + moduleOutput: StepQuizChildQuizOutputProtocol? + ) { + self.step = step + self.dataset = dataset + self.reply = reply + self.provideModuleInputCallback = provideModuleInputCallback + self.moduleOutput = moduleOutput + } + + func makeModule() -> StepQuizFillBlanksView { + let viewModel = StepQuizFillBlanksViewModel( + step: step, + dataset: dataset, + reply: reply, + viewDataMapper: StepQuizFillBlanksViewDataMapper( + fillBlanksItemMapper: FillBlanksItemMapper(), + highlightr: Highlightr().require(), + codeEditorThemeService: CodeEditorThemeService(), + cache: StepQuizFillBlanksViewDataMapperCache.shared + ), + provideModuleInputCallback: provideModuleInputCallback + ) + + moduleInput = viewModel + viewModel.moduleOutput = moduleOutput + + return StepQuizFillBlanksView(viewModel: viewModel) + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/StepQuizFillBlanksViewModel.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/StepQuizFillBlanksViewModel.swift new file mode 100644 index 0000000000..89ca19f68e --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/StepQuizFillBlanksViewModel.swift @@ -0,0 +1,73 @@ +import Combine +import Foundation +import shared + +final class StepQuizFillBlanksViewModel: ObservableObject { + weak var moduleOutput: StepQuizChildQuizOutputProtocol? + private let provideModuleInputCallback: (StepQuizChildQuizInputProtocol?) -> Void + + @Published private(set) var viewData: StepQuizFillBlanksViewData + + init( + step: Step, + dataset: Dataset, + reply: Reply?, + viewDataMapper: StepQuizFillBlanksViewDataMapper, + provideModuleInputCallback: @escaping (StepQuizChildQuizInputProtocol?) -> Void + ) { + self.provideModuleInputCallback = provideModuleInputCallback + self.viewData = viewDataMapper.mapToViewData(dataset: dataset, reply: reply) + } + + func doProvideModuleInput() { + provideModuleInputCallback(self) + } + + func doInputTextUpdate(_ inputText: String, for component: StepQuizFillBlankComponent) { + guard let index = viewData.components.firstIndex( + where: { $0.id == component.id } + ) else { + return + } + + viewData.components[index].inputText = inputText + outputCurrentReply() + } + + func doSelectComponent(at indexPath: IndexPath) { + setIsFirstResponder(true, forComponentAt: indexPath) + } + + func doDeselectComponent(at indexPath: IndexPath) { + setIsFirstResponder(false, forComponentAt: indexPath) + } + + private func setIsFirstResponder(_ isFirstResponder: Bool, forComponentAt indexPath: IndexPath) { + guard viewData.components[indexPath.row].type == .input else { + return + } + + viewData.components[indexPath.row].isFirstResponder = isFirstResponder + } +} + +// MARK: - StepQuizFillBlanksViewModel: StepQuizChildQuizInputProtocol - + +extension StepQuizFillBlanksViewModel: StepQuizChildQuizInputProtocol { + func createReply() -> Reply { + let blanks: [String] = viewData.components.compactMap { component in + switch component.type { + case .text, .lineBreak: + return nil + case .input: + return component.inputText ?? "" + } + } + + return Reply.companion.fillBlanks(blanks: blanks) + } + + private func outputCurrentReply() { + moduleOutput?.handleChildQuizSync(reply: createReply()) + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/ViewData/StepQuizFillBlanksViewData.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/ViewData/StepQuizFillBlanksViewData.swift new file mode 100644 index 0000000000..ee3343af52 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/ViewData/StepQuizFillBlanksViewData.swift @@ -0,0 +1,21 @@ +import Foundation + +struct StepQuizFillBlanksViewData: Hashable { + var components: [StepQuizFillBlankComponent] +} + +struct StepQuizFillBlankComponent: Hashable, Identifiable { + var id: Int = 0 + let type: ComponentType + // text + var attributedText: NSAttributedString? + // input + var inputText: String? + var isFirstResponder = false + + enum ComponentType { + case text + case input + case lineBreak + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/ViewData/StepQuizFillBlanksViewDataMapper.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/ViewData/StepQuizFillBlanksViewDataMapper.swift new file mode 100644 index 0000000000..7bc5b2eef3 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/ViewData/StepQuizFillBlanksViewDataMapper.swift @@ -0,0 +1,89 @@ +import Foundation +import Highlightr +import shared + +final class StepQuizFillBlanksViewDataMapper { + private let fillBlanksItemMapper: FillBlanksItemMapper + private let highlightr: Highlightr + private let codeEditorThemeService: CodeEditorThemeServiceProtocol + private let cache: StepQuizFillBlanksViewDataMapperCacheProtocol + + init( + fillBlanksItemMapper: FillBlanksItemMapper, + highlightr: Highlightr, + codeEditorThemeService: CodeEditorThemeServiceProtocol, + cache: StepQuizFillBlanksViewDataMapperCacheProtocol + ) { + let theme = codeEditorThemeService.theme + highlightr.setTheme(to: theme.name) + highlightr.theme.setCodeFont(theme.font) + + self.highlightr = highlightr + self.codeEditorThemeService = codeEditorThemeService + self.cache = cache + self.fillBlanksItemMapper = fillBlanksItemMapper + } + + func mapToViewData(dataset: Dataset, reply: Reply?) -> StepQuizFillBlanksViewData { + guard let fillBlanksData = fillBlanksItemMapper.map(dataset: dataset, reply: reply) else { + return .init(components: []) + } + + return mapFillBlanksDataToViewData(fillBlanksData) + } + + private func mapFillBlanksDataToViewData(_ fillBlanksData: FillBlanksData) -> StepQuizFillBlanksViewData { + let language = fillBlanksData.language + + var components = fillBlanksData.fillBlanks + .map { mapFillBlanksItem($0, language: language) } + .flatMap { $0 } + for index in components.indices { + components[index].id = index + } + + return StepQuizFillBlanksViewData(components: components) + } + + private func mapFillBlanksItem( + _ fillBlanksItem: FillBlanksItem, + language: String? + ) -> [StepQuizFillBlankComponent] { + switch FillBlanksItemKs(fillBlanksItem) { + case .text(let data): + var result = [StepQuizFillBlankComponent]() + + if data.startsWithNewLine { + result.append(StepQuizFillBlankComponent(type: .lineBreak)) + } + + let hash = data.text.hashValue ^ UITraitCollection.current.userInterfaceStyle.hashValue + + if let cachedCode = cache.getHighlightedCode(for: hash) { + result.append(StepQuizFillBlankComponent(type: .text, attributedText: cachedCode)) + } else { + let unescaped = HTMLString.unescape(string: data.text) + + if let highlightedCode = highlight(code: unescaped, language: language) { + cache.setHighlightedCode(highlightedCode, for: hash) + result.append(StepQuizFillBlankComponent(type: .text, attributedText: highlightedCode)) + } else { + let attributedText = NSAttributedString( + string: unescaped, + attributes: [.font: codeEditorThemeService.theme.font] + ) + cache.setHighlightedCode(attributedText, for: hash) + result.append(StepQuizFillBlankComponent(type: .text, attributedText: attributedText)) + } + } + + return result + case .input(let data): + return [StepQuizFillBlankComponent(type: .input, inputText: data.inputText)] + } + } + + private func highlight(code: String, language: String?) -> NSAttributedString? { + highlightr.highlight(code, as: language, fastRender: true) + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/ViewData/StepQuizFillBlanksViewDataMapperCache.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/ViewData/StepQuizFillBlanksViewDataMapperCache.swift new file mode 100644 index 0000000000..7336f17573 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/ViewData/StepQuizFillBlanksViewDataMapperCache.swift @@ -0,0 +1,38 @@ +import Foundation + +protocol StepQuizFillBlanksViewDataMapperCacheProtocol: AnyObject { + func getHighlightedCode(for key: Int) -> NSAttributedString? + func setHighlightedCode(_ code: NSAttributedString, for key: Int) +} + +final class StepQuizFillBlanksViewDataMapperCache: StepQuizFillBlanksViewDataMapperCacheProtocol { + static let shared = StepQuizFillBlanksViewDataMapperCache() + + private lazy var cache: NSCache = { + let cache = NSCache() + cache.countLimit = 50 + return cache + }() + + private init() { + NotificationCenter.default.addObserver( + self, + selector: #selector(clearCacheOnEnterBackground), + name: UIApplication.didEnterBackgroundNotification, + object: nil + ) + } + + func getHighlightedCode(for key: Int) -> NSAttributedString? { + cache.object(forKey: key as NSNumber) + } + + func setHighlightedCode(_ code: NSAttributedString, for key: Int) { + cache.setObject(code, forKey: key as NSNumber) + } + + @objc + private func clearCacheOnEnterBackground() { + cache.removeAllObjects() + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/FillBlanksQuizViewWrapper.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/FillBlanksQuizViewWrapper.swift new file mode 100644 index 0000000000..869df774cc --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/FillBlanksQuizViewWrapper.swift @@ -0,0 +1,100 @@ +import Foundation +import SwiftUI +import UIKit + +struct FillBlanksQuizViewWrapper: UIViewRepresentable { + let components: [StepQuizFillBlankComponent] + let isUserInteractionEnabled: Bool + + var onInputDidChange: ((String, StepQuizFillBlankComponent) -> Void)? + + var onDidSelectComponent: ((IndexPath) -> Void)? + var onDidDeselectComponent: ((IndexPath) -> Void)? + + static func dismantleUIView(_ uiView: FillBlanksQuizView, coordinator: Coordinator) { + coordinator.onInputDidChange = nil + coordinator.onDidSelectComponent = nil + coordinator.onDidDeselectComponent = nil + coordinator.collectionViewAdapter.delegate = nil + } + + func makeUIView(context: Context) -> FillBlanksQuizView { + FillBlanksQuizView() + } + + func updateUIView(_ uiView: FillBlanksQuizView, context: Context) { + let collectionViewAdapter = context.coordinator.collectionViewAdapter + let shouldUpdateCollectionViewData = collectionViewAdapter.components != components + + collectionViewAdapter.components = components + collectionViewAdapter.isUserInteractionEnabled = isUserInteractionEnabled + + if shouldUpdateCollectionViewData { + uiView.updateCollectionViewData( + delegate: collectionViewAdapter, + dataSource: collectionViewAdapter + ) + } + + context.coordinator.onInputDidChange = { [weak collectionViewAdapter, weak uiView] inputText, component in + guard let collectionViewAdapter, let uiView else { + return + } + + guard let index = collectionViewAdapter.components.firstIndex( + where: { $0.id == component.id } + ) else { + return + } + + collectionViewAdapter.components[index].inputText = inputText + self.onInputDidChange?(inputText, component) + + uiView.invalidateCollectionViewLayout() + } + context.coordinator.onDidSelectComponent = onDidSelectComponent + context.coordinator.onDidDeselectComponent = onDidDeselectComponent + } + + func makeCoordinator() -> Coordinator { + Coordinator() + } +} + +extension FillBlanksQuizViewWrapper { + class Coordinator: NSObject, FillBlanksQuizCollectionViewAdapterDelegate { + private(set) var collectionViewAdapter = FillBlanksQuizCollectionViewAdapter() + + var onInputDidChange: ((String, StepQuizFillBlankComponent) -> Void)? + + var onDidSelectComponent: ((IndexPath) -> Void)? + var onDidDeselectComponent: ((IndexPath) -> Void)? + + override init() { + super.init() + collectionViewAdapter.delegate = self + } + + func fillBlanksQuizCollectionViewAdapter( + _ adapter: FillBlanksQuizCollectionViewAdapter, + inputDidChange inputText: String, + forComponent component: StepQuizFillBlankComponent + ) { + onInputDidChange?(inputText, component) + } + + func fillBlanksQuizCollectionViewAdapter( + _ adapter: FillBlanksQuizCollectionViewAdapter, + didSelectComponentAt indexPath: IndexPath + ) { + onDidSelectComponent?(indexPath) + } + + func fillBlanksQuizCollectionViewAdapter( + _ adapter: FillBlanksQuizCollectionViewAdapter, + didDeselectComponentAt indexPath: IndexPath + ) { + onDidDeselectComponent?(indexPath) + } + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/StepQuizFillBlanksView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/StepQuizFillBlanksView.swift new file mode 100644 index 0000000000..3a5888f093 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/StepQuizFillBlanksView.swift @@ -0,0 +1,25 @@ +import SwiftUI + +struct StepQuizFillBlanksView: View { + @StateObject var viewModel: StepQuizFillBlanksViewModel + + @Environment(\.isEnabled) private var isEnabled + + var body: some View { + FillBlanksQuizViewWrapper( + components: viewModel.viewData.components, + isUserInteractionEnabled: isEnabled, + onInputDidChange: viewModel.doInputTextUpdate(_:for:), + onDidSelectComponent: viewModel.doSelectComponent(at:), + onDidDeselectComponent: viewModel.doDeselectComponent(at:) + ) + .onAppear { + viewModel.doProvideModuleInput() + KeyboardManager.setEnableAutoToolbar(true) + } + .onDisappear { + KeyboardManager.setEnableAutoToolbar(false) + } + .opacity(isEnabled ? 1 : 0.5) + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/Cells/FillBlanksInputCollectionViewCell.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/Cells/FillBlanksInputCollectionViewCell.swift new file mode 100644 index 0000000000..46cc224a3e --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/Cells/FillBlanksInputCollectionViewCell.swift @@ -0,0 +1,133 @@ +import SnapKit +import UIKit + +extension FillBlanksInputCollectionViewCell { + struct Appearance { + let minWidth: CGFloat = 48 + + let cornerRadius: CGFloat = 8 + + let insets = LayoutInsets.small.uiEdgeInsets + + static let font = CodeEditorThemeService().theme.font + let textColor = UIColor.primaryText + } +} + +final class FillBlanksInputCollectionViewCell: UICollectionViewCell, Reusable { + var appearance = Appearance() + + private lazy var inputContainerView: FillBlanksQuizInputContainerView = { + let view = FillBlanksQuizInputContainerView( + appearance: .init(cornerRadius: self.appearance.cornerRadius) + ) + return view + }() + + private lazy var textField: UITextField = { + let textField = UITextField() + textField.font = Appearance.font + textField.textColor = self.appearance.textColor + textField.textAlignment = .center + textField.delegate = self + textField.addTarget(self, action: #selector(self.textFieldDidChange(_:)), for: .editingChanged) + // Disable features + textField.autocapitalizationType = .none + textField.autocorrectionType = .no + textField.spellCheckingType = .no + textField.smartDashesType = .no + textField.smartQuotesType = .no + textField.smartInsertDeleteType = .no + return textField + }() + + var text: String? { + didSet { + self.textField.text = self.text + } + } + + var isEnabled = true { + didSet { + self.isUserInteractionEnabled = self.isEnabled + } + } + + var state: FillBlanksQuizInputContainerView.State { + get { + self.inputContainerView.state + } + set { + self.inputContainerView.state = newValue + } + } + + var onInputChanged: ((String) -> Void)? + + var onBecameFirstResponder: (() -> Void)? + var onResignedFirstResponder: (() -> Void)? + + override init(frame: CGRect) { + super.init(frame: frame) + + self.addSubviews() + self.makeConstraints() + } + + @available(*, unavailable) + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func becomeFirstResponder() -> Bool { + self.textField.becomeFirstResponder() + } + + @objc + private func textFieldDidChange(_ sender: UITextField) { + self.onInputChanged?(sender.text ?? "") + } + + static func calculatePreferredContentSize(text: String, maxWidth: CGFloat) -> CGSize { + let appearance = Appearance() + + let sizeOfString = Appearance.font.sizeOfString(string: text, constrainedToWidth: Double(maxWidth)) + let widthOfStringWithInsets = appearance.insets.left + sizeOfString.width.rounded(.up) + appearance.insets.right + + let width = max(appearance.minWidth, min(maxWidth, widthOfStringWithInsets)) + let height = (appearance.insets.top + Appearance.font.pointSize + appearance.insets.bottom).rounded(.up) + + return CGSize(width: width, height: height) + } +} + +extension FillBlanksInputCollectionViewCell: ProgrammaticallyInitializableViewProtocol { + func addSubviews() { + self.contentView.addSubview(self.inputContainerView) + self.inputContainerView.addSubview(self.textField) + } + + func makeConstraints() { + self.inputContainerView.translatesAutoresizingMaskIntoConstraints = false + self.inputContainerView.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + + self.textField.translatesAutoresizingMaskIntoConstraints = false + self.textField.snp.makeConstraints { make in + make.edges.equalToSuperview().inset(self.appearance.insets) + } + } +} + +// MARK: - FillBlanksInputCollectionViewCell: UITextFieldDelegate - + +extension FillBlanksInputCollectionViewCell: UITextFieldDelegate { + func textFieldDidBeginEditing(_ textField: UITextField) { + onBecameFirstResponder?() + } + + func textFieldDidEndEditing(_ textField: UITextField) { + onResignedFirstResponder?() + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/Cells/FillBlanksTextCollectionViewCell.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/Cells/FillBlanksTextCollectionViewCell.swift new file mode 100644 index 0000000000..208c046e5b --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/Cells/FillBlanksTextCollectionViewCell.swift @@ -0,0 +1,88 @@ +import SnapKit +import UIKit + +extension FillBlanksTextCollectionViewCell { + struct Appearance { + let font = UIFont.preferredFont(forTextStyle: .body) + let textColor = UIColor.primaryText + } +} + +final class FillBlanksTextCollectionViewCell: UICollectionViewCell, Reusable { + private static var prototypeTextLabel: UILabel? + + private lazy var textLabel: UILabel = { + Self.makeTextLabel(appearance: self.appearance) + }() + + var appearance = Appearance() + + var attributedText: NSAttributedString? { + didSet { + if let attributedText = self.attributedText { + self.textLabel.attributedText = attributedText + } else { + self.textLabel.attributedText = nil + } + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + + self.addSubviews() + self.makeConstraints() + } + + @available(*, unavailable) + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + Self.prototypeTextLabel = nil + } + + static func calculatePreferredContentSize(attributedText: NSAttributedString?, maxWidth: CGFloat) -> CGSize { + if Self.prototypeTextLabel == nil { + Self.prototypeTextLabel = Self.makeTextLabel() + } + + guard let label = Self.prototypeTextLabel else { + return .zero + } + + label.frame = CGRect(x: 0, y: 0, width: maxWidth, height: CGFloat.greatestFiniteMagnitude) + + label.attributedText = attributedText + label.sizeToFit() + + var size = label.bounds.size + size.width = size.width.rounded(.up) + size.height = size.height.rounded(.up) + + return size + } + + private static func makeTextLabel(appearance: Appearance = Appearance()) -> UILabel { + let label = UILabel() + label.font = appearance.font + label.textColor = appearance.textColor + label.numberOfLines = 0 + label.lineBreakMode = .byWordWrapping + return label + } +} + +extension FillBlanksTextCollectionViewCell: ProgrammaticallyInitializableViewProtocol { + func addSubviews() { + self.contentView.addSubview(self.textLabel) + } + + func makeConstraints() { + self.textLabel.translatesAutoresizingMaskIntoConstraints = false + self.textLabel.snp.makeConstraints { make in + make.edges.equalToSuperview() + } + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/FillBlanksQuizCollectionViewAdapter.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/FillBlanksQuizCollectionViewAdapter.swift new file mode 100644 index 0000000000..fdeb08063c --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/FillBlanksQuizCollectionViewAdapter.swift @@ -0,0 +1,154 @@ +import UIKit + +protocol FillBlanksQuizCollectionViewAdapterDelegate: AnyObject { + func fillBlanksQuizCollectionViewAdapter( + _ adapter: FillBlanksQuizCollectionViewAdapter, + inputDidChange inputText: String, + forComponent component: StepQuizFillBlankComponent + ) + + func fillBlanksQuizCollectionViewAdapter( + _ adapter: FillBlanksQuizCollectionViewAdapter, + didSelectComponentAt indexPath: IndexPath + ) + func fillBlanksQuizCollectionViewAdapter( + _ adapter: FillBlanksQuizCollectionViewAdapter, + didDeselectComponentAt indexPath: IndexPath + ) +} + +final class FillBlanksQuizCollectionViewAdapter: NSObject { + weak var delegate: FillBlanksQuizCollectionViewAdapterDelegate? + + var components: [StepQuizFillBlankComponent] + var isUserInteractionEnabled = true + + init(components: [StepQuizFillBlankComponent] = []) { + self.components = components + super.init() + } +} + +// MARK: - FillBlanksQuizCollectionViewAdapter: UICollectionViewDataSource - + +extension FillBlanksQuizCollectionViewAdapter: UICollectionViewDataSource { + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + self.components.count + } + + func collectionView( + _ collectionView: UICollectionView, + cellForItemAt indexPath: IndexPath + ) -> UICollectionViewCell { + let component = self.components[indexPath.row] + + switch component.type { + case .text, .lineBreak: + let cell: FillBlanksTextCollectionViewCell = collectionView.dequeueReusableCell(for: indexPath) + cell.attributedText = component.attributedText + return cell + case .input: + let cell: FillBlanksInputCollectionViewCell = collectionView.dequeueReusableCell(for: indexPath) + cell.text = component.inputText + cell.isEnabled = self.isUserInteractionEnabled + cell.state = component.isFirstResponder ? .firstResponder : .default + cell.onInputChanged = { [weak self] text in + guard let strongSelf = self else { + return + } + + strongSelf.delegate?.fillBlanksQuizCollectionViewAdapter( + strongSelf, + inputDidChange: text, + forComponent: component + ) + } + cell.onBecameFirstResponder = { [weak self, weak cell] in + guard let strongSelf = self, + let strongCell = cell else { + return + } + + strongCell.state = .firstResponder + strongSelf.components[indexPath.row].isFirstResponder = true + + strongSelf.delegate?.fillBlanksQuizCollectionViewAdapter( + strongSelf, + didSelectComponentAt: indexPath + ) + } + cell.onResignedFirstResponder = { [weak self, weak cell] in + guard let strongSelf = self, + let strongCell = cell else { + return + } + + strongCell.state = .default + strongSelf.components[indexPath.row].isFirstResponder = false + + strongSelf.delegate?.fillBlanksQuizCollectionViewAdapter( + strongSelf, + didDeselectComponentAt: indexPath + ) + } + return cell + } + } +} + +// MARK: - FillBlanksQuizCollectionViewAdapter: UICollectionViewDelegateFlowLayout - + +extension FillBlanksQuizCollectionViewAdapter: UICollectionViewDelegateFlowLayout { + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + sizeForItemAt indexPath: IndexPath + ) -> CGSize { + guard let flowLayout = collectionViewLayout as? UICollectionViewFlowLayout else { + return .zero + } + + let maxWidth = collectionView.bounds.width + - flowLayout.sectionInset.left + - flowLayout.sectionInset.right + + let component = self.components[indexPath.row] + + switch component.type { + case .lineBreak: + return CGSize(width: maxWidth, height: flowLayout.minimumLineSpacing) + case .text: + return FillBlanksTextCollectionViewCell.calculatePreferredContentSize( + attributedText: component.attributedText, + maxWidth: maxWidth + ) + case .input: + return FillBlanksInputCollectionViewCell.calculatePreferredContentSize( + text: component.inputText ?? "", + maxWidth: maxWidth + ) + } + } + + func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool { + if !self.isUserInteractionEnabled { + return false + } + + switch self.components[indexPath.row].type { + case .text, .lineBreak: + return false + case .input: + return true + } + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + if self.components[indexPath.row].type == .input, + let cell = collectionView.cellForItem(at: indexPath) as? FillBlanksInputCollectionViewCell { + _ = cell.becomeFirstResponder() + } + + self.delegate?.fillBlanksQuizCollectionViewAdapter(self, didSelectComponentAt: indexPath) + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/FillBlanksQuizInputContainerView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/FillBlanksQuizInputContainerView.swift new file mode 100644 index 0000000000..93cb702af1 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/FillBlanksQuizInputContainerView.swift @@ -0,0 +1,64 @@ +import SnapKit +import UIKit + +extension FillBlanksQuizInputContainerView { + struct Appearance { + var cornerRadius: CGFloat = 18 + let borderWidth: CGFloat = 1 + + let backgroundColor = UIColor.clear + } +} + +final class FillBlanksQuizInputContainerView: UIView { + let appearance: Appearance + + var state = State.default { + didSet { + self.updateState() + } + } + + init( + frame: CGRect = .zero, + appearance: Appearance = Appearance() + ) { + self.appearance = appearance + super.init(frame: frame) + + self.clipsToBounds = true + self.layer.cornerRadius = self.appearance.cornerRadius + self.backgroundColor = self.appearance.backgroundColor + + self.updateState() + } + + @available(*, unavailable) + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + self.performBlockIfAppearanceChanged(from: previousTraitCollection, block: self.updateState) + } + + private func updateState() { + self.layer.borderColor = self.state.borderColor.cgColor + self.layer.borderWidth = self.appearance.borderWidth + } + + enum State { + case `default` + case firstResponder + + fileprivate var borderColor: UIColor { + switch self { + case .default: + return ColorPalette.onSurfaceAlpha12 + case .firstResponder: + return ColorPalette.primary + } + } + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/FillBlanksQuizTitleView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/FillBlanksQuizTitleView.swift new file mode 100644 index 0000000000..bc07e7742e --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/FillBlanksQuizTitleView.swift @@ -0,0 +1,88 @@ +import SnapKit +import UIKit + +extension FillBlanksQuizTitleView { + struct Appearance { + let textColor = UIColor.primaryText + let font = UIFont.preferredFont(forTextStyle: .headline) + let insets = LayoutInsets(horizontal: LayoutInsets.defaultInset, vertical: LayoutInsets.smallInset) + + var backgroundColor = ColorPalette.background + } +} + +final class FillBlanksQuizTitleView: UIView { + let appearance: Appearance + + private lazy var titleLabel: UILabel = { + let label = UILabel() + label.text = Strings.StepQuizFillBlanks.title + label.textColor = self.appearance.textColor + label.font = self.appearance.font + label.numberOfLines = 1 + return label + }() + + private lazy var topSeparatorView = UIKitSeparatorView() + private lazy var bottomSeparatorView = UIKitSeparatorView() + + override var intrinsicContentSize: CGSize { + let titleLabelHeight = self.titleLabel.intrinsicContentSize.height + let height = self.appearance.insets.top + titleLabelHeight + self.appearance.insets.bottom + return CGSize(width: UIView.noIntrinsicMetric, height: height) + } + + init( + frame: CGRect = .zero, + appearance: Appearance = Appearance() + ) { + self.appearance = appearance + super.init(frame: frame) + + self.setupView() + self.addSubviews() + self.makeConstraints() + } + + @available(*, unavailable) + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension FillBlanksQuizTitleView: ProgrammaticallyInitializableViewProtocol { + func setupView() { + self.backgroundColor = self.appearance.backgroundColor + } + + func addSubviews() { + self.addSubview(self.titleLabel) + self.addSubview(self.topSeparatorView) + self.addSubview(self.bottomSeparatorView) + } + + func makeConstraints() { + self.titleLabel.translatesAutoresizingMaskIntoConstraints = false + self.titleLabel.snp.makeConstraints { make in + make.top.equalToSuperview().offset(self.appearance.insets.top) + make.leading.equalToSuperview().offset(self.appearance.insets.leading) + make.bottom.equalToSuperview().offset(-self.appearance.insets.bottom) + make.trailing.equalToSuperview().offset(-self.appearance.insets.trailing) + } + + self.topSeparatorView.translatesAutoresizingMaskIntoConstraints = false + self.topSeparatorView.snp.makeConstraints { make in + make.top.leading.trailing.equalToSuperview() + } + + self.bottomSeparatorView.translatesAutoresizingMaskIntoConstraints = false + self.bottomSeparatorView.snp.makeConstraints { make in + make.leading.bottom.trailing.equalToSuperview() + } + } +} + +@available(iOS 17, *) +#Preview { + FillBlanksQuizTitleView() +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/FillBlanksQuizView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/FillBlanksQuizView.swift new file mode 100644 index 0000000000..b00abcf8eb --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StepQuizSubmodules/StepQuizFillBlanks/Views/UIKit/FillBlanksQuizView.swift @@ -0,0 +1,136 @@ +import SnapKit +import UIKit + +extension FillBlanksQuizView { + struct Appearance { + let horizontalInset = LayoutInsets.defaultInset + + let collectionViewMinHeight: CGFloat = 44 + let collectionViewMinLineSpacing: CGFloat = 4 + let collectionViewMinInteritemSpacing: CGFloat = 4 + let collectionViewSectionInset = LayoutInsets.default.uiEdgeInsets + + let backgroundColor = ColorPalette.background + } +} + +final class FillBlanksQuizView: UIView { + let appearance: Appearance + + private lazy var titleView = FillBlanksQuizTitleView( + appearance: .init(backgroundColor: self.appearance.backgroundColor) + ) + + private lazy var collectionView: UICollectionView = { + let collectionViewLayout = LeftAlignedCollectionViewFlowLayout() + collectionViewLayout.scrollDirection = .vertical + collectionViewLayout.minimumLineSpacing = self.appearance.collectionViewMinLineSpacing + collectionViewLayout.minimumInteritemSpacing = self.appearance.collectionViewMinInteritemSpacing + collectionViewLayout.sectionInset = self.appearance.collectionViewSectionInset + + let collectionView = UICollectionView( + frame: .zero, + collectionViewLayout: collectionViewLayout + ) + collectionView.backgroundColor = self.appearance.backgroundColor + collectionView.isScrollEnabled = false + collectionView.register(cellClass: FillBlanksInputCollectionViewCell.self) + collectionView.register(cellClass: FillBlanksTextCollectionViewCell.self) + + return collectionView + }() + + private lazy var bottomSeparatorView = UIKitSeparatorView() + + override var intrinsicContentSize: CGSize { + let titleViewHeight = self.titleView.intrinsicContentSize.height + let collectionViewHeight = max( + self.appearance.collectionViewMinHeight, + self.collectionView.collectionViewLayout.collectionViewContentSize.height + ) + + let height = titleViewHeight + collectionViewHeight + + return CGSize(width: UIView.noIntrinsicMetric, height: height) + } + + 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") + } + + func updateCollectionViewData(delegate: UICollectionViewDelegate, dataSource: UICollectionViewDataSource) { + self.collectionView.delegate = delegate + self.collectionView.dataSource = dataSource + self.collectionView.reloadData() + + DispatchQueue.main.async { + self.invalidateIntrinsicContentSize() + } + } + + func invalidateCollectionViewLayout() { + DispatchQueue.main.async { + UIView.performWithoutAnimation { + self.collectionView.collectionViewLayout.invalidateLayout() + self.layoutIfNeeded() + self.invalidateIntrinsicContentSize() + } + } + } +} + +// MARK: - FillBlanksQuizView: ProgrammaticallyInitializableViewProtocol - + +extension FillBlanksQuizView: ProgrammaticallyInitializableViewProtocol { + func setupView() { + self.backgroundColor = self.appearance.backgroundColor + } + + func addSubviews() { + self.addSubview(self.titleView) + self.addSubview(self.collectionView) + self.addSubview(self.bottomSeparatorView) + } + + func makeConstraints() { + self.titleView.translatesAutoresizingMaskIntoConstraints = false + self.titleView.snp.makeConstraints { make in + make.top.equalToSuperview() + make.leading.equalToSuperview().offset(-self.appearance.horizontalInset) + make.trailing.equalToSuperview().offset(self.appearance.horizontalInset) + } + + self.collectionView.translatesAutoresizingMaskIntoConstraints = false + self.collectionView.snp.makeConstraints { make in + make.top.equalTo(self.titleView.snp.bottom) + make.leading.equalToSuperview().offset(-self.appearance.horizontalInset) + make.bottom.equalToSuperview() + make.trailing.equalToSuperview().offset(self.appearance.horizontalInset) + } + + self.bottomSeparatorView.translatesAutoresizingMaskIntoConstraints = false + self.bottomSeparatorView.snp.makeConstraints { make in + make.bottom.equalToSuperview() + make.leading.equalToSuperview().offset(-self.appearance.horizontalInset) + make.trailing.equalToSuperview().offset(self.appearance.horizontalInset) + } + } +} + +@available(iOS 17, *) +#Preview { + FillBlanksQuizView() +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Systems/KeyboardManager.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Systems/KeyboardManager.swift index 7e909788db..41e49ab3c8 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Systems/KeyboardManager.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Systems/KeyboardManager.swift @@ -23,6 +23,10 @@ enum KeyboardManager { IQKeyboardManager.shared.enable = isEnabled } + static func setEnableAutoToolbar(_ enableAutoToolbar: Bool) { + IQKeyboardManager.shared.enableAutoToolbar = enableAutoToolbar + } + static func reloadLayoutIfNeeded() { IQKeyboardManager.shared.reloadLayoutIfNeeded() } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Views/UIKit/CollectionViewLayouts/LeftAlignedCollectionViewFlowLayout.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Views/UIKit/CollectionViewLayouts/LeftAlignedCollectionViewFlowLayout.swift new file mode 100644 index 0000000000..250e415342 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Views/UIKit/CollectionViewLayouts/LeftAlignedCollectionViewFlowLayout.swift @@ -0,0 +1,30 @@ +import UIKit + +class LeftAlignedCollectionViewFlowLayout: UICollectionViewFlowLayout { + override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? { + let attributesCopy = NSArray(array: super.layoutAttributesForElements(in: rect) ?? [], copyItems: true) + let attributes = attributesCopy as? [UICollectionViewLayoutAttributes] + + var leftMargin = self.sectionInset.left + var maxY: CGFloat = -1.0 + + attributes?.forEach { layoutAttribute in + if layoutAttribute.representedElementKind == UICollectionView.elementKindSectionHeader { + leftMargin = self.sectionInset.left + layoutAttribute.frame.size.width -= self.sectionInset.left + self.sectionInset.right + } + + // Detect a new line + if layoutAttribute.frame.origin.y >= maxY { + leftMargin = self.sectionInset.left + } + + layoutAttribute.frame.origin.x = leftMargin + + leftMargin += layoutAttribute.frame.width + self.minimumInteritemSpacing + maxY = max(layoutAttribute.frame.maxY, maxY) + } + + return attributes + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Views/UIKit/UIKitSeparatorView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Views/UIKit/UIKitSeparatorView.swift new file mode 100644 index 0000000000..3d40629279 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Views/UIKit/UIKitSeparatorView.swift @@ -0,0 +1,40 @@ +import SnapKit +import UIKit + +extension UIKitSeparatorView { + struct Appearance { + /// UITableView's default separator height. + let height: CGFloat = 1.0 + /// UITableView's default separator color. + var color = UIColor.separator + } +} + +/// View to make separator consistent appearance. +final class UIKitSeparatorView: UIView { + let appearance: Appearance + + override var intrinsicContentSize: CGSize { + CGSize(width: UIView.noIntrinsicMetric, height: self.appearance.height / UIScreen.main.scale) + } + + init(frame: CGRect = .zero, appearance: Appearance = Appearance()) { + self.appearance = appearance + super.init(frame: frame) + self.setupView() + } + + override func layoutSubviews() { + super.layoutSubviews() + self.invalidateIntrinsicContentSize() + } + + @available(*, unavailable) + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupView() { + self.backgroundColor = self.appearance.color + } +}