diff --git a/iosHyperskillApp/iosHyperskillApp.xcodeproj/project.pbxproj b/iosHyperskillApp/iosHyperskillApp.xcodeproj/project.pbxproj index 89f78b8cad..aaeae0fe59 100644 --- a/iosHyperskillApp/iosHyperskillApp.xcodeproj/project.pbxproj +++ b/iosHyperskillApp/iosHyperskillApp.xcodeproj/project.pbxproj @@ -187,6 +187,9 @@ 2C55E1902A056AFC00FE58D7 /* ProblemsLimitSkeletonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C55E18F2A056AFC00FE58D7 /* ProblemsLimitSkeletonView.swift */; }; 2C55E1922A05706300FE58D7 /* HomeSubheadlineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C55E1912A05706300FE58D7 /* HomeSubheadlineView.swift */; }; 2C56611A2AEA418D00D607FB /* FillBlanksSelectCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C5661192AEA418D00D607FB /* FillBlanksSelectCollectionViewCell.swift */; }; + 2C5837A12B28413C0096B89B /* SearchPlaceholderEmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C5837A02B28413C0096B89B /* SearchPlaceholderEmptyView.swift */; }; + 2C5837A32B2844E20096B89B /* SearchPlaceholderSuggestionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C5837A22B2844E20096B89B /* SearchPlaceholderSuggestionsView.swift */; }; + 2C5837A62B284E570096B89B /* NavigationLink+Empty.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C5837A52B284E570096B89B /* NavigationLink+Empty.swift */; }; 2C58DE232803BF97002A2774 /* AuthLogoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C58DE222803BF97002A2774 /* AuthLogoView.swift */; }; 2C58DE252803C185002A2774 /* AuthSocialControlsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C58DE242803C185002A2774 /* AuthSocialControlsView.swift */; }; 2C58DE292803D197002A2774 /* UIColor+DynamicColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C58DE282803D197002A2774 /* UIColor+DynamicColor.swift */; }; @@ -473,6 +476,7 @@ 2CF41A8E28505D2C000736D6 /* LatexView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CF41A8D28505D2C000736D6 /* LatexView.swift */; }; 2CF4341228126C79002893CD /* View+EndEditing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CF4341128126C79002893CD /* View+EndEditing.swift */; }; 2CF43414281281DB002893CD /* AuthTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CF43413281281DB002893CD /* AuthTextField.swift */; }; + 2CF5DF912B2853DA006E4ED7 /* SearchPlaceholderLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CF5DF902B2853DA006E4ED7 /* SearchPlaceholderLoadingView.swift */; }; 2CF72AA32847757300E1C192 /* StepQuizTableViewData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CF72AA22847757300E1C192 /* StepQuizTableViewData.swift */; }; 2CF72AA5284775BF00E1C192 /* StepQuizTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CF72AA4284775BF00E1C192 /* StepQuizTableView.swift */; }; 2CF72AA828477E0600E1C192 /* StepQuizTableRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CF72AA728477E0600E1C192 /* StepQuizTableRowView.swift */; }; @@ -491,6 +495,8 @@ 40D8E6EFE44EB7A6092C171B /* Pods_iosHyperskillApp.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9C0F8A86D62CB915A1E49CAA /* Pods_iosHyperskillApp.framework */; }; 59B66CD4D1508049555D35AE /* ProgressScreenView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCC18157582494D2909B214C /* ProgressScreenView.swift */; }; 60B4F143CF507F83C9581020 /* LeaderboardViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E205DEF27554501F7BE01AA /* LeaderboardViewModel.swift */; }; + 7A628C36D862C98ED2046D4F /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 907B10B0F7D4970530A478A2 /* SearchView.swift */; }; + 8E154CD6AF7D45A2CA013F85 /* SearchAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F55BD539626D22DCF0E1344 /* SearchAssembly.swift */; }; 9195A8624F8058A7D5F936F8 /* NotificationsOnboardingViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3570563AEEEEF2F5495BCA6 /* NotificationsOnboardingViewModel.swift */; }; AE0B2D1D267B8904498FA371 /* ProjectSelectionDetailsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEDCC11294912B8656C8B264 /* ProjectSelectionDetailsViewModel.swift */; }; B2B30D0486FC13DCC80F4263 /* NotificationsOnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3944E4546DEF47A28B2E7292 /* NotificationsOnboardingView.swift */; }; @@ -621,6 +627,7 @@ E9FB89AC2893EA580011EFFB /* NotificationPermissionStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9FB89AB2893EA580011EFFB /* NotificationPermissionStatus.swift */; }; E9FB89B02893EA900011EFFB /* UserNotificationsCenterDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9FB89AF2893EA900011EFFB /* UserNotificationsCenterDelegate.swift */; }; ECD10958C8BA7D758D3D1F66 /* ProjectSelectionDetailsAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15AD7CF422D27CCAE2839046 /* ProjectSelectionDetailsAssembly.swift */; }; + ED49113F88FF32AAFE6AFFBC /* SearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25AEA48C62195F44E21D6491 /* SearchViewModel.swift */; }; F759010A5FC990E99AAF0D76 /* ProgressScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20A5BDA1E184CA8FBAAD8584 /* ProgressScreenViewModel.swift */; }; /* End PBXBuildFile section */ @@ -668,6 +675,7 @@ 1496D2AE71B028929CE863C6 /* TrackSelectionDetailsAssembly.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrackSelectionDetailsAssembly.swift; sourceTree = ""; }; 15AD7CF422D27CCAE2839046 /* ProjectSelectionDetailsAssembly.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ProjectSelectionDetailsAssembly.swift; sourceTree = ""; }; 20A5BDA1E184CA8FBAAD8584 /* ProgressScreenViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ProgressScreenViewModel.swift; sourceTree = ""; }; + 25AEA48C62195F44E21D6491 /* SearchViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SearchViewModel.swift; sourceTree = ""; }; 2C005DCB27EF5B0300DC6503 /* GoogleServiceInfo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GoogleServiceInfo.swift; sourceTree = ""; }; 2C0146A928FDF2350083DA9C /* StepQuizCodeFullScreenInputProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizCodeFullScreenInputProtocol.swift; sourceTree = ""; }; 2C023C85285D927A00D2D5A9 /* StepQuizTableAssembly.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizTableAssembly.swift; sourceTree = ""; }; @@ -850,6 +858,9 @@ 2C55E18F2A056AFC00FE58D7 /* ProblemsLimitSkeletonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProblemsLimitSkeletonView.swift; sourceTree = ""; }; 2C55E1912A05706300FE58D7 /* HomeSubheadlineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeSubheadlineView.swift; sourceTree = ""; }; 2C5661192AEA418D00D607FB /* FillBlanksSelectCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FillBlanksSelectCollectionViewCell.swift; sourceTree = ""; }; + 2C5837A02B28413C0096B89B /* SearchPlaceholderEmptyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchPlaceholderEmptyView.swift; sourceTree = ""; }; + 2C5837A22B2844E20096B89B /* SearchPlaceholderSuggestionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchPlaceholderSuggestionsView.swift; sourceTree = ""; }; + 2C5837A52B284E570096B89B /* NavigationLink+Empty.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NavigationLink+Empty.swift"; sourceTree = ""; }; 2C58DE222803BF97002A2774 /* AuthLogoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthLogoView.swift; sourceTree = ""; }; 2C58DE242803C185002A2774 /* AuthSocialControlsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthSocialControlsView.swift; sourceTree = ""; }; 2C58DE282803D197002A2774 /* UIColor+DynamicColor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+DynamicColor.swift"; sourceTree = ""; }; @@ -1138,6 +1149,7 @@ 2CF41A8D28505D2C000736D6 /* LatexView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LatexView.swift; sourceTree = ""; }; 2CF4341128126C79002893CD /* View+EndEditing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+EndEditing.swift"; sourceTree = ""; }; 2CF43413281281DB002893CD /* AuthTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthTextField.swift; sourceTree = ""; }; + 2CF5DF902B2853DA006E4ED7 /* SearchPlaceholderLoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchPlaceholderLoadingView.swift; sourceTree = ""; }; 2CF72AA22847757300E1C192 /* StepQuizTableViewData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizTableViewData.swift; sourceTree = ""; }; 2CF72AA4284775BF00E1C192 /* StepQuizTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizTableView.swift; sourceTree = ""; }; 2CF72AA728477E0600E1C192 /* StepQuizTableRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepQuizTableRowView.swift; sourceTree = ""; }; @@ -1162,6 +1174,8 @@ 71D01125D308034C53D75DA6 /* ProjectSelectionDetailsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ProjectSelectionDetailsView.swift; sourceTree = ""; }; 7555FF7B242A565900829871 /* iosHyperskillApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = iosHyperskillApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; 7555FF8C242A565B00829871 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 7F55BD539626D22DCF0E1344 /* SearchAssembly.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SearchAssembly.swift; sourceTree = ""; }; + 907B10B0F7D4970530A478A2 /* SearchView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = ""; }; 9AACF19B25D42FD4AE322D5A /* ProgressScreenAssembly.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ProgressScreenAssembly.swift; sourceTree = ""; }; 9C0F8A86D62CB915A1E49CAA /* Pods_iosHyperskillApp.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_iosHyperskillApp.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C2065D585FD89A96C31C08BC /* TrackSelectionDetailsView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrackSelectionDetailsView.swift; sourceTree = ""; }; @@ -1629,6 +1643,7 @@ 2C963BC82812D3410036DD53 /* ProfileSettings */, 69443CBBFA46C4A121EA173F /* ProgressScreen */, 2C5CA2452A203C4500DBF2F9 /* ProjectSelection */, + 3C00014807122833363E303F /* Search */, 2C9E5E8229B211DD003AEC16 /* StageImplement */, 2CAE8CEE280525A100E6C83D /* Step */, 2C41127428376DE3004948A3 /* StepQuiz */, @@ -2185,6 +2200,25 @@ path = Input; sourceTree = ""; }; + 2C58379F2B28412C0096B89B /* Views */ = { + isa = PBXGroup; + children = ( + 2C5837A02B28413C0096B89B /* SearchPlaceholderEmptyView.swift */, + 2CF5DF902B2853DA006E4ED7 /* SearchPlaceholderLoadingView.swift */, + 2C5837A22B2844E20096B89B /* SearchPlaceholderSuggestionsView.swift */, + 907B10B0F7D4970530A478A2 /* SearchView.swift */, + ); + path = Views; + sourceTree = ""; + }; + 2C5837A42B284E4C0096B89B /* NavigationLink */ = { + isa = PBXGroup; + children = ( + 2C5837A52B284E570096B89B /* NavigationLink+Empty.swift */, + ); + path = NavigationLink; + sourceTree = ""; + }; 2C58DE212803BE84002A2774 /* Views */ = { isa = PBXGroup; children = ( @@ -2341,6 +2375,7 @@ 2C725B5C28090CE500A49043 /* SwiftUI */ = { isa = PBXGroup; children = ( + 2C5837A42B284E4C0096B89B /* NavigationLink */, 2C323750283808300062CAF6 /* View */, ); path = SwiftUI; @@ -3511,6 +3546,16 @@ path = Injection; sourceTree = ""; }; + 3C00014807122833363E303F /* Search */ = { + isa = PBXGroup; + children = ( + 7F55BD539626D22DCF0E1344 /* SearchAssembly.swift */, + 25AEA48C62195F44E21D6491 /* SearchViewModel.swift */, + 2C58379F2B28412C0096B89B /* Views */, + ); + path = Search; + sourceTree = ""; + }; 46D884ECAA31C3DB83AF8E56 /* Details */ = { isa = PBXGroup; children = ( @@ -4307,6 +4352,7 @@ 2CEB50CE288AACEA0044F9AB /* StepQuizCodeFullScreenTab.swift in Sources */, 2CEB50C8288A94050044F9AB /* BlockExtensions.swift in Sources */, 2C54E4282A1F717F003406B9 /* CardView.swift in Sources */, + 2C5837A62B284E570096B89B /* NavigationLink+Empty.swift in Sources */, E9D2D675284E0B30000757AC /* StepQuizMatchingView.swift in Sources */, 2CBC97CD2A555AA20078E445 /* StageImplementProjectCompletedModalView.swift in Sources */, 2CC95C0E2A4EBB970036C73E /* ProjectLevelAvatarView.swift in Sources */, @@ -4666,6 +4712,7 @@ 2CA368E728EEAE09004F7FD8 /* AppView.swift in Sources */, 2C5215AA291E5B63006C2427 /* PullToRefresh.swift in Sources */, 2CAE8CF4280525D400E6C83D /* StepAssembly.swift in Sources */, + 2C5837A32B2844E20096B89B /* SearchPlaceholderSuggestionsView.swift in Sources */, 2CD316C028A3B2040002B2B2 /* ApplicationTheme+SharedTheme.swift in Sources */, 2C1061A4285C34C900EBD614 /* StepQuizChildQuizOutputProtocol.swift in Sources */, 2C20FBA4284F165A006D879E /* ProcessedContent.swift in Sources */, @@ -4789,9 +4836,11 @@ E9D537D42A71393A00F21828 /* ProfileBadgesGridView.swift in Sources */, C727878256DA0342EF174A4E /* TrackSelectionDetailsView.swift in Sources */, D9B929495D696A140BA3D150 /* TrackSelectionDetailsViewModel.swift in Sources */, + 2CF5DF912B2853DA006E4ED7 /* SearchPlaceholderLoadingView.swift in Sources */, ECD10958C8BA7D758D3D1F66 /* ProjectSelectionDetailsAssembly.swift in Sources */, 018CAC44EED7A992000ECF87 /* ProjectSelectionDetailsView.swift in Sources */, 2CB0ADEC2B04AD550089D557 /* ChallengeWidgetView.swift in Sources */, + 2C5837A12B28413C0096B89B /* SearchPlaceholderEmptyView.swift in Sources */, AE0B2D1D267B8904498FA371 /* ProjectSelectionDetailsViewModel.swift in Sources */, 2C8DD4092AFB7DFD00FD5359 /* ShareStreakModalViewController.swift in Sources */, 0809817CFCC9D4C45457B3C8 /* ProgressScreenAssembly.swift in Sources */, @@ -4805,6 +4854,9 @@ 043790C380B462AFEB2B13BC /* LeaderboardAssembly.swift in Sources */, BAEC674E5161E8C7A10ADAAB /* LeaderboardView.swift in Sources */, 60B4F143CF507F83C9581020 /* LeaderboardViewModel.swift in Sources */, + 8E154CD6AF7D45A2CA013F85 /* SearchAssembly.swift in Sources */, + 7A628C36D862C98ED2046D4F /* SearchView.swift in Sources */, + ED49113F88FF32AAFE6AFFBC /* SearchViewModel.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Extensions/SwiftUI/NavigationLink/NavigationLink+Empty.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Extensions/SwiftUI/NavigationLink/NavigationLink+Empty.swift new file mode 100644 index 0000000000..095f4ac4ac --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Extensions/SwiftUI/NavigationLink/NavigationLink+Empty.swift @@ -0,0 +1,8 @@ +import SwiftUI + +extension NavigationLink where Label == EmptyView, Destination == EmptyView { + /// Useful in cases where a `NavigationLink` is needed but there should not be a destination. e.g. for programmatic navigation. + static var empty: NavigationLink { + self.init(destination: EmptyView(), label: { EmptyView() }) + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Models/Constants/Strings.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Models/Constants/Strings.swift index baef2d90ff..978726b700 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Models/Constants/Strings.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Models/Constants/Strings.swift @@ -572,4 +572,18 @@ enum Strings { static let changeProject = sharedStrings.progress_screen_change_project.localized() } } + + // MARK: - Search - + + enum Search { + static let title = sharedStrings.search_title.localized() + + static let placeholderEmptyTitle = sharedStrings.search_placeholder_empty_title.localized() + static let placeholderEmptySubtitle = sharedStrings.search_placeholder_empty_subtitle.localized() + + static let placeholderSuggestionsTitle = sharedStrings.search_placeholder_suggestions_title.localized() + static let placeholderSuggestionsSubtitle = sharedStrings.search_placeholder_suggestions_subtitle.localized() + + static let placeholderErrorDescription = sharedStrings.search_placeholder_error_description.localized() + } } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/GamificationToolbar/GamificationToolbarViewActionHandler.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/GamificationToolbar/GamificationToolbarViewActionHandler.swift index 0bb56cc16e..30ba234a29 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/GamificationToolbar/GamificationToolbarViewActionHandler.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/GamificationToolbar/GamificationToolbarViewActionHandler.swift @@ -13,7 +13,10 @@ enum GamificationToolbarViewActionHandler { let assembly = ProgressScreenAssembly() stackRouter.pushViewController(assembly.makeModule()) case .showSearchScreen: - #warning("TODO: ALTAPPS-1058 show search screen") + if #available(iOS 15.0, *) { + let assembly = SearchAssembly() + stackRouter.pushViewController(assembly.makeModule()) + } } } } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/GamificationToolbar/Views/GamificationToolbarContent.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/GamificationToolbar/Views/GamificationToolbarContent.swift index 56d99edce0..257a14c8e7 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/GamificationToolbar/Views/GamificationToolbarContent.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/GamificationToolbar/Views/GamificationToolbarContent.swift @@ -4,6 +4,10 @@ import SwiftUI extension GamificationToolbarContent { struct Appearance { let skeletonSize = CGSize(width: 56, height: 28) + + let searchImageWidthHeight: CGFloat = 16 + let searchImagePadding: CGFloat = 6 + let searchImageBackgroundColor = Color(ColorPalette.surface) } } @@ -14,6 +18,7 @@ struct GamificationToolbarContent: ToolbarContent { let onStreakTap: () -> Void let onProgressTap: () -> Void + let onSearchTap: () -> Void var body: some ToolbarContent { ToolbarItem(placement: .primaryAction) { @@ -22,6 +27,9 @@ struct GamificationToolbarContent: ToolbarContent { HStack { SkeletonRoundedView(appearance: .init(size: appearance.skeletonSize)) SkeletonRoundedView(appearance: .init(size: appearance.skeletonSize)) + if #available(iOS 15.0, *) { + SkeletonRoundedView(appearance: .init(size: appearance.skeletonSize)) + } } case .error: HStack {} @@ -41,6 +49,21 @@ struct GamificationToolbarContent: ToolbarContent { isCompletedToday: data.streak.isCompleted, onTap: onStreakTap ) + + if #available(iOS 15.0, *) { + Button( + action: onSearchTap, + label: { + Image(systemName: "magnifyingglass") + .resizable() + .renderingMode(.template) + .aspectRatio(contentMode: .fit) + .frame(widthHeight: appearance.searchImageWidthHeight) + .padding(appearance.searchImagePadding) + .background(Circle().foregroundColor(appearance.searchImageBackgroundColor)) + } + ) + } } } } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Home/HomeViewModel.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Home/HomeViewModel.swift index db8eeb0101..f04ffac5e6 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Home/HomeViewModel.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Home/HomeViewModel.swift @@ -67,6 +67,14 @@ final class HomeViewModel: FeatureViewModel UIViewController { + let searchComponent = AppGraphBridge.sharedAppGraph.buildSearchComponent() + + let searchViewModel = SearchViewModel( + feature: searchComponent.searchFeature + ) + + let stackRouter = StackRouter() + + let searchView = SearchView( + viewModel: searchViewModel, + stackRouter: stackRouter + ) + + let hostingController = StyledHostingController( + rootView: searchView + ) + hostingController.hidesBottomBarWhenPushed = true + hostingController.navigationItem.largeTitleDisplayMode = .always + hostingController.title = Strings.Search.title + + stackRouter.rootViewController = hostingController + + return hostingController + } +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Search/SearchViewModel.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Search/SearchViewModel.swift new file mode 100644 index 0000000000..c1f376b634 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Search/SearchViewModel.swift @@ -0,0 +1,52 @@ +import Foundation +import shared + +final class SearchViewModel: FeatureViewModel< + SearchFeature.ViewState, + SearchFeatureMessage, + SearchFeatureActionViewAction +> { + var searchResultsViewStateKs: SearchFeatureSearchResultsViewStateKs { .init(state.searchResultsViewState) } + + private var isFirstTimeBecomeFirstResponder = true + + override func shouldNotifyStateDidChange( + oldState: SearchFeature.ViewState, + newState: SearchFeature.ViewState + ) -> Bool { + !oldState.isEqual(newState) + } + + func shouldBecomeFirstResponder() -> Bool { + if isFirstTimeBecomeFirstResponder { + isFirstTimeBecomeFirstResponder = false + return true + } else { + return false + } + } + + func doQueryChanged(query: String) { + onNewMessage(SearchFeatureMessageQueryChanged(query: query)) + } + + func doSearch() { + onNewMessage(SearchFeatureMessageSearchClicked()) + } + + func doRetrySearch() { + onNewMessage(SearchFeatureMessageRetrySearchClicked()) + } + + func doSearchResultsItemPresentation(id: Int64) { + onNewMessage(SearchFeatureMessageSearchResultsItemClicked(id: id)) + } + + // MARK: Analytic + + func logViewedEvent() { + onNewMessage(SearchFeatureMessageViewedEventMessage()) + } +} + +extension SearchFeatureSearchResultsViewStateContent.Item: Identifiable {} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Search/Views/SearchPlaceholderEmptyView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Search/Views/SearchPlaceholderEmptyView.swift new file mode 100644 index 0000000000..a5f0bb6cd4 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Search/Views/SearchPlaceholderEmptyView.swift @@ -0,0 +1,22 @@ +import SwiftUI + +struct SearchPlaceholderEmptyView: View { + var body: some View { + VStack { + Text(Strings.Search.placeholderEmptyTitle) + .font(.title2.bold()) + .foregroundColor(.primaryText) + + Text(Strings.Search.placeholderEmptySubtitle) + .font(.body) + .foregroundColor(.secondaryText) + } + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + } +} + +#Preview { + SearchPlaceholderEmptyView() + .padding() +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Search/Views/SearchPlaceholderLoadingView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Search/Views/SearchPlaceholderLoadingView.swift new file mode 100644 index 0000000000..4c4d28af13 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Search/Views/SearchPlaceholderLoadingView.swift @@ -0,0 +1,19 @@ +import SwiftUI + +struct SearchPlaceholderLoadingView: View { + var body: some View { + ScrollView([], showsIndicators: false) { + VStack(alignment: .leading) { + ForEach(0..<10) { _ in + SkeletonRoundedView() + .frame(height: 60) + } + } + .padding([.horizontal, .bottom]) + } + } +} + +#Preview { + SearchPlaceholderLoadingView() +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Search/Views/SearchPlaceholderSuggestionsView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Search/Views/SearchPlaceholderSuggestionsView.swift new file mode 100644 index 0000000000..0e2c09aa67 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Search/Views/SearchPlaceholderSuggestionsView.swift @@ -0,0 +1,22 @@ +import SwiftUI + +struct SearchPlaceholderSuggestionsView: View { + var body: some View { + VStack { + Text(Strings.Search.placeholderSuggestionsTitle) + .font(.title2.bold()) + .foregroundColor(.primaryText) + + Text(Strings.Search.placeholderSuggestionsSubtitle) + .font(.body) + .foregroundColor(.secondaryText) + } + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + } +} + +#Preview { + SearchPlaceholderSuggestionsView() + .padding() +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Search/Views/SearchView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Search/Views/SearchView.swift new file mode 100644 index 0000000000..07228162b3 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Search/Views/SearchView.swift @@ -0,0 +1,107 @@ +import shared +import SwiftUI + +@available(iOS 15.0, *) +struct SearchView: View { + @StateObject var viewModel: SearchViewModel + + var stackRouter: StackRouterProtocol + + var body: some View { + ZStack { + UIViewControllerEventsWrapper(onViewDidAppear: viewModel.logViewedEvent) + + buildBody() + } + .searchable( + text: Binding.init( + get: { viewModel.state.query }, + set: viewModel.doQueryChanged(query:) + ) + ) + .onSubmit(of: .search, viewModel.doSearch) + .introspectViewController { viewController in + guard viewModel.shouldBecomeFirstResponder() else { + return + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + viewController.navigationItem.searchController?.searchBar.becomeFirstResponder() + } + } + .onAppear { + viewModel.startListening() + viewModel.onViewAction = handleViewAction(_:) + } + .onDisappear { + viewModel.stopListening() + viewModel.onViewAction = nil + } + } + + // MARK: Private API + + @ViewBuilder + private func buildBody() -> some View { + switch viewModel.searchResultsViewStateKs { + case .idle, .loading: + if viewModel.state.query.isEmpty { + SearchPlaceholderSuggestionsView() + } else { + SearchPlaceholderLoadingView() + } + case .error: + PlaceholderView( + configuration: .networkError( + titleText: Strings.Search.placeholderErrorDescription, + buttonText: Strings.StepQuiz.retryButton, + backgroundColor: .clear, + action: viewModel.doRetrySearch + ) + ) + case .empty: + SearchPlaceholderEmptyView() + case .content(let data): + List(data.searchResults) { searchResult in + Button( + action: { + viewModel.doSearchResultsItemPresentation(id: searchResult.id.int64Value) + }, + label: { + HStack { + Text(searchResult.title) + Spacer() + NavigationLink.empty + } + } + ) + .accentColor(.primaryText) + } + .listStyle(.insetGrouped) + } + } +} + +// MARK: - SearchView (ViewAction) - + +@available(iOS 15.0, *) +private extension SearchView { + func handleViewAction( + _ viewAction: SearchFeatureActionViewAction + ) { + switch SearchFeatureActionViewActionKs(viewAction) { + case .openStepScreen(let openStepScreenViewAction): + let assembly = StepAssembly(stepRoute: openStepScreenViewAction.stepRoute) + stackRouter.pushViewController(assembly.makeModule()) + case .openStepScreenFailed(let openStepScreenFailedViewAction): + ProgressHUD.showError(status: openStepScreenFailedViewAction.message) + } + } +} + +// MARK: - SearchView (Preview) - + +@available(iOS 17, *) +#Preview { + SearchAssembly().makeModule() +} diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StudyPlan/StudyPlanViewModel.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StudyPlan/StudyPlanViewModel.swift index ec15aa46ef..9a505f67c4 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StudyPlan/StudyPlanViewModel.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StudyPlan/StudyPlanViewModel.swift @@ -80,6 +80,14 @@ final class StudyPlanViewModel: FeatureViewModel< ) } + func doSearchBarButtonItemAction() { + onNewMessage( + StudyPlanScreenFeatureMessageGamificationToolbarMessage( + message: GamificationToolbarFeatureMessageClickedSearch() + ) + ) + } + func doReloadProblemsLimit() { onNewMessage( StudyPlanScreenFeatureMessageProblemsLimitMessage( diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StudyPlan/Views/StudyPlanView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StudyPlan/Views/StudyPlanView.swift index 578ab83d57..1d0381d351 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StudyPlan/Views/StudyPlanView.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/StudyPlan/Views/StudyPlanView.swift @@ -36,7 +36,8 @@ struct StudyPlanView: View { GamificationToolbarContent( viewStateKs: viewModel.gamificationToolbarViewStateKs, onStreakTap: viewModel.doStreakBarButtonItemAction, - onProgressTap: viewModel.doProgressBarButtonItemAction + onProgressTap: viewModel.doProgressBarButtonItemAction, + onSearchTap: viewModel.doSearchBarButtonItemAction ) } .onAppear { diff --git a/shared/src/commonMain/resources/MR/base/strings.xml b/shared/src/commonMain/resources/MR/base/strings.xml index d33d6a9d14..f8611abe15 100644 --- a/shared/src/commonMain/resources/MR/base/strings.xml +++ b/shared/src/commonMain/resources/MR/base/strings.xml @@ -537,7 +537,11 @@ again later. Nothing found. Try changing your search query. + Nothing found + Try changing your search query Oops! We were unable to perform the search. + Find topic + Search all of Hyperskill for topic theory Project Mastery