From 32a5445d6f00ce5e28a49c66d1fe632d9058ffd7 Mon Sep 17 00:00:00 2001 From: Ivan Magda Date: Thu, 7 Dec 2023 16:27:04 +0700 Subject: [PATCH 01/24] Add SearchResultsDataComponent --- .../hyperskill/app/core/injection/AppGraph.kt | 2 + .../app/core/injection/BaseAppGraph.kt | 5 ++ .../repository/SearchResultsRepositoryImpl.kt | 13 ++++ .../source/SearchResultsRemoteDataSource.kt | 8 +++ .../domain/model/SearchResult.kt | 15 +++++ .../domain/model/SearchResultTargetType.kt | 12 ++++ .../repository/SearchResultsRepository.kt | 25 ++++++++ .../injection/SearchResultsDataComponent.kt | 7 +++ .../SearchResultsDataComponentImpl.kt | 15 +++++ .../SearchResultsRemoteDataSourceImpl.kt | 27 ++++++++ .../remote/model/SearchResultsRequest.kt | 20 ++++++ .../remote/model/SearchResultsResponse.kt | 16 +++++ ...earchResultsResponseDeserializationTest.kt | 61 +++++++++++++++++++ 13 files changed, 226 insertions(+) create mode 100644 shared/src/commonMain/kotlin/org/hyperskill/app/search_results/data/repository/SearchResultsRepositoryImpl.kt create mode 100644 shared/src/commonMain/kotlin/org/hyperskill/app/search_results/data/source/SearchResultsRemoteDataSource.kt create mode 100644 shared/src/commonMain/kotlin/org/hyperskill/app/search_results/domain/model/SearchResult.kt create mode 100644 shared/src/commonMain/kotlin/org/hyperskill/app/search_results/domain/model/SearchResultTargetType.kt create mode 100644 shared/src/commonMain/kotlin/org/hyperskill/app/search_results/domain/repository/SearchResultsRepository.kt create mode 100644 shared/src/commonMain/kotlin/org/hyperskill/app/search_results/injection/SearchResultsDataComponent.kt create mode 100644 shared/src/commonMain/kotlin/org/hyperskill/app/search_results/injection/SearchResultsDataComponentImpl.kt create mode 100644 shared/src/commonMain/kotlin/org/hyperskill/app/search_results/remote/SearchResultsRemoteDataSourceImpl.kt create mode 100644 shared/src/commonMain/kotlin/org/hyperskill/app/search_results/remote/model/SearchResultsRequest.kt create mode 100644 shared/src/commonMain/kotlin/org/hyperskill/app/search_results/remote/model/SearchResultsResponse.kt create mode 100644 shared/src/commonTest/kotlin/org/hyperskill/search_results/remote/model/SearchResultsResponseDeserializationTest.kt diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/AppGraph.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/AppGraph.kt index 698c3a1352..88b3fb5650 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/AppGraph.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/AppGraph.kt @@ -49,6 +49,7 @@ import org.hyperskill.app.project_selection.list.injection.ProjectSelectionListC import org.hyperskill.app.projects.injection.ProjectsDataComponent import org.hyperskill.app.providers.injection.ProvidersDataComponent import org.hyperskill.app.reactions.injection.ReactionsDataComponent +import org.hyperskill.app.search_results.injection.SearchResultsDataComponent import org.hyperskill.app.sentry.injection.SentryComponent import org.hyperskill.app.share_streak.injection.ShareStreakDataComponent import org.hyperskill.app.stage_implement.injection.StageImplementComponent @@ -161,4 +162,5 @@ interface AppGraph { fun buildLeaderboardDataComponent(): LeaderboardDataComponent fun buildLeaderboardScreenComponent(): LeaderboardScreenComponent fun buildLeaderboardWidgetComponent(): LeaderboardWidgetComponent + fun buildSearchResultsDataComponent(): SearchResultsDataComponent } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/BaseAppGraph.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/BaseAppGraph.kt index 30b9f62684..d31da6c3cd 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/BaseAppGraph.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/BaseAppGraph.kt @@ -92,6 +92,8 @@ import org.hyperskill.app.providers.injection.ProvidersDataComponent import org.hyperskill.app.providers.injection.ProvidersDataComponentImpl import org.hyperskill.app.reactions.injection.ReactionsDataComponent import org.hyperskill.app.reactions.injection.ReactionsDataComponentImpl +import org.hyperskill.app.search_results.injection.SearchResultsDataComponent +import org.hyperskill.app.search_results.injection.SearchResultsDataComponentImpl import org.hyperskill.app.share_streak.injection.ShareStreakDataComponent import org.hyperskill.app.share_streak.injection.ShareStreakDataComponentImpl import org.hyperskill.app.stage_implement.injection.StageImplementComponent @@ -436,4 +438,7 @@ abstract class BaseAppGraph : AppGraph { override fun buildLeaderboardWidgetComponent(): LeaderboardWidgetComponent = LeaderboardWidgetComponentImpl(this) + + override fun buildSearchResultsDataComponent(): SearchResultsDataComponent = + SearchResultsDataComponentImpl(this) } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search_results/data/repository/SearchResultsRepositoryImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search_results/data/repository/SearchResultsRepositoryImpl.kt new file mode 100644 index 0000000000..5a1e22c09a --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search_results/data/repository/SearchResultsRepositoryImpl.kt @@ -0,0 +1,13 @@ +package org.hyperskill.app.search_results.data.repository + +import org.hyperskill.app.search_results.data.source.SearchResultsRemoteDataSource +import org.hyperskill.app.search_results.domain.repository.SearchResultsRepository +import org.hyperskill.app.search_results.remote.model.SearchResultsRequest +import org.hyperskill.app.search_results.remote.model.SearchResultsResponse + +internal class SearchResultsRepositoryImpl( + private val searchResultsRemoteDataSource: SearchResultsRemoteDataSource +) : SearchResultsRepository { + override suspend fun getSearchResults(request: SearchResultsRequest): Result = + searchResultsRemoteDataSource.getSearchResults(request) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search_results/data/source/SearchResultsRemoteDataSource.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search_results/data/source/SearchResultsRemoteDataSource.kt new file mode 100644 index 0000000000..36dbbf59c8 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search_results/data/source/SearchResultsRemoteDataSource.kt @@ -0,0 +1,8 @@ +package org.hyperskill.app.search_results.data.source + +import org.hyperskill.app.search_results.remote.model.SearchResultsRequest +import org.hyperskill.app.search_results.remote.model.SearchResultsResponse + +interface SearchResultsRemoteDataSource { + suspend fun getSearchResults(request: SearchResultsRequest): Result +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search_results/domain/model/SearchResult.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search_results/domain/model/SearchResult.kt new file mode 100644 index 0000000000..cf1fb01338 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search_results/domain/model/SearchResult.kt @@ -0,0 +1,15 @@ +package org.hyperskill.app.search_results.domain.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SearchResult( + @SerialName("target_type") + internal val targetTypeValue: String, + @SerialName("target_id") + val targetId: Long +) { + val targetType: SearchResultTargetType? + get() = SearchResultTargetType.getByValue(targetTypeValue) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search_results/domain/model/SearchResultTargetType.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search_results/domain/model/SearchResultTargetType.kt new file mode 100644 index 0000000000..fc47f11d8c --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search_results/domain/model/SearchResultTargetType.kt @@ -0,0 +1,12 @@ +package org.hyperskill.app.search_results.domain.model + +enum class SearchResultTargetType(val value: String) { + TOPIC("topic"); + + companion object { + private val VALUES: Array = values() + + fun getByValue(value: String): SearchResultTargetType? = + VALUES.firstOrNull { it.value == value } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search_results/domain/repository/SearchResultsRepository.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search_results/domain/repository/SearchResultsRepository.kt new file mode 100644 index 0000000000..31e9f31f3b --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search_results/domain/repository/SearchResultsRepository.kt @@ -0,0 +1,25 @@ +package org.hyperskill.app.search_results.domain.repository + +import org.hyperskill.app.search_results.domain.model.SearchResult +import org.hyperskill.app.search_results.domain.model.SearchResultTargetType +import org.hyperskill.app.search_results.remote.model.SearchResultsRequest +import org.hyperskill.app.search_results.remote.model.SearchResultsResponse + +interface SearchResultsRepository { + suspend fun getSearchResults(request: SearchResultsRequest): Result +} + +suspend fun SearchResultsRepository.getTopicSearchResults( + query: String, + pageSize: Int = 20, + page: Int = 1 +): Result> = + getSearchResults( + SearchResultsRequest( + query = query, + pageSize = pageSize, + page = page + ) + ).map { response: SearchResultsResponse -> + response.searchResults.filter { it.targetType == SearchResultTargetType.TOPIC } + } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search_results/injection/SearchResultsDataComponent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search_results/injection/SearchResultsDataComponent.kt new file mode 100644 index 0000000000..cac70b2089 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search_results/injection/SearchResultsDataComponent.kt @@ -0,0 +1,7 @@ +package org.hyperskill.app.search_results.injection + +import org.hyperskill.app.search_results.domain.repository.SearchResultsRepository + +interface SearchResultsDataComponent { + val searchResultsRepository: SearchResultsRepository +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search_results/injection/SearchResultsDataComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search_results/injection/SearchResultsDataComponentImpl.kt new file mode 100644 index 0000000000..266aa3425b --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search_results/injection/SearchResultsDataComponentImpl.kt @@ -0,0 +1,15 @@ +package org.hyperskill.app.search_results.injection + +import org.hyperskill.app.core.injection.AppGraph +import org.hyperskill.app.search_results.data.repository.SearchResultsRepositoryImpl +import org.hyperskill.app.search_results.data.source.SearchResultsRemoteDataSource +import org.hyperskill.app.search_results.domain.repository.SearchResultsRepository +import org.hyperskill.app.search_results.remote.SearchResultsRemoteDataSourceImpl + +internal class SearchResultsDataComponentImpl(appGraph: AppGraph) : SearchResultsDataComponent { + private val searchResultsRemoteDataSource: SearchResultsRemoteDataSource = + SearchResultsRemoteDataSourceImpl(appGraph.networkComponent.authorizedHttpClient) + + override val searchResultsRepository: SearchResultsRepository + get() = SearchResultsRepositoryImpl(searchResultsRemoteDataSource) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search_results/remote/SearchResultsRemoteDataSourceImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search_results/remote/SearchResultsRemoteDataSourceImpl.kt new file mode 100644 index 0000000000..541e677d22 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search_results/remote/SearchResultsRemoteDataSourceImpl.kt @@ -0,0 +1,27 @@ +package org.hyperskill.app.search_results.remote + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.client.request.parameter +import io.ktor.http.ContentType +import io.ktor.http.contentType +import org.hyperskill.app.search_results.data.source.SearchResultsRemoteDataSource +import org.hyperskill.app.search_results.remote.model.SearchResultsRequest +import org.hyperskill.app.search_results.remote.model.SearchResultsResponse + +internal class SearchResultsRemoteDataSourceImpl( + private val httpClient: HttpClient +) : SearchResultsRemoteDataSource { + override suspend fun getSearchResults(request: SearchResultsRequest): Result = + kotlin.runCatching { + httpClient + .get("/api/search-results") { + contentType(ContentType.Application.Json) + request.parameters.forEach { (key, value) -> + parameter(key, value) + } + } + .body() + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search_results/remote/model/SearchResultsRequest.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search_results/remote/model/SearchResultsRequest.kt new file mode 100644 index 0000000000..a428d50ac5 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search_results/remote/model/SearchResultsRequest.kt @@ -0,0 +1,20 @@ +package org.hyperskill.app.search_results.remote.model + +class SearchResultsRequest( + query: String, + pageSize: Int, + page: Int +) { + companion object { + private const val PARAM_QUERY = "query" + private const val PARAM_PAGE_SIZE = "page_size" + private const val PARAM_PAGE = "page" + } + + val parameters: Map = + mapOf( + PARAM_QUERY to query, + PARAM_PAGE_SIZE to pageSize, + PARAM_PAGE to page + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search_results/remote/model/SearchResultsResponse.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search_results/remote/model/SearchResultsResponse.kt new file mode 100644 index 0000000000..692c2a0937 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search_results/remote/model/SearchResultsResponse.kt @@ -0,0 +1,16 @@ +package org.hyperskill.app.search_results.remote.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.hyperskill.app.core.remote.Meta +import org.hyperskill.app.core.remote.MetaResponse +import org.hyperskill.app.search_results.domain.model.SearchResult + +@Serializable +class SearchResultsResponse( + @SerialName("meta") + override val meta: Meta, + + @SerialName("search-results") + val searchResults: List +) : MetaResponse \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/org/hyperskill/search_results/remote/model/SearchResultsResponseDeserializationTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/search_results/remote/model/SearchResultsResponseDeserializationTest.kt new file mode 100644 index 0000000000..d579fb1a02 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/hyperskill/search_results/remote/model/SearchResultsResponseDeserializationTest.kt @@ -0,0 +1,61 @@ +package org.hyperskill.search_results.remote.model + +import kotlin.test.Test +import kotlin.test.assertEquals +import org.hyperskill.app.core.remote.Meta +import org.hyperskill.app.network.injection.NetworkModule +import org.hyperskill.app.search_results.domain.model.SearchResult +import org.hyperskill.app.search_results.remote.model.SearchResultsResponse + +class SearchResultsResponseDeserializationTest { + companion object { + private val TEST_JSON_STRING = """ +{ + "meta": { + "page": 1, + "has_next": true, + "has_previous": false + }, + "search-results": [ + { + "target_type": "topic", + "target_id": 22, + "position": 1, + "score": 79.00877 + }, + { + "target_type": "topic", + "target_id": 488, + "position": 2, + "score": 77.70255 + } + ] +} + """.trimIndent() + } + + @Test + fun `Test SearchResultsResponse deserialization`() { + val json = NetworkModule.provideJson() + val expected = SearchResultsResponse( + meta = Meta( + page = 1, + hasNext = true, + hasPrevious = false + ), + searchResults = listOf( + SearchResult( + targetTypeValue = "topic", + targetId = 22 + ), + SearchResult( + targetTypeValue = "topic", + targetId = 488 + ) + ) + ) + val decodedObject = json.decodeFromString(SearchResultsResponse.serializer(), TEST_JSON_STRING) + assertEquals(expected.meta, decodedObject.meta) + assertEquals(expected.searchResults, decodedObject.searchResults) + } +} \ No newline at end of file From ef3afad8285e390ae4c3efdf6c77c120c24c9fd2 Mon Sep 17 00:00:00 2001 From: Ivan Magda Date: Thu, 7 Dec 2023 16:35:00 +0700 Subject: [PATCH 02/24] Delete TopicsInteractor --- .../injection/StepCompletionComponentImpl.kt | 2 +- .../presentation/StepCompletionActionDispatcher.kt | 6 +++--- .../topics/data/repository/TopicsRepositoryImpl.kt | 2 +- .../topics/domain/interactor/TopicsInteractor.kt | 14 -------------- .../app/topics/injection/TopicsDataComponent.kt | 2 -- .../topics/injection/TopicsDataComponentImpl.kt | 6 +----- .../topics/remote/TopicsRemoteDataSourceImpl.kt | 2 +- 7 files changed, 7 insertions(+), 27 deletions(-) delete mode 100644 shared/src/commonMain/kotlin/org/hyperskill/app/topics/domain/interactor/TopicsInteractor.kt diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/injection/StepCompletionComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/injection/StepCompletionComponentImpl.kt index effa484b9e..74b08c63a7 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/injection/StepCompletionComponentImpl.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/injection/StepCompletionComponentImpl.kt @@ -19,7 +19,7 @@ internal class StepCompletionComponentImpl( appGraph.submissionDataComponent.submissionRepository, appGraph.buildStepDataComponent().stepInteractor, appGraph.buildProgressesDataComponent().progressesInteractor, - appGraph.buildTopicsDataComponent().topicsInteractor, + appGraph.buildTopicsDataComponent().topicsRepository, appGraph.analyticComponent.analyticInteractor, appGraph.commonComponent.resourceProvider, appGraph.sentryComponent.sentryInteractor, diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/presentation/StepCompletionActionDispatcher.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/presentation/StepCompletionActionDispatcher.kt index cfb464023a..7458abb811 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/presentation/StepCompletionActionDispatcher.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/presentation/StepCompletionActionDispatcher.kt @@ -30,7 +30,7 @@ import org.hyperskill.app.step_completion.presentation.StepCompletionFeature.Act import org.hyperskill.app.step_completion.presentation.StepCompletionFeature.Message import org.hyperskill.app.step_quiz.domain.repository.SubmissionRepository import org.hyperskill.app.streaks.domain.model.StreakState -import org.hyperskill.app.topics.domain.interactor.TopicsInteractor +import org.hyperskill.app.topics.domain.repository.TopicsRepository import ru.nobird.app.presentation.redux.dispatcher.CoroutineActionDispatcher class StepCompletionActionDispatcher( @@ -38,7 +38,7 @@ class StepCompletionActionDispatcher( submissionRepository: SubmissionRepository, private val stepInteractor: StepInteractor, private val progressesInteractor: ProgressesInteractor, - private val topicsInteractor: TopicsInteractor, + private val topicsRepository: TopicsRepository, private val analyticInteractor: AnalyticInteractor, private val resourceProvider: ResourceProvider, private val sentryInteractor: SentryInteractor, @@ -150,7 +150,7 @@ class StepCompletionActionDispatcher( coroutineScope { val topicDeferred = async { - topicsInteractor.getTopic(action.topicId) + topicsRepository.getTopic(action.topicId) } val nextLearningActivityDeferred = async { nextLearningActivityStateRepository.getState(forceUpdate = true) diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/topics/data/repository/TopicsRepositoryImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/topics/data/repository/TopicsRepositoryImpl.kt index 7181d35f8a..f3e297bcce 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/topics/data/repository/TopicsRepositoryImpl.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/topics/data/repository/TopicsRepositoryImpl.kt @@ -4,7 +4,7 @@ import org.hyperskill.app.topics.data.source.TopicsRemoteDataSource import org.hyperskill.app.topics.domain.model.Topic import org.hyperskill.app.topics.domain.repository.TopicsRepository -class TopicsRepositoryImpl( +internal class TopicsRepositoryImpl( private val topicsRemoteDataSource: TopicsRemoteDataSource ) : TopicsRepository { override suspend fun getTopics(topicsIds: List): Result> = diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/topics/domain/interactor/TopicsInteractor.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/topics/domain/interactor/TopicsInteractor.kt deleted file mode 100644 index 031af89c5b..0000000000 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/topics/domain/interactor/TopicsInteractor.kt +++ /dev/null @@ -1,14 +0,0 @@ -package org.hyperskill.app.topics.domain.interactor - -import org.hyperskill.app.topics.domain.model.Topic -import org.hyperskill.app.topics.domain.repository.TopicsRepository - -class TopicsInteractor( - private val topicsRepository: TopicsRepository -) { - suspend fun getTopics(topicsIds: List): Result> = - topicsRepository.getTopics(topicsIds) - - suspend fun getTopic(topicId: Long): Result = - topicsRepository.getTopic(topicId) -} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/topics/injection/TopicsDataComponent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/topics/injection/TopicsDataComponent.kt index 2d17ff7245..826ecc70cf 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/topics/injection/TopicsDataComponent.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/topics/injection/TopicsDataComponent.kt @@ -1,9 +1,7 @@ package org.hyperskill.app.topics.injection -import org.hyperskill.app.topics.domain.interactor.TopicsInteractor import org.hyperskill.app.topics.domain.repository.TopicsRepository interface TopicsDataComponent { val topicsRepository: TopicsRepository - val topicsInteractor: TopicsInteractor } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/topics/injection/TopicsDataComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/topics/injection/TopicsDataComponentImpl.kt index 08c1c310b2..78b47e19dd 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/topics/injection/TopicsDataComponentImpl.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/topics/injection/TopicsDataComponentImpl.kt @@ -3,11 +3,10 @@ package org.hyperskill.app.topics.injection import org.hyperskill.app.core.injection.AppGraph import org.hyperskill.app.topics.data.repository.TopicsRepositoryImpl import org.hyperskill.app.topics.data.source.TopicsRemoteDataSource -import org.hyperskill.app.topics.domain.interactor.TopicsInteractor import org.hyperskill.app.topics.domain.repository.TopicsRepository import org.hyperskill.app.topics.remote.TopicsRemoteDataSourceImpl -class TopicsDataComponentImpl( +internal class TopicsDataComponentImpl( appGraph: AppGraph ) : TopicsDataComponent { private val topicsRemoteDataSource: TopicsRemoteDataSource = @@ -15,7 +14,4 @@ class TopicsDataComponentImpl( override val topicsRepository: TopicsRepository get() = TopicsRepositoryImpl(topicsRemoteDataSource) - - override val topicsInteractor: TopicsInteractor - get() = TopicsInteractor(topicsRepository) } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/topics/remote/TopicsRemoteDataSourceImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/topics/remote/TopicsRemoteDataSourceImpl.kt index 9582790ba4..ef89fca675 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/topics/remote/TopicsRemoteDataSourceImpl.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/topics/remote/TopicsRemoteDataSourceImpl.kt @@ -10,7 +10,7 @@ import org.hyperskill.app.topics.data.source.TopicsRemoteDataSource import org.hyperskill.app.topics.domain.model.Topic import org.hyperskill.app.topics.remote.model.TopicsResponse -class TopicsRemoteDataSourceImpl( +internal class TopicsRemoteDataSourceImpl( private val httpClient: HttpClient ) : TopicsRemoteDataSource { override suspend fun getTopics(topicsIds: List): Result> = From bf46e58964c42b04fbdeb501f8e5c3eda5b97892 Mon Sep 17 00:00:00 2001 From: Ivan Magda Date: Fri, 8 Dec 2023 14:25:07 +0700 Subject: [PATCH 03/24] Gamification toolbar add search message --- .../delegate/GamificationToolbarDelegate.kt | 3 ++ ...GamificationToolbarViewActionHandler.swift | 2 ++ .../hyperskill/HyperskillAnalyticTarget.kt | 2 +- ...rClickedProgressHyperskillAnalyticEvent.kt | 4 +-- ...barClickedSearchHyperskillAnalyticEvent.kt | 34 +++++++++++++++++++ ...barClickedStreakHyperskillAnalyticEvent.kt | 6 +++- .../domain/model/GamificationToolbarScreen.kt | 3 -- .../GamificationToolbarFeature.kt | 2 ++ .../GamificationToolbarReducer.kt | 12 +++++++ .../HyperskillSentryTransactionBuilder.kt | 6 ---- 10 files changed, 61 insertions(+), 13 deletions(-) create mode 100644 shared/src/commonMain/kotlin/org/hyperskill/app/gamification_toolbar/domain/analytic/GamificationToolbarClickedSearchHyperskillAnalyticEvent.kt diff --git a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/gamification_toolbar/view/ui/delegate/GamificationToolbarDelegate.kt b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/gamification_toolbar/view/ui/delegate/GamificationToolbarDelegate.kt index 4c95216604..c4dc79a9f4 100644 --- a/androidHyperskillApp/src/main/java/org/hyperskill/app/android/gamification_toolbar/view/ui/delegate/GamificationToolbarDelegate.kt +++ b/androidHyperskillApp/src/main/java/org/hyperskill/app/android/gamification_toolbar/view/ui/delegate/GamificationToolbarDelegate.kt @@ -81,6 +81,9 @@ class GamificationToolbarDelegate( mainScreenRouter.switch(Tabs.PROFILE) GamificationToolbarFeature.Action.ViewAction.ShowProgressScreen -> router.navigateTo(ProgressScreen) + GamificationToolbarFeature.Action.ViewAction.ShowSearchScreen -> { + // TODO: ALTAPPS-1059 Show search screen + } } } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/GamificationToolbar/GamificationToolbarViewActionHandler.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/GamificationToolbar/GamificationToolbarViewActionHandler.swift index 710bdca340..0bb56cc16e 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/GamificationToolbar/GamificationToolbarViewActionHandler.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/GamificationToolbar/GamificationToolbarViewActionHandler.swift @@ -12,6 +12,8 @@ enum GamificationToolbarViewActionHandler { case .showProgressScreen: let assembly = ProgressScreenAssembly() stackRouter.pushViewController(assembly.makeModule()) + case .showSearchScreen: + #warning("TODO: ALTAPPS-1058 show search screen") } } } diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/domain/model/hyperskill/HyperskillAnalyticTarget.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/domain/model/hyperskill/HyperskillAnalyticTarget.kt index f715c90a24..7a4f48948b 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/domain/model/hyperskill/HyperskillAnalyticTarget.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/domain/model/hyperskill/HyperskillAnalyticTarget.kt @@ -36,7 +36,6 @@ enum class HyperskillAnalyticTarget(val targetName: String) { DAILY_STUDY_REMINDS_TIME("daily_study_reminds_time"), DAILY_NOTIFICATIONS_NOTICE("daily_notifications_notice"), DAILY_NOTIFICATION("daily_notification"), - CONTINUE_TO_HYPERSKILL("continue_to_hyperskill"), CONTINUE("continue"), RELOAD("reload"), DEADLINE_RELOAD("deadline_reload"), @@ -69,6 +68,7 @@ enum class HyperskillAnalyticTarget(val targetName: String) { CLOSE("close"), STREAK("streak"), PROGRESS("progress"), + SEARCH("search"), GET_STREAK_FREEZE("get_streak_freeze"), STREAK_FREEZE_ICON("streak_freeze_icon"), STREAK_FREEZE_MODAL("streak_freeze_modal"), diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/gamification_toolbar/domain/analytic/GamificationToolbarClickedProgressHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/gamification_toolbar/domain/analytic/GamificationToolbarClickedProgressHyperskillAnalyticEvent.kt index 5e41200a3c..cae7420878 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/gamification_toolbar/domain/analytic/GamificationToolbarClickedProgressHyperskillAnalyticEvent.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/gamification_toolbar/domain/analytic/GamificationToolbarClickedProgressHyperskillAnalyticEvent.kt @@ -12,10 +12,10 @@ import org.hyperskill.app.gamification_toolbar.domain.model.GamificationToolbarS * JSON payload: * ``` * { - * "route": "/track | /home", + * "route": "/home | /study-plan | /leaderboard", * "action": "click", * "part": "head", - * "target": "streak" + * "target": "progress" * } * ``` * diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/gamification_toolbar/domain/analytic/GamificationToolbarClickedSearchHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/gamification_toolbar/domain/analytic/GamificationToolbarClickedSearchHyperskillAnalyticEvent.kt new file mode 100644 index 0000000000..9c92031ca8 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/gamification_toolbar/domain/analytic/GamificationToolbarClickedSearchHyperskillAnalyticEvent.kt @@ -0,0 +1,34 @@ +package org.hyperskill.app.gamification_toolbar.domain.analytic + +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticAction +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticEvent +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticPart +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticTarget +import org.hyperskill.app.gamification_toolbar.domain.model.GamificationToolbarScreen + +/** + * Represents a click on the search navigation bar button item analytic event. + * + * JSON payload: + * ``` + * { + * "route": "/home | /study-plan | /leaderboard", + * "action": "click", + * "part": "head", + * "target": "search" + * } + * ``` + * + * @constructor Creates a new instance of [GamificationToolbarClickedSearchHyperskillAnalyticEvent]. + * @param screen The screen where the event was triggered. + * + * @see HyperskillAnalyticEvent + */ +class GamificationToolbarClickedSearchHyperskillAnalyticEvent( + screen: GamificationToolbarScreen +) : HyperskillAnalyticEvent( + screen.analyticRoute, + HyperskillAnalyticAction.CLICK, + HyperskillAnalyticPart.HEAD, + HyperskillAnalyticTarget.SEARCH +) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/gamification_toolbar/domain/analytic/GamificationToolbarClickedStreakHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/gamification_toolbar/domain/analytic/GamificationToolbarClickedStreakHyperskillAnalyticEvent.kt index 58256bf52e..9fa9864fc4 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/gamification_toolbar/domain/analytic/GamificationToolbarClickedStreakHyperskillAnalyticEvent.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/gamification_toolbar/domain/analytic/GamificationToolbarClickedStreakHyperskillAnalyticEvent.kt @@ -12,12 +12,16 @@ import org.hyperskill.app.gamification_toolbar.domain.model.GamificationToolbarS * JSON payload: * ``` * { - * "route": "/track | /home", + * "route": "/home | /study-plan | /leaderboard", * "action": "click", * "part": "head", * "target": "streak" * } * ``` + * + * @constructor Creates a new instance of [GamificationToolbarClickedStreakHyperskillAnalyticEvent]. + * @param screen The screen where the event was triggered. + * * @see HyperskillAnalyticEvent */ class GamificationToolbarClickedStreakHyperskillAnalyticEvent( diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/gamification_toolbar/domain/model/GamificationToolbarScreen.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/gamification_toolbar/domain/model/GamificationToolbarScreen.kt index d0df9ea506..6b55d0a334 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/gamification_toolbar/domain/model/GamificationToolbarScreen.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/gamification_toolbar/domain/model/GamificationToolbarScreen.kt @@ -6,14 +6,12 @@ import org.hyperskill.app.sentry.domain.model.transaction.HyperskillSentryTransa enum class GamificationToolbarScreen { HOME, - TRACK, STUDY_PLAN, LEADERBOARD; internal val analyticRoute: HyperskillAnalyticRoute get() = when (this) { HOME -> HyperskillAnalyticRoute.Home() - TRACK -> HyperskillAnalyticRoute.Track() STUDY_PLAN -> HyperskillAnalyticRoute.StudyPlan() LEADERBOARD -> HyperskillAnalyticRoute.Leaderboard() } @@ -21,7 +19,6 @@ enum class GamificationToolbarScreen { internal val fetchContentSentryTransaction: HyperskillSentryTransaction get() = when (this) { HOME -> HyperskillSentryTransactionBuilder.buildGamificationToolbarHomeScreenRemoteDataLoading() - TRACK -> HyperskillSentryTransactionBuilder.buildGamificationToolbarTrackScreenRemoteDataLoading() STUDY_PLAN -> HyperskillSentryTransactionBuilder.buildGamificationToolbarStudyPlanScreenRemoteDataLoading() LEADERBOARD -> HyperskillSentryTransactionBuilder.buildGamificationToolbarLeaderboardScreenRemoteDataLoading() diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/gamification_toolbar/presentation/GamificationToolbarFeature.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/gamification_toolbar/presentation/GamificationToolbarFeature.kt index 4fd5f5e927..4f877d0653 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/gamification_toolbar/presentation/GamificationToolbarFeature.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/gamification_toolbar/presentation/GamificationToolbarFeature.kt @@ -49,6 +49,7 @@ object GamificationToolbarFeature { sealed interface Message { object ClickedStreak : Message object ClickedProgress : Message + object ClickedSearch : Message } internal sealed interface InternalMessage : Message { @@ -79,6 +80,7 @@ object GamificationToolbarFeature { sealed interface ViewAction : Action { object ShowProfileTab : ViewAction object ShowProgressScreen : ViewAction + object ShowSearchScreen : ViewAction } } diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/gamification_toolbar/presentation/GamificationToolbarReducer.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/gamification_toolbar/presentation/GamificationToolbarReducer.kt index edb7651908..63ecf55431 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/gamification_toolbar/presentation/GamificationToolbarReducer.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/gamification_toolbar/presentation/GamificationToolbarReducer.kt @@ -1,6 +1,7 @@ package org.hyperskill.app.gamification_toolbar.presentation import org.hyperskill.app.gamification_toolbar.domain.analytic.GamificationToolbarClickedProgressHyperskillAnalyticEvent +import org.hyperskill.app.gamification_toolbar.domain.analytic.GamificationToolbarClickedSearchHyperskillAnalyticEvent import org.hyperskill.app.gamification_toolbar.domain.analytic.GamificationToolbarClickedStreakHyperskillAnalyticEvent import org.hyperskill.app.gamification_toolbar.domain.model.GamificationToolbarData import org.hyperskill.app.gamification_toolbar.domain.model.GamificationToolbarScreen @@ -123,6 +124,17 @@ class GamificationToolbarReducer( } else { null } + is Message.ClickedSearch -> + if (state is State.Content) { + state to setOf( + Action.ViewAction.ShowSearchScreen, + InternalAction.LogAnalyticEvent( + GamificationToolbarClickedSearchHyperskillAnalyticEvent(screen) + ) + ) + } else { + null + } } ?: (state to emptySet()) private fun createContentState(gamificationToolbarData: GamificationToolbarData): State.Content = diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/sentry/domain/model/transaction/HyperskillSentryTransactionBuilder.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/sentry/domain/model/transaction/HyperskillSentryTransactionBuilder.kt index 9a61dd20ca..8414449068 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/sentry/domain/model/transaction/HyperskillSentryTransactionBuilder.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/sentry/domain/model/transaction/HyperskillSentryTransactionBuilder.kt @@ -169,12 +169,6 @@ object HyperskillSentryTransactionBuilder { operation = HyperskillSentryTransactionOperation.API_LOAD ) - fun buildGamificationToolbarTrackScreenRemoteDataLoading(): HyperskillSentryTransaction = - HyperskillSentryTransaction( - name = "navigation-bar-items-feature-track-screen-remote-data-loading", - operation = HyperskillSentryTransactionOperation.API_LOAD - ) - fun buildGamificationToolbarStudyPlanScreenRemoteDataLoading(): HyperskillSentryTransaction = HyperskillSentryTransaction( name = "navigation-bar-items-feature-study_plan-screen-remote-data-loading", From 8a42a194553b2c981f563f9edc77b1b3962a7eeb Mon Sep 17 00:00:00 2001 From: Ivan Magda Date: Fri, 8 Dec 2023 15:08:41 +0700 Subject: [PATCH 04/24] SearchFeature initial commit --- .../hyperskill/HyperskillAnalyticRoute.kt | 5 +++ .../hyperskill/app/core/injection/AppGraph.kt | 2 + .../app/core/injection/BaseAppGraph.kt | 5 +++ .../SearchViewedHyperskillAnalyticEvent.kt | 23 ++++++++++ .../app/search/injection/SearchComponent.kt | 8 ++++ .../search/injection/SearchComponentImpl.kt | 14 +++++++ .../search/injection/SearchFeatureBuilder.kt | 39 +++++++++++++++++ .../presentation/SearchActionDispatcher.kt | 20 +++++++++ .../app/search/presentation/SearchFeature.kt | 42 +++++++++++++++++++ .../app/search/presentation/SearchReducer.kt | 18 ++++++++ .../view/mapper/SearchViewStateMapper.kt | 10 +++++ 11 files changed, 186 insertions(+) create mode 100644 shared/src/commonMain/kotlin/org/hyperskill/app/search/domain/analytic/SearchViewedHyperskillAnalyticEvent.kt create mode 100644 shared/src/commonMain/kotlin/org/hyperskill/app/search/injection/SearchComponent.kt create mode 100644 shared/src/commonMain/kotlin/org/hyperskill/app/search/injection/SearchComponentImpl.kt create mode 100644 shared/src/commonMain/kotlin/org/hyperskill/app/search/injection/SearchFeatureBuilder.kt create mode 100644 shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchActionDispatcher.kt create mode 100644 shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchFeature.kt create mode 100644 shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchReducer.kt create mode 100644 shared/src/commonMain/kotlin/org/hyperskill/app/search/view/mapper/SearchViewStateMapper.kt diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/domain/model/hyperskill/HyperskillAnalyticRoute.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/domain/model/hyperskill/HyperskillAnalyticRoute.kt index 8f9017c368..348123385b 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/domain/model/hyperskill/HyperskillAnalyticRoute.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/domain/model/hyperskill/HyperskillAnalyticRoute.kt @@ -126,4 +126,9 @@ sealed class HyperskillAnalyticRoute { override val path: String = "/progress" } + + class Search : HyperskillAnalyticRoute() { + override val path: String = + "/search" + } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/AppGraph.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/AppGraph.kt index 88b3fb5650..dc450e29cf 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/AppGraph.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/AppGraph.kt @@ -49,6 +49,7 @@ import org.hyperskill.app.project_selection.list.injection.ProjectSelectionListC import org.hyperskill.app.projects.injection.ProjectsDataComponent import org.hyperskill.app.providers.injection.ProvidersDataComponent import org.hyperskill.app.reactions.injection.ReactionsDataComponent +import org.hyperskill.app.search.injection.SearchComponent import org.hyperskill.app.search_results.injection.SearchResultsDataComponent import org.hyperskill.app.sentry.injection.SentryComponent import org.hyperskill.app.share_streak.injection.ShareStreakDataComponent @@ -163,4 +164,5 @@ interface AppGraph { fun buildLeaderboardScreenComponent(): LeaderboardScreenComponent fun buildLeaderboardWidgetComponent(): LeaderboardWidgetComponent fun buildSearchResultsDataComponent(): SearchResultsDataComponent + fun buildSearchComponent(): SearchComponent } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/BaseAppGraph.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/BaseAppGraph.kt index d31da6c3cd..47efb9b04d 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/BaseAppGraph.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/core/injection/BaseAppGraph.kt @@ -92,6 +92,8 @@ import org.hyperskill.app.providers.injection.ProvidersDataComponent import org.hyperskill.app.providers.injection.ProvidersDataComponentImpl import org.hyperskill.app.reactions.injection.ReactionsDataComponent import org.hyperskill.app.reactions.injection.ReactionsDataComponentImpl +import org.hyperskill.app.search.injection.SearchComponent +import org.hyperskill.app.search.injection.SearchComponentImpl import org.hyperskill.app.search_results.injection.SearchResultsDataComponent import org.hyperskill.app.search_results.injection.SearchResultsDataComponentImpl import org.hyperskill.app.share_streak.injection.ShareStreakDataComponent @@ -441,4 +443,7 @@ abstract class BaseAppGraph : AppGraph { override fun buildSearchResultsDataComponent(): SearchResultsDataComponent = SearchResultsDataComponentImpl(this) + + override fun buildSearchComponent(): SearchComponent = + SearchComponentImpl(this) } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search/domain/analytic/SearchViewedHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search/domain/analytic/SearchViewedHyperskillAnalyticEvent.kt new file mode 100644 index 0000000000..7128c34849 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search/domain/analytic/SearchViewedHyperskillAnalyticEvent.kt @@ -0,0 +1,23 @@ +package org.hyperskill.app.search.domain.analytic + +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticAction +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticEvent +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticRoute + +/** + * Represents a view analytic event. + * + * JSON payload: + * ``` + * { + * "route": "/search", + * "action": "view" + * } + * ``` + * + * @see HyperskillAnalyticEvent + */ +object SearchViewedHyperskillAnalyticEvent : HyperskillAnalyticEvent( + route = HyperskillAnalyticRoute.Search(), + action = HyperskillAnalyticAction.VIEW +) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search/injection/SearchComponent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search/injection/SearchComponent.kt new file mode 100644 index 0000000000..cd4593de6b --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search/injection/SearchComponent.kt @@ -0,0 +1,8 @@ +package org.hyperskill.app.search.injection + +import org.hyperskill.app.search.presentation.SearchFeature +import ru.nobird.app.presentation.redux.feature.Feature + +interface SearchComponent { + val searchFeature: Feature +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search/injection/SearchComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search/injection/SearchComponentImpl.kt new file mode 100644 index 0000000000..a4218ceb54 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search/injection/SearchComponentImpl.kt @@ -0,0 +1,14 @@ +package org.hyperskill.app.search.injection + +import org.hyperskill.app.core.injection.AppGraph +import org.hyperskill.app.search.presentation.SearchFeature +import ru.nobird.app.presentation.redux.feature.Feature + +internal class SearchComponentImpl(private val appGraph: AppGraph) : SearchComponent { + override val searchFeature: Feature + get() = SearchFeatureBuilder.build( + analyticInteractor = appGraph.analyticComponent.analyticInteractor, + logger = appGraph.loggerComponent.logger, + buildVariant = appGraph.commonComponent.buildKonfig.buildVariant, + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search/injection/SearchFeatureBuilder.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search/injection/SearchFeatureBuilder.kt new file mode 100644 index 0000000000..7db959a688 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search/injection/SearchFeatureBuilder.kt @@ -0,0 +1,39 @@ +package org.hyperskill.app.search.injection + +import co.touchlab.kermit.Logger +import org.hyperskill.app.analytic.domain.interactor.AnalyticInteractor +import org.hyperskill.app.core.domain.BuildVariant +import org.hyperskill.app.core.presentation.ActionDispatcherOptions +import org.hyperskill.app.core.presentation.transformState +import org.hyperskill.app.logging.presentation.wrapWithLogger +import org.hyperskill.app.search.presentation.SearchActionDispatcher +import org.hyperskill.app.search.presentation.SearchFeature +import org.hyperskill.app.search.presentation.SearchReducer +import org.hyperskill.app.search.view.mapper.SearchViewStateMapper +import ru.nobird.app.presentation.redux.dispatcher.wrapWithActionDispatcher +import ru.nobird.app.presentation.redux.feature.Feature +import ru.nobird.app.presentation.redux.feature.ReduxFeature + +internal object SearchFeatureBuilder { + private const val LOG_TAG = "SearchFeature" + + fun build( + analyticInteractor: AnalyticInteractor, + logger: Logger, + buildVariant: BuildVariant, + ): Feature { + val searchReducer = SearchReducer().wrapWithLogger(buildVariant, logger, LOG_TAG) + + val searchActionDispatcher = SearchActionDispatcher( + config = ActionDispatcherOptions(), + analyticInteractor = analyticInteractor + ) + + return ReduxFeature( + initialState = SearchFeature.initialState(), + reducer = searchReducer + ) + .transformState(SearchViewStateMapper::map) + .wrapWithActionDispatcher(searchActionDispatcher) + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchActionDispatcher.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchActionDispatcher.kt new file mode 100644 index 0000000000..5b23a775a7 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchActionDispatcher.kt @@ -0,0 +1,20 @@ +package org.hyperskill.app.search.presentation + +import org.hyperskill.app.analytic.domain.interactor.AnalyticInteractor +import org.hyperskill.app.core.presentation.ActionDispatcherOptions +import org.hyperskill.app.search.presentation.SearchFeature.Action +import org.hyperskill.app.search.presentation.SearchFeature.Message +import ru.nobird.app.presentation.redux.dispatcher.CoroutineActionDispatcher + +internal class SearchActionDispatcher( + config: ActionDispatcherOptions, + private val analyticInteractor: AnalyticInteractor +) : CoroutineActionDispatcher(config.createConfig()) { + override suspend fun doSuspendableAction(action: Action) { + when (action) { + is SearchFeature.InternalAction.LogAnalyticEvent -> { + analyticInteractor.logEvent(action.analyticEvent) + } + } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchFeature.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchFeature.kt new file mode 100644 index 0000000000..326674e61b --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchFeature.kt @@ -0,0 +1,42 @@ +package org.hyperskill.app.search.presentation + +import org.hyperskill.app.analytic.domain.model.AnalyticEvent +import org.hyperskill.app.topics.domain.model.Topic + +object SearchFeature { + internal data class State( + val query: String, + val searchResultsState: SearchResultsState + ) + + internal sealed interface SearchResultsState { + object Editing : SearchResultsState + object Loading : SearchResultsState + object Error : SearchResultsState + data class Content(val topics: List) : SearchResultsState + } + + data class ViewState( + val query: String + ) + + internal fun initialState(): State = + State( + query = "", + searchResultsState = SearchResultsState.Editing + ) + + sealed interface Message { + object ViewedEventMessage : Message + } + + internal sealed interface InternalMessage : Message + + sealed interface Action { + sealed interface ViewAction : Action + } + + internal sealed interface InternalAction : Action { + data class LogAnalyticEvent(val analyticEvent: AnalyticEvent) : InternalAction + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchReducer.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchReducer.kt new file mode 100644 index 0000000000..a82b9f06f4 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchReducer.kt @@ -0,0 +1,18 @@ +package org.hyperskill.app.search.presentation + +import org.hyperskill.app.search.domain.analytic.SearchViewedHyperskillAnalyticEvent +import org.hyperskill.app.search.presentation.SearchFeature.Action +import org.hyperskill.app.search.presentation.SearchFeature.InternalAction +import org.hyperskill.app.search.presentation.SearchFeature.State +import ru.nobird.app.presentation.redux.reducer.StateReducer + +private typealias SearchReducerResult = Pair> + +internal class SearchReducer : StateReducer { + override fun reduce(state: State, message: SearchFeature.Message): SearchReducerResult = + when (message) { + SearchFeature.Message.ViewedEventMessage -> { + state to setOf(InternalAction.LogAnalyticEvent(SearchViewedHyperskillAnalyticEvent)) + } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search/view/mapper/SearchViewStateMapper.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search/view/mapper/SearchViewStateMapper.kt new file mode 100644 index 0000000000..56d771f822 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search/view/mapper/SearchViewStateMapper.kt @@ -0,0 +1,10 @@ +package org.hyperskill.app.search.view.mapper + +import org.hyperskill.app.search.presentation.SearchFeature + +internal object SearchViewStateMapper { + fun map(state: SearchFeature.State): SearchFeature.ViewState = + SearchFeature.ViewState( + query = state.query + ) +} \ No newline at end of file From 8acff522eb68bbfca1f0dda64f4089699d7dd3c8 Mon Sep 17 00:00:00 2001 From: Ivan Magda Date: Fri, 8 Dec 2023 18:07:15 +0700 Subject: [PATCH 05/24] Do search --- ...rchClickedSearchHyperskillAnalyticEvent.kt | 46 ++++++++++++ .../domain/interactor/SearchInteractor.kt | 30 ++++++++ .../search/injection/SearchComponentImpl.kt | 9 +++ .../search/injection/SearchFeatureBuilder.kt | 6 ++ .../presentation/SearchActionDispatcher.kt | 30 +++++++- .../app/search/presentation/SearchFeature.kt | 11 ++- .../app/search/presentation/SearchReducer.kt | 73 ++++++++++++++++++- .../HyperskillSentryTransactionBuilder.kt | 9 +++ 8 files changed, 209 insertions(+), 5 deletions(-) create mode 100644 shared/src/commonMain/kotlin/org/hyperskill/app/search/domain/analytic/SearchClickedSearchHyperskillAnalyticEvent.kt create mode 100644 shared/src/commonMain/kotlin/org/hyperskill/app/search/domain/interactor/SearchInteractor.kt diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search/domain/analytic/SearchClickedSearchHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search/domain/analytic/SearchClickedSearchHyperskillAnalyticEvent.kt new file mode 100644 index 0000000000..ae63db8a2c --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search/domain/analytic/SearchClickedSearchHyperskillAnalyticEvent.kt @@ -0,0 +1,46 @@ +package org.hyperskill.app.search.domain.analytic + +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticAction +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticEvent +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticPart +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticRoute +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticTarget + +/** + * Represents a click analytic event on the "Search" button. + * + * JSON payload: + * ``` + * { + * "route": "/search", + * "action": "click", + * "part": "main", + * "target": "search", + * "context": + * { + * "query": "test" + * } + * } + * ``` + * + * @property query a search query. + * + * @see HyperskillAnalyticEvent + */ +class SearchClickedSearchHyperskillAnalyticEvent( + private val query: String +) : HyperskillAnalyticEvent( + HyperskillAnalyticRoute.Search(), + HyperskillAnalyticAction.CLICK, + HyperskillAnalyticPart.MAIN, + HyperskillAnalyticTarget.SEARCH +) { + companion object { + private const val PARAM_QUERY = "query" + } + + override val params: Map + get() = super.params + mapOf( + PARAM_CONTEXT to mapOf(PARAM_QUERY to query) + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search/domain/interactor/SearchInteractor.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search/domain/interactor/SearchInteractor.kt new file mode 100644 index 0000000000..819ab5e10d --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search/domain/interactor/SearchInteractor.kt @@ -0,0 +1,30 @@ +package org.hyperskill.app.search.domain.interactor + +import org.hyperskill.app.search_results.domain.repository.SearchResultsRepository +import org.hyperskill.app.search_results.domain.repository.getTopicSearchResults +import org.hyperskill.app.topics.domain.model.Topic +import org.hyperskill.app.topics.domain.repository.TopicsRepository + +internal class SearchInteractor( + private val searchResultsRepository: SearchResultsRepository, + private val topicsRepository: TopicsRepository +) { + suspend fun searchTopics( + query: String, + pageSize: Int = 20, + page: Int = 1 + ): Result> = + searchResultsRepository + .getTopicSearchResults(query = query, pageSize = pageSize, page = page) + .map { searchResults -> + searchResults.map { it.targetId } + } + .fold( + onSuccess = { topicsIds -> + topicsRepository.getTopics(topicsIds) + }, + onFailure = { error -> + Result.failure(error) + } + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search/injection/SearchComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search/injection/SearchComponentImpl.kt index a4218ceb54..245dfc480b 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/search/injection/SearchComponentImpl.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search/injection/SearchComponentImpl.kt @@ -1,12 +1,21 @@ package org.hyperskill.app.search.injection import org.hyperskill.app.core.injection.AppGraph +import org.hyperskill.app.search.domain.interactor.SearchInteractor import org.hyperskill.app.search.presentation.SearchFeature import ru.nobird.app.presentation.redux.feature.Feature internal class SearchComponentImpl(private val appGraph: AppGraph) : SearchComponent { + private val searchInteractor: SearchInteractor = + SearchInteractor( + searchResultsRepository = appGraph.buildSearchResultsDataComponent().searchResultsRepository, + topicsRepository = appGraph.buildTopicsDataComponent().topicsRepository + ) + override val searchFeature: Feature get() = SearchFeatureBuilder.build( + searchInteractor = searchInteractor, + sentryInteractor = appGraph.sentryComponent.sentryInteractor, analyticInteractor = appGraph.analyticComponent.analyticInteractor, logger = appGraph.loggerComponent.logger, buildVariant = appGraph.commonComponent.buildKonfig.buildVariant, diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search/injection/SearchFeatureBuilder.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search/injection/SearchFeatureBuilder.kt index 7db959a688..f4c8bd2d6b 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/search/injection/SearchFeatureBuilder.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search/injection/SearchFeatureBuilder.kt @@ -6,10 +6,12 @@ import org.hyperskill.app.core.domain.BuildVariant import org.hyperskill.app.core.presentation.ActionDispatcherOptions import org.hyperskill.app.core.presentation.transformState import org.hyperskill.app.logging.presentation.wrapWithLogger +import org.hyperskill.app.search.domain.interactor.SearchInteractor import org.hyperskill.app.search.presentation.SearchActionDispatcher import org.hyperskill.app.search.presentation.SearchFeature import org.hyperskill.app.search.presentation.SearchReducer import org.hyperskill.app.search.view.mapper.SearchViewStateMapper +import org.hyperskill.app.sentry.domain.interactor.SentryInteractor import ru.nobird.app.presentation.redux.dispatcher.wrapWithActionDispatcher import ru.nobird.app.presentation.redux.feature.Feature import ru.nobird.app.presentation.redux.feature.ReduxFeature @@ -18,6 +20,8 @@ internal object SearchFeatureBuilder { private const val LOG_TAG = "SearchFeature" fun build( + searchInteractor: SearchInteractor, + sentryInteractor: SentryInteractor, analyticInteractor: AnalyticInteractor, logger: Logger, buildVariant: BuildVariant, @@ -26,6 +30,8 @@ internal object SearchFeatureBuilder { val searchActionDispatcher = SearchActionDispatcher( config = ActionDispatcherOptions(), + searchInteractor = searchInteractor, + sentryInteractor = sentryInteractor, analyticInteractor = analyticInteractor ) diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchActionDispatcher.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchActionDispatcher.kt index 5b23a775a7..6dccbdbfd0 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchActionDispatcher.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchActionDispatcher.kt @@ -2,19 +2,47 @@ package org.hyperskill.app.search.presentation import org.hyperskill.app.analytic.domain.interactor.AnalyticInteractor import org.hyperskill.app.core.presentation.ActionDispatcherOptions +import org.hyperskill.app.search.domain.interactor.SearchInteractor import org.hyperskill.app.search.presentation.SearchFeature.Action +import org.hyperskill.app.search.presentation.SearchFeature.InternalAction +import org.hyperskill.app.search.presentation.SearchFeature.InternalMessage import org.hyperskill.app.search.presentation.SearchFeature.Message +import org.hyperskill.app.sentry.domain.interactor.SentryInteractor +import org.hyperskill.app.sentry.domain.model.transaction.HyperskillSentryTransactionBuilder +import org.hyperskill.app.sentry.domain.withTransaction import ru.nobird.app.presentation.redux.dispatcher.CoroutineActionDispatcher internal class SearchActionDispatcher( config: ActionDispatcherOptions, + private val searchInteractor: SearchInteractor, + private val sentryInteractor: SentryInteractor, private val analyticInteractor: AnalyticInteractor ) : CoroutineActionDispatcher(config.createConfig()) { override suspend fun doSuspendableAction(action: Action) { when (action) { - is SearchFeature.InternalAction.LogAnalyticEvent -> { + is InternalAction.PerformSearch -> { + handlePerformSearchAction(action, ::onNewMessage) + } + is InternalAction.LogAnalyticEvent -> { analyticInteractor.logEvent(action.analyticEvent) } } } + + private suspend fun handlePerformSearchAction( + action: InternalAction.PerformSearch, + onNewMessage: (Message) -> Unit + ) { + sentryInteractor.withTransaction( + HyperskillSentryTransactionBuilder.buildSearchFeaturePerformSearch(), + onError = { InternalMessage.PerformSearchError } + ) { + val topics = searchInteractor + .searchTopics(query = action.query) + .getOrThrow() + InternalMessage.PerformSearchSuccess( + topics = topics + ) + }.let(onNewMessage) + } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchFeature.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchFeature.kt index 326674e61b..e9a34ed461 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchFeature.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchFeature.kt @@ -27,16 +27,25 @@ object SearchFeature { ) sealed interface Message { + data class QueryChanged(val query: String) : Message + + object SearchClicked : Message + object ViewedEventMessage : Message } - internal sealed interface InternalMessage : Message + internal sealed interface InternalMessage : Message { + object PerformSearchError : InternalMessage + data class PerformSearchSuccess(val topics: List) : InternalMessage + } sealed interface Action { sealed interface ViewAction : Action } internal sealed interface InternalAction : Action { + data class PerformSearch(val query: String) : InternalAction + data class LogAnalyticEvent(val analyticEvent: AnalyticEvent) : InternalAction } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchReducer.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchReducer.kt index a82b9f06f4..2726d649e9 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchReducer.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchReducer.kt @@ -1,18 +1,85 @@ package org.hyperskill.app.search.presentation +import org.hyperskill.app.search.domain.analytic.SearchClickedSearchHyperskillAnalyticEvent import org.hyperskill.app.search.domain.analytic.SearchViewedHyperskillAnalyticEvent import org.hyperskill.app.search.presentation.SearchFeature.Action import org.hyperskill.app.search.presentation.SearchFeature.InternalAction +import org.hyperskill.app.search.presentation.SearchFeature.InternalMessage +import org.hyperskill.app.search.presentation.SearchFeature.Message import org.hyperskill.app.search.presentation.SearchFeature.State import ru.nobird.app.presentation.redux.reducer.StateReducer private typealias SearchReducerResult = Pair> -internal class SearchReducer : StateReducer { - override fun reduce(state: State, message: SearchFeature.Message): SearchReducerResult = +internal class SearchReducer : StateReducer { + override fun reduce(state: State, message: Message): SearchReducerResult = when (message) { - SearchFeature.Message.ViewedEventMessage -> { + is Message.QueryChanged -> { + handleQueryChangedMessage(state, message) + } + Message.SearchClicked -> { + handleSearchClickedMessage(state) + } + InternalMessage.PerformSearchError -> { + if (state.searchResultsState == SearchFeature.SearchResultsState.Loading) { + state.copy( + searchResultsState = SearchFeature.SearchResultsState.Error + ) to emptySet() + } else { + null + } + } + is InternalMessage.PerformSearchSuccess -> { + if (state.searchResultsState == SearchFeature.SearchResultsState.Loading) { + state.copy( + searchResultsState = SearchFeature.SearchResultsState.Content(message.topics) + ) to emptySet() + } else { + null + } + } + Message.ViewedEventMessage -> { state to setOf(InternalAction.LogAnalyticEvent(SearchViewedHyperskillAnalyticEvent)) } + } ?: (state to emptySet()) + + private fun handleQueryChangedMessage( + state: State, + message: Message.QueryChanged + ): SearchReducerResult? { + if (state.searchResultsState == SearchFeature.SearchResultsState.Loading) { + return null + } + + val newSearchResultsState = when (state.searchResultsState) { + SearchFeature.SearchResultsState.Editing -> SearchFeature.SearchResultsState.Editing + SearchFeature.SearchResultsState.Loading -> SearchFeature.SearchResultsState.Loading + SearchFeature.SearchResultsState.Error -> SearchFeature.SearchResultsState.Editing + is SearchFeature.SearchResultsState.Content -> SearchFeature.SearchResultsState.Editing + } + + return state.copy( + query = message.query, + searchResultsState = newSearchResultsState + ) to emptySet() + } + + private fun handleSearchClickedMessage(state: State): SearchReducerResult { + val analyticActions = setOf( + InternalAction.LogAnalyticEvent(SearchClickedSearchHyperskillAnalyticEvent(state.query)) + ) + + if (state.query.isBlank()) { + return state to analyticActions + } + + return when (state.searchResultsState) { + SearchFeature.SearchResultsState.Editing, + SearchFeature.SearchResultsState.Error -> + state to (analyticActions + InternalAction.PerformSearch(state.query)) + SearchFeature.SearchResultsState.Loading, + is SearchFeature.SearchResultsState.Content -> + state to analyticActions } + } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/sentry/domain/model/transaction/HyperskillSentryTransactionBuilder.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/sentry/domain/model/transaction/HyperskillSentryTransactionBuilder.kt index 8414449068..8ac6a9161a 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/sentry/domain/model/transaction/HyperskillSentryTransactionBuilder.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/sentry/domain/model/transaction/HyperskillSentryTransactionBuilder.kt @@ -294,4 +294,13 @@ object HyperskillSentryTransactionBuilder { name = "challenge-widget-feature-fetch-challenges", operation = HyperskillSentryTransactionOperation.API_LOAD ) + + /** + * SearchFeature + */ + fun buildSearchFeaturePerformSearch(): HyperskillSentryTransaction = + HyperskillSentryTransaction( + name = "search-feature-perform-search", + operation = HyperskillSentryTransactionOperation.API_LOAD + ) } \ No newline at end of file From 5cb60dd963199bfb40039c21ed07cf7db869d85f Mon Sep 17 00:00:00 2001 From: Ivan Magda Date: Fri, 8 Dec 2023 18:29:38 +0700 Subject: [PATCH 06/24] Add ViewState --- .../app/search/presentation/SearchFeature.kt | 18 +++++++++- .../view/mapper/SearchViewStateMapper.kt | 33 +++++++++++++++++-- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchFeature.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchFeature.kt index e9a34ed461..905ae90782 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchFeature.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchFeature.kt @@ -2,6 +2,7 @@ package org.hyperskill.app.search.presentation import org.hyperskill.app.analytic.domain.model.AnalyticEvent import org.hyperskill.app.topics.domain.model.Topic +import ru.nobird.app.core.model.Identifiable object SearchFeature { internal data class State( @@ -17,9 +18,24 @@ object SearchFeature { } data class ViewState( - val query: String + val query: String, + val searchResultsViewState: SearchResultsViewState, + val isSearchButtonEnabled: Boolean, + val isUserInteractionEnabled: Boolean ) + sealed interface SearchResultsViewState { + object Editing : SearchResultsViewState + object Loading : SearchResultsViewState + object Error : SearchResultsViewState + data class Content(val searchResults: List) : SearchResultsViewState { + data class Item( + override val id: Long, + val title: String + ) : Identifiable + } + } + internal fun initialState(): State = State( query = "", diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search/view/mapper/SearchViewStateMapper.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search/view/mapper/SearchViewStateMapper.kt index 56d771f822..74675e4d55 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/search/view/mapper/SearchViewStateMapper.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search/view/mapper/SearchViewStateMapper.kt @@ -3,8 +3,35 @@ package org.hyperskill.app.search.view.mapper import org.hyperskill.app.search.presentation.SearchFeature internal object SearchViewStateMapper { - fun map(state: SearchFeature.State): SearchFeature.ViewState = - SearchFeature.ViewState( - query = state.query + fun map(state: SearchFeature.State): SearchFeature.ViewState { + val searchResultsViewState = mapSearchResultsState(state.searchResultsState) + + val isSearchButtonEnabled = state.query.isNotBlank() && + searchResultsViewState != SearchFeature.SearchResultsViewState.Loading + + return SearchFeature.ViewState( + query = state.query, + searchResultsViewState = searchResultsViewState, + isSearchButtonEnabled = isSearchButtonEnabled, + isUserInteractionEnabled = searchResultsViewState != SearchFeature.SearchResultsViewState.Loading ) + } + + private fun mapSearchResultsState( + state: SearchFeature.SearchResultsState + ): SearchFeature.SearchResultsViewState = + when (state) { + SearchFeature.SearchResultsState.Editing -> SearchFeature.SearchResultsViewState.Editing + SearchFeature.SearchResultsState.Loading -> SearchFeature.SearchResultsViewState.Loading + SearchFeature.SearchResultsState.Error -> SearchFeature.SearchResultsViewState.Error + is SearchFeature.SearchResultsState.Content -> + SearchFeature.SearchResultsViewState.Content( + searchResults = state.topics.map { topic -> + SearchFeature.SearchResultsViewState.Content.Item( + id = topic.id, + title = topic.title + ) + } + ) + } } \ No newline at end of file From e74e358208e4c1943aa5b3d521dba9fd7eabbaea Mon Sep 17 00:00:00 2001 From: Ivan Magda Date: Fri, 8 Dec 2023 19:10:51 +0700 Subject: [PATCH 07/24] Handle click on search results item --- .../step_quiz/AndroidStepQuizTest.kt | 3 ++ .../hyperskill/HyperskillAnalyticPart.kt | 4 +- .../hyperskill/HyperskillAnalyticTarget.kt | 3 +- .../domain/analytic/SearchAnalyticParams.kt | 6 +++ ...earchClickedItemHyperskillAnalyticEvent.kt | 48 +++++++++++++++++++ ...rchClickedSearchHyperskillAnalyticEvent.kt | 6 +-- .../presentation/SearchActionDispatcher.kt | 3 ++ .../app/search/presentation/SearchFeature.kt | 6 ++- .../app/search/presentation/SearchReducer.kt | 28 +++++++++++ .../app/step/domain/model/StepRoute.kt | 3 ++ .../app/step/presentation/StepReducer.kt | 12 ++++- .../presentation/StepCompletionFeature.kt | 1 + .../step_quiz/presentation/StepQuizReducer.kt | 1 + .../presentation/StepQuizResolver.kt | 1 + 14 files changed, 115 insertions(+), 10 deletions(-) create mode 100644 shared/src/commonMain/kotlin/org/hyperskill/app/search/domain/analytic/SearchAnalyticParams.kt create mode 100644 shared/src/commonMain/kotlin/org/hyperskill/app/search/domain/analytic/SearchClickedItemHyperskillAnalyticEvent.kt diff --git a/shared/src/androidUnitTest/kotlin/org/hyperskill/step_quiz/AndroidStepQuizTest.kt b/shared/src/androidUnitTest/kotlin/org/hyperskill/step_quiz/AndroidStepQuizTest.kt index 8ba140613d..0be8377762 100644 --- a/shared/src/androidUnitTest/kotlin/org/hyperskill/step_quiz/AndroidStepQuizTest.kt +++ b/shared/src/androidUnitTest/kotlin/org/hyperskill/step_quiz/AndroidStepQuizTest.kt @@ -33,6 +33,7 @@ class AndroidStepQuizTest { isTheoryAvailable = when (concreteStepRouteClass) { StepRoute.Learn.Step::class -> true StepRoute.Learn.TheoryOpenedFromPractice::class -> false + StepRoute.Learn.TheoryOpenedFromSearch::class -> false StepRoute.LearnDaily::class -> false StepRoute.Repeat.Practice::class -> true StepRoute.Repeat.Theory::class -> false @@ -49,6 +50,8 @@ class AndroidStepQuizTest { StepRoute.Learn.Step::class -> StepRoute.Learn.Step(step.id) StepRoute.Learn.TheoryOpenedFromPractice::class -> StepRoute.Learn.TheoryOpenedFromPractice(step.id) + StepRoute.Learn.TheoryOpenedFromSearch::class -> + StepRoute.Learn.TheoryOpenedFromSearch(step.id) StepRoute.LearnDaily::class -> StepRoute.LearnDaily(step.id) StepRoute.Repeat.Practice::class -> StepRoute.Repeat.Practice(step.id) StepRoute.Repeat.Theory::class -> StepRoute.Repeat.Theory(step.id) diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/domain/model/hyperskill/HyperskillAnalyticPart.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/domain/model/hyperskill/HyperskillAnalyticPart.kt index e2d3027878..196f36e078 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/domain/model/hyperskill/HyperskillAnalyticPart.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/domain/model/hyperskill/HyperskillAnalyticPart.kt @@ -35,10 +35,10 @@ enum class HyperskillAnalyticPart(val partName: String) { STREAK_RECOVERY_MODAL("streak_recovery_modal"), STAGE_COMPLETED_MODAL("stage_completed_modal"), PROJECT_COMPLETED_MODAL("project_completed_modal"), - NEXT_LEARNING_ACTIVITY_WIDGET("next_learning_activity_widget"), FULL_SCREEN_CODE_EDITOR("full_screen_code_editor"), CODE_EDITOR("code_editor"), SHARE_STREAK_MODAL("share_streak_modal"), LEADERBOARD_DAY_TAB("leaderboard_day_tab"), - LEADERBOARD_WEEK_TAB("leaderboard_week_tab") + LEADERBOARD_WEEK_TAB("leaderboard_week_tab"), + SEARCH_RESULTS("search_results") } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/domain/model/hyperskill/HyperskillAnalyticTarget.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/domain/model/hyperskill/HyperskillAnalyticTarget.kt index 7a4f48948b..91c8c45942 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/domain/model/hyperskill/HyperskillAnalyticTarget.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/analytic/domain/model/hyperskill/HyperskillAnalyticTarget.kt @@ -107,5 +107,6 @@ enum class HyperskillAnalyticTarget(val targetName: String) { COLLECT_REWARD("collect_reward"), DAY("day"), WEEK("week"), - LEADERBOARD_ITEM("leaderboard_item") + LEADERBOARD_ITEM("leaderboard_item"), + TOPIC("topic") } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search/domain/analytic/SearchAnalyticParams.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search/domain/analytic/SearchAnalyticParams.kt new file mode 100644 index 0000000000..2a457db3d0 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search/domain/analytic/SearchAnalyticParams.kt @@ -0,0 +1,6 @@ +package org.hyperskill.app.search.domain.analytic + +internal object SearchAnalyticParams { + const val PARAM_QUERY = "query" + const val PARAM_TOPIC_ID = "topic_id" +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search/domain/analytic/SearchClickedItemHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search/domain/analytic/SearchClickedItemHyperskillAnalyticEvent.kt new file mode 100644 index 0000000000..eba8b554fb --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search/domain/analytic/SearchClickedItemHyperskillAnalyticEvent.kt @@ -0,0 +1,48 @@ +package org.hyperskill.app.search.domain.analytic + +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticAction +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticEvent +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticPart +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticRoute +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticTarget + +/** + * Represents a click analytic event on the search results item. + * + * JSON payload: + * ``` + * { + * "route": "/search", + * "action": "click", + * "part": "search_results", + * "target": "topic", + * "context": + * { + * "query": "test", + * "topic_id": 1 + * } + * } + * ``` + * + * @property query a search query. + * @property topicId target topic id. + * + * @see HyperskillAnalyticEvent + */ +class SearchClickedItemHyperskillAnalyticEvent( + private val query: String, + private val topicId: Long +) : HyperskillAnalyticEvent( + HyperskillAnalyticRoute.Search(), + HyperskillAnalyticAction.CLICK, + HyperskillAnalyticPart.SEARCH_RESULTS, + HyperskillAnalyticTarget.TOPIC +) { + override val params: Map + get() = super.params + mapOf( + PARAM_CONTEXT to mapOf( + SearchAnalyticParams.PARAM_QUERY to query, + SearchAnalyticParams.PARAM_TOPIC_ID to topicId + ) + ) +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search/domain/analytic/SearchClickedSearchHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search/domain/analytic/SearchClickedSearchHyperskillAnalyticEvent.kt index ae63db8a2c..2988057f5a 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/search/domain/analytic/SearchClickedSearchHyperskillAnalyticEvent.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search/domain/analytic/SearchClickedSearchHyperskillAnalyticEvent.kt @@ -35,12 +35,8 @@ class SearchClickedSearchHyperskillAnalyticEvent( HyperskillAnalyticPart.MAIN, HyperskillAnalyticTarget.SEARCH ) { - companion object { - private const val PARAM_QUERY = "query" - } - override val params: Map get() = super.params + mapOf( - PARAM_CONTEXT to mapOf(PARAM_QUERY to query) + PARAM_CONTEXT to mapOf(SearchAnalyticParams.PARAM_QUERY to query) ) } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchActionDispatcher.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchActionDispatcher.kt index 6dccbdbfd0..9aade4de8c 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchActionDispatcher.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchActionDispatcher.kt @@ -26,6 +26,9 @@ internal class SearchActionDispatcher( is InternalAction.LogAnalyticEvent -> { analyticInteractor.logEvent(action.analyticEvent) } + else -> { + // no op + } } } diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchFeature.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchFeature.kt index 905ae90782..073a3c8193 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchFeature.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchFeature.kt @@ -1,6 +1,7 @@ package org.hyperskill.app.search.presentation import org.hyperskill.app.analytic.domain.model.AnalyticEvent +import org.hyperskill.app.step.domain.model.StepRoute import org.hyperskill.app.topics.domain.model.Topic import ru.nobird.app.core.model.Identifiable @@ -46,6 +47,7 @@ object SearchFeature { data class QueryChanged(val query: String) : Message object SearchClicked : Message + data class SearchResultsItemClicked(val id: Long) : Message object ViewedEventMessage : Message } @@ -56,7 +58,9 @@ object SearchFeature { } sealed interface Action { - sealed interface ViewAction : Action + sealed interface ViewAction : Action { + data class OpenStepScreen(val stepRoute: StepRoute) : ViewAction + } } internal sealed interface InternalAction : Action { diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchReducer.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchReducer.kt index 2726d649e9..c743290a7a 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchReducer.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchReducer.kt @@ -1,5 +1,6 @@ package org.hyperskill.app.search.presentation +import org.hyperskill.app.search.domain.analytic.SearchClickedItemHyperskillAnalyticEvent import org.hyperskill.app.search.domain.analytic.SearchClickedSearchHyperskillAnalyticEvent import org.hyperskill.app.search.domain.analytic.SearchViewedHyperskillAnalyticEvent import org.hyperskill.app.search.presentation.SearchFeature.Action @@ -7,6 +8,7 @@ import org.hyperskill.app.search.presentation.SearchFeature.InternalAction import org.hyperskill.app.search.presentation.SearchFeature.InternalMessage import org.hyperskill.app.search.presentation.SearchFeature.Message import org.hyperskill.app.search.presentation.SearchFeature.State +import org.hyperskill.app.step.domain.model.StepRoute import ru.nobird.app.presentation.redux.reducer.StateReducer private typealias SearchReducerResult = Pair> @@ -38,6 +40,9 @@ internal class SearchReducer : StateReducer { null } } + is Message.SearchResultsItemClicked -> { + handleSearchResultsItemClickedMessage(state, message) + } Message.ViewedEventMessage -> { state to setOf(InternalAction.LogAnalyticEvent(SearchViewedHyperskillAnalyticEvent)) } @@ -82,4 +87,27 @@ internal class SearchReducer : StateReducer { state to analyticActions } } + + private fun handleSearchResultsItemClickedMessage( + state: State, + message: Message.SearchResultsItemClicked + ): SearchReducerResult? { + if (state.searchResultsState is SearchFeature.SearchResultsState.Content) { + val targetTopic = state.searchResultsState.topics.firstOrNull { it.id == message.id } ?: return null + return state to buildSet { + if (targetTopic.theoryId != null) { + add( + Action.ViewAction.OpenStepScreen(StepRoute.Learn.TheoryOpenedFromSearch(targetTopic.theoryId)) + ) + } + add( + InternalAction.LogAnalyticEvent( + SearchClickedItemHyperskillAnalyticEvent(query = state.query, topicId = targetTopic.id) + ) + ) + } + } else { + return null + } + } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step/domain/model/StepRoute.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step/domain/model/StepRoute.kt index 38d522994d..ec2fdd606d 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step/domain/model/StepRoute.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step/domain/model/StepRoute.kt @@ -23,6 +23,9 @@ sealed interface StepRoute { @Serializable data class TheoryOpenedFromPractice(override val stepId: Long) : Learn + + @Serializable + data class TheoryOpenedFromSearch(override val stepId: Long) : Learn } @Serializable diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step/presentation/StepReducer.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step/presentation/StepReducer.kt index 1a24954c55..48d5c72b39 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step/presentation/StepReducer.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step/presentation/StepReducer.kt @@ -29,7 +29,17 @@ class StepReducer( is Message.StepLoaded.Success -> { State.Data( step = message.step, - isPracticingAvailable = stepRoute is StepRoute.Learn, + isPracticingAvailable = when (stepRoute) { + is StepRoute.Learn.Step, + is StepRoute.Learn.TheoryOpenedFromPractice -> + true + is StepRoute.Learn.TheoryOpenedFromSearch, + is StepRoute.LearnDaily, + is StepRoute.Repeat.Practice, + is StepRoute.Repeat.Theory, + is StepRoute.StageImplement -> + false + }, stepCompletionState = StepCompletionFeature.createState(message.step, stepRoute) ) to setOf(Action.UpdateNextLearningActivityState(message.step)) } diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/presentation/StepCompletionFeature.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/presentation/StepCompletionFeature.kt index 51132340cc..e11e3dc431 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/presentation/StepCompletionFeature.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_completion/presentation/StepCompletionFeature.kt @@ -13,6 +13,7 @@ interface StepCompletionFeature { currentStep = step, startPracticingButtonAction = when (stepRoute) { is StepRoute.Learn.TheoryOpenedFromPractice, + is StepRoute.Learn.TheoryOpenedFromSearch, is StepRoute.Repeat.Theory -> StartPracticingButtonAction.NavigateToBack is StepRoute.Learn.Step, diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/presentation/StepQuizReducer.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/presentation/StepQuizReducer.kt index 11f66bf38c..6d840e5186 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/presentation/StepQuizReducer.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/presentation/StepQuizReducer.kt @@ -403,6 +403,7 @@ class StepQuizReducer( is StepRoute.Learn.Step -> StepRoute.Learn.TheoryOpenedFromPractice(stepId = topicTheoryId) is StepRoute.Learn.TheoryOpenedFromPractice, + is StepRoute.Learn.TheoryOpenedFromSearch, is StepRoute.LearnDaily, is StepRoute.Repeat.Theory, is StepRoute.StageImplement -> diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/presentation/StepQuizResolver.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/presentation/StepQuizResolver.kt index d35003ef99..af5fab57a6 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/presentation/StepQuizResolver.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/step_quiz/presentation/StepQuizResolver.kt @@ -116,6 +116,7 @@ object StepQuizResolver { is StepRoute.LearnDaily, is StepRoute.StageImplement, is StepRoute.Learn.TheoryOpenedFromPractice, + is StepRoute.Learn.TheoryOpenedFromSearch, is StepRoute.Repeat.Theory -> { false } From 641f30c1d5faacad28e78c6cc2af45787d2953f9 Mon Sep 17 00:00:00 2001 From: Ivan Magda Date: Fri, 8 Dec 2023 19:16:17 +0700 Subject: [PATCH 08/24] Handle retry search --- ...ickedRetrySearchHyperskillAnalyticEvent.kt | 29 +++++++++++++++++++ .../app/search/presentation/SearchFeature.kt | 2 ++ .../app/search/presentation/SearchReducer.kt | 13 +++++++++ 3 files changed, 44 insertions(+) create mode 100644 shared/src/commonMain/kotlin/org/hyperskill/app/search/domain/analytic/SearchClickedRetrySearchHyperskillAnalyticEvent.kt diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search/domain/analytic/SearchClickedRetrySearchHyperskillAnalyticEvent.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search/domain/analytic/SearchClickedRetrySearchHyperskillAnalyticEvent.kt new file mode 100644 index 0000000000..6f3a1ae7b8 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search/domain/analytic/SearchClickedRetrySearchHyperskillAnalyticEvent.kt @@ -0,0 +1,29 @@ +package org.hyperskill.app.search.domain.analytic + +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticAction +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticEvent +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticPart +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticRoute +import org.hyperskill.app.analytic.domain.model.hyperskill.HyperskillAnalyticTarget + +/** + * Represents a click analytic event of the error state placeholder retry button. + * + * JSON payload: + * ``` + * { + * "route": "/search", + * "action": "click", + * "part": "main", + * "target": "retry" + * } + * ``` + * + * @see HyperskillAnalyticEvent + */ +object SearchClickedRetrySearchHyperskillAnalyticEvent : HyperskillAnalyticEvent( + HyperskillAnalyticRoute.Search(), + HyperskillAnalyticAction.CLICK, + HyperskillAnalyticPart.MAIN, + HyperskillAnalyticTarget.RETRY +) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchFeature.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchFeature.kt index 073a3c8193..9e69e9429b 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchFeature.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchFeature.kt @@ -47,6 +47,8 @@ object SearchFeature { data class QueryChanged(val query: String) : Message object SearchClicked : Message + object RetrySearchClicked : Message + data class SearchResultsItemClicked(val id: Long) : Message object ViewedEventMessage : Message diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchReducer.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchReducer.kt index c743290a7a..e5ead29a76 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchReducer.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchReducer.kt @@ -1,6 +1,7 @@ package org.hyperskill.app.search.presentation import org.hyperskill.app.search.domain.analytic.SearchClickedItemHyperskillAnalyticEvent +import org.hyperskill.app.search.domain.analytic.SearchClickedRetrySearchHyperskillAnalyticEvent import org.hyperskill.app.search.domain.analytic.SearchClickedSearchHyperskillAnalyticEvent import org.hyperskill.app.search.domain.analytic.SearchViewedHyperskillAnalyticEvent import org.hyperskill.app.search.presentation.SearchFeature.Action @@ -22,6 +23,18 @@ internal class SearchReducer : StateReducer { Message.SearchClicked -> { handleSearchClickedMessage(state) } + Message.RetrySearchClicked -> { + if (state.searchResultsState == SearchFeature.SearchResultsState.Error) { + state.copy( + searchResultsState = SearchFeature.SearchResultsState.Loading + ) to setOf( + InternalAction.PerformSearch(state.query), + InternalAction.LogAnalyticEvent(SearchClickedRetrySearchHyperskillAnalyticEvent) + ) + } else { + null + } + } InternalMessage.PerformSearchError -> { if (state.searchResultsState == SearchFeature.SearchResultsState.Loading) { state.copy( From 1379c83af086f1718c38eba418fffaaca2a069e9 Mon Sep 17 00:00:00 2001 From: Ivan Magda Date: Fri, 8 Dec 2023 19:17:52 +0700 Subject: [PATCH 09/24] Reformat code --- .../org/hyperskill/app/search/presentation/SearchReducer.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchReducer.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchReducer.kt index e5ead29a76..e0e2b709a1 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchReducer.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchReducer.kt @@ -110,7 +110,9 @@ internal class SearchReducer : StateReducer { return state to buildSet { if (targetTopic.theoryId != null) { add( - Action.ViewAction.OpenStepScreen(StepRoute.Learn.TheoryOpenedFromSearch(targetTopic.theoryId)) + Action.ViewAction.OpenStepScreen( + StepRoute.Learn.TheoryOpenedFromSearch(targetTopic.theoryId) + ) ) } add( From cd19f779b5dad694c74a9d376ff73fa5ddb0c3ed Mon Sep 17 00:00:00 2001 From: Ivan Magda Date: Fri, 8 Dec 2023 19:20:05 +0700 Subject: [PATCH 10/24] Add empty view state --- .../app/search/presentation/SearchFeature.kt | 1 + .../view/mapper/SearchViewStateMapper.kt | 23 +++++++++++-------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchFeature.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchFeature.kt index 9e69e9429b..bc08428ce5 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchFeature.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchFeature.kt @@ -29,6 +29,7 @@ object SearchFeature { object Editing : SearchResultsViewState object Loading : SearchResultsViewState object Error : SearchResultsViewState + object Empty : SearchResultsViewState data class Content(val searchResults: List) : SearchResultsViewState { data class Item( override val id: Long, diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search/view/mapper/SearchViewStateMapper.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search/view/mapper/SearchViewStateMapper.kt index 74675e4d55..b3b27994a0 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/search/view/mapper/SearchViewStateMapper.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search/view/mapper/SearchViewStateMapper.kt @@ -24,14 +24,19 @@ internal object SearchViewStateMapper { SearchFeature.SearchResultsState.Editing -> SearchFeature.SearchResultsViewState.Editing SearchFeature.SearchResultsState.Loading -> SearchFeature.SearchResultsViewState.Loading SearchFeature.SearchResultsState.Error -> SearchFeature.SearchResultsViewState.Error - is SearchFeature.SearchResultsState.Content -> - SearchFeature.SearchResultsViewState.Content( - searchResults = state.topics.map { topic -> - SearchFeature.SearchResultsViewState.Content.Item( - id = topic.id, - title = topic.title - ) - } - ) + is SearchFeature.SearchResultsState.Content -> { + if (state.topics.isEmpty()) { + SearchFeature.SearchResultsViewState.Empty + } else { + SearchFeature.SearchResultsViewState.Content( + searchResults = state.topics.map { topic -> + SearchFeature.SearchResultsViewState.Content.Item( + id = topic.id, + title = topic.title + ) + } + ) + } + } } } \ No newline at end of file From 04c4dca0751507e92bf9f2be4ce94bc6ab2b2df5 Mon Sep 17 00:00:00 2001 From: Ivan Magda Date: Fri, 8 Dec 2023 19:24:41 +0700 Subject: [PATCH 11/24] Add string resources --- shared/src/commonMain/resources/MR/base/strings.xml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/shared/src/commonMain/resources/MR/base/strings.xml b/shared/src/commonMain/resources/MR/base/strings.xml index 873bb79294..a71dca4168 100644 --- a/shared/src/commonMain/resources/MR/base/strings.xml +++ b/shared/src/commonMain/resources/MR/base/strings.xml @@ -530,6 +530,12 @@ Oops! We were unable to load the challenge. Reload + + Search + Find topic + Nothing found. Try changing your search query. + Oops! We were unable to perform the search. + Project Mastery Topic Mastery From 8115096f5463a7191b6757c4119a8682b7828974 Mon Sep 17 00:00:00 2001 From: Ivan Magda Date: Fri, 8 Dec 2023 19:31:51 +0700 Subject: [PATCH 12/24] Fix SearchReducer.handleSearchClickedMessage --- .../org/hyperskill/app/search/presentation/SearchReducer.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchReducer.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchReducer.kt index e0e2b709a1..5fe756af1a 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchReducer.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchReducer.kt @@ -94,7 +94,9 @@ internal class SearchReducer : StateReducer { return when (state.searchResultsState) { SearchFeature.SearchResultsState.Editing, SearchFeature.SearchResultsState.Error -> - state to (analyticActions + InternalAction.PerformSearch(state.query)) + state.copy( + searchResultsState = SearchFeature.SearchResultsState.Loading + ) to (analyticActions + InternalAction.PerformSearch(state.query)) SearchFeature.SearchResultsState.Loading, is SearchFeature.SearchResultsState.Content -> state to analyticActions From e2ed8c88f50905eafe5423e23ba8b3fb391adf3a Mon Sep 17 00:00:00 2001 From: Ivan Magda Date: Mon, 11 Dec 2023 11:02:44 +0700 Subject: [PATCH 13/24] Update SearchResult.targetType --- .../search_results/domain/model/SearchResult.kt | 7 ++----- .../domain/model/SearchResultTargetType.kt | 14 +++++++------- .../SearchResultsResponseDeserializationTest.kt | 15 +++++++++++++-- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search_results/domain/model/SearchResult.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search_results/domain/model/SearchResult.kt index cf1fb01338..5f78b4c4a5 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/search_results/domain/model/SearchResult.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search_results/domain/model/SearchResult.kt @@ -6,10 +6,7 @@ import kotlinx.serialization.Serializable @Serializable data class SearchResult( @SerialName("target_type") - internal val targetTypeValue: String, + val targetType: SearchResultTargetType = SearchResultTargetType.UNKNOWN, @SerialName("target_id") val targetId: Long -) { - val targetType: SearchResultTargetType? - get() = SearchResultTargetType.getByValue(targetTypeValue) -} \ No newline at end of file +) \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search_results/domain/model/SearchResultTargetType.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search_results/domain/model/SearchResultTargetType.kt index fc47f11d8c..9b4bc539eb 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/search_results/domain/model/SearchResultTargetType.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search_results/domain/model/SearchResultTargetType.kt @@ -1,12 +1,12 @@ package org.hyperskill.app.search_results.domain.model -enum class SearchResultTargetType(val value: String) { - TOPIC("topic"); +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable - companion object { - private val VALUES: Array = values() +@Serializable +enum class SearchResultTargetType { + @SerialName("topic") + TOPIC, - fun getByValue(value: String): SearchResultTargetType? = - VALUES.firstOrNull { it.value == value } - } + UNKNOWN } \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/org/hyperskill/search_results/remote/model/SearchResultsResponseDeserializationTest.kt b/shared/src/commonTest/kotlin/org/hyperskill/search_results/remote/model/SearchResultsResponseDeserializationTest.kt index d579fb1a02..33fe041057 100644 --- a/shared/src/commonTest/kotlin/org/hyperskill/search_results/remote/model/SearchResultsResponseDeserializationTest.kt +++ b/shared/src/commonTest/kotlin/org/hyperskill/search_results/remote/model/SearchResultsResponseDeserializationTest.kt @@ -5,6 +5,7 @@ import kotlin.test.assertEquals import org.hyperskill.app.core.remote.Meta import org.hyperskill.app.network.injection.NetworkModule import org.hyperskill.app.search_results.domain.model.SearchResult +import org.hyperskill.app.search_results.domain.model.SearchResultTargetType import org.hyperskill.app.search_results.remote.model.SearchResultsResponse class SearchResultsResponseDeserializationTest { @@ -28,6 +29,12 @@ class SearchResultsResponseDeserializationTest { "target_id": 488, "position": 2, "score": 77.70255 + }, + { + "target_type": "project", + "target_id": 353, + "position": 54, + "score": 39.685966 } ] } @@ -45,12 +52,16 @@ class SearchResultsResponseDeserializationTest { ), searchResults = listOf( SearchResult( - targetTypeValue = "topic", + targetType = SearchResultTargetType.TOPIC, targetId = 22 ), SearchResult( - targetTypeValue = "topic", + targetType = SearchResultTargetType.TOPIC, targetId = 488 + ), + SearchResult( + targetType = SearchResultTargetType.UNKNOWN, + targetId = 353 ) ) ) From 040b7817be810baa8f6301cc2b789ff5b4e2d198 Mon Sep 17 00:00:00 2001 From: Ivan Magda Date: Mon, 11 Dec 2023 16:31:16 +0700 Subject: [PATCH 14/24] Add OpenStepScreenFailed view action --- .../app/search/injection/SearchComponentImpl.kt | 1 + .../app/search/injection/SearchFeatureBuilder.kt | 6 +++++- .../app/search/presentation/SearchFeature.kt | 1 + .../app/search/presentation/SearchReducer.kt | 12 +++++++++++- shared/src/commonMain/resources/MR/base/strings.xml | 3 +++ 5 files changed, 21 insertions(+), 2 deletions(-) diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search/injection/SearchComponentImpl.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search/injection/SearchComponentImpl.kt index 245dfc480b..0c05be017f 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/search/injection/SearchComponentImpl.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search/injection/SearchComponentImpl.kt @@ -17,6 +17,7 @@ internal class SearchComponentImpl(private val appGraph: AppGraph) : SearchCompo searchInteractor = searchInteractor, sentryInteractor = appGraph.sentryComponent.sentryInteractor, analyticInteractor = appGraph.analyticComponent.analyticInteractor, + resourceProvider = appGraph.commonComponent.resourceProvider, logger = appGraph.loggerComponent.logger, buildVariant = appGraph.commonComponent.buildKonfig.buildVariant, ) diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search/injection/SearchFeatureBuilder.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search/injection/SearchFeatureBuilder.kt index f4c8bd2d6b..76e5cef186 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/search/injection/SearchFeatureBuilder.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search/injection/SearchFeatureBuilder.kt @@ -5,6 +5,7 @@ import org.hyperskill.app.analytic.domain.interactor.AnalyticInteractor import org.hyperskill.app.core.domain.BuildVariant import org.hyperskill.app.core.presentation.ActionDispatcherOptions import org.hyperskill.app.core.presentation.transformState +import org.hyperskill.app.core.view.mapper.ResourceProvider import org.hyperskill.app.logging.presentation.wrapWithLogger import org.hyperskill.app.search.domain.interactor.SearchInteractor import org.hyperskill.app.search.presentation.SearchActionDispatcher @@ -23,10 +24,13 @@ internal object SearchFeatureBuilder { searchInteractor: SearchInteractor, sentryInteractor: SentryInteractor, analyticInteractor: AnalyticInteractor, + resourceProvider: ResourceProvider, logger: Logger, buildVariant: BuildVariant, ): Feature { - val searchReducer = SearchReducer().wrapWithLogger(buildVariant, logger, LOG_TAG) + val searchReducer = SearchReducer( + resourceProvider = resourceProvider + ).wrapWithLogger(buildVariant, logger, LOG_TAG) val searchActionDispatcher = SearchActionDispatcher( config = ActionDispatcherOptions(), diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchFeature.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchFeature.kt index bc08428ce5..c41163edda 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchFeature.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchFeature.kt @@ -63,6 +63,7 @@ object SearchFeature { sealed interface Action { sealed interface ViewAction : Action { data class OpenStepScreen(val stepRoute: StepRoute) : ViewAction + data class OpenStepScreenFailed(val message: String) : ViewAction } } diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchReducer.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchReducer.kt index 5fe756af1a..748393f023 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchReducer.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchReducer.kt @@ -1,5 +1,7 @@ package org.hyperskill.app.search.presentation +import org.hyperskill.app.SharedResources +import org.hyperskill.app.core.view.mapper.ResourceProvider import org.hyperskill.app.search.domain.analytic.SearchClickedItemHyperskillAnalyticEvent import org.hyperskill.app.search.domain.analytic.SearchClickedRetrySearchHyperskillAnalyticEvent import org.hyperskill.app.search.domain.analytic.SearchClickedSearchHyperskillAnalyticEvent @@ -14,7 +16,9 @@ import ru.nobird.app.presentation.redux.reducer.StateReducer private typealias SearchReducerResult = Pair> -internal class SearchReducer : StateReducer { +internal class SearchReducer( + private val resourceProvider: ResourceProvider +) : StateReducer { override fun reduce(state: State, message: Message): SearchReducerResult = when (message) { is Message.QueryChanged -> { @@ -116,6 +120,12 @@ internal class SearchReducer : StateReducer { StepRoute.Learn.TheoryOpenedFromSearch(targetTopic.theoryId) ) ) + } else { + add( + Action.ViewAction.OpenStepScreenFailed( + resourceProvider.getString(SharedResources.strings.search_open_step_screen_error_message) + ) + ) } add( InternalAction.LogAnalyticEvent( diff --git a/shared/src/commonMain/resources/MR/base/strings.xml b/shared/src/commonMain/resources/MR/base/strings.xml index a71dca4168..d33d6a9d14 100644 --- a/shared/src/commonMain/resources/MR/base/strings.xml +++ b/shared/src/commonMain/resources/MR/base/strings.xml @@ -533,6 +533,9 @@ Search Find topic + Sorry, we were unable to open the search result: please try + again later. + Nothing found. Try changing your search query. Oops! We were unable to perform the search. From 385b9de2741f8f371d32a8f3d4004c159583e28a Mon Sep 17 00:00:00 2001 From: Ivan Magda Date: Mon, 11 Dec 2023 20:33:16 +0700 Subject: [PATCH 15/24] Perform search requests while typing --- .../presentation/SearchActionDispatcher.kt | 47 ++++++++++++++----- .../app/search/presentation/SearchFeature.kt | 12 ++--- .../app/search/presentation/SearchReducer.kt | 33 ++++++------- .../view/mapper/SearchViewStateMapper.kt | 17 ++----- 4 files changed, 61 insertions(+), 48 deletions(-) diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchActionDispatcher.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchActionDispatcher.kt index 9aade4de8c..c3a429712d 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchActionDispatcher.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchActionDispatcher.kt @@ -1,5 +1,10 @@ package org.hyperskill.app.search.presentation +import kotlin.time.DurationUnit +import kotlin.time.toDuration +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import org.hyperskill.app.analytic.domain.interactor.AnalyticInteractor import org.hyperskill.app.core.presentation.ActionDispatcherOptions import org.hyperskill.app.search.domain.interactor.SearchInteractor @@ -18,11 +23,20 @@ internal class SearchActionDispatcher( private val sentryInteractor: SentryInteractor, private val analyticInteractor: AnalyticInteractor ) : CoroutineActionDispatcher(config.createConfig()) { + private var searchJob: Job? = null + + companion object { + private val SEARCH_DELAY = 500.toDuration(DurationUnit.MILLISECONDS) + } + override suspend fun doSuspendableAction(action: Action) { when (action) { is InternalAction.PerformSearch -> { handlePerformSearchAction(action, ::onNewMessage) } + InternalAction.CancelSearch -> { + searchJob?.cancel() + } is InternalAction.LogAnalyticEvent -> { analyticInteractor.logEvent(action.analyticEvent) } @@ -36,16 +50,27 @@ internal class SearchActionDispatcher( action: InternalAction.PerformSearch, onNewMessage: (Message) -> Unit ) { - sentryInteractor.withTransaction( - HyperskillSentryTransactionBuilder.buildSearchFeaturePerformSearch(), - onError = { InternalMessage.PerformSearchError } - ) { - val topics = searchInteractor - .searchTopics(query = action.query) - .getOrThrow() - InternalMessage.PerformSearchSuccess( - topics = topics - ) - }.let(onNewMessage) + suspend fun search() { + sentryInteractor.withTransaction( + HyperskillSentryTransactionBuilder.buildSearchFeaturePerformSearch(), + onError = { InternalMessage.PerformSearchError } + ) { + val topics = searchInteractor + .searchTopics(query = action.query) + .getOrThrow() + InternalMessage.PerformSearchSuccess(topics) + }.let(onNewMessage) + } + + searchJob?.cancel() + + if (action.withDelay) { + searchJob = actionScope.launch { + delay(SEARCH_DELAY) + search() + } + } else { + search() + } } } \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchFeature.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchFeature.kt index c41163edda..1b9aa43204 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchFeature.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchFeature.kt @@ -12,7 +12,7 @@ object SearchFeature { ) internal sealed interface SearchResultsState { - object Editing : SearchResultsState + object Idle : SearchResultsState object Loading : SearchResultsState object Error : SearchResultsState data class Content(val topics: List) : SearchResultsState @@ -21,12 +21,11 @@ object SearchFeature { data class ViewState( val query: String, val searchResultsViewState: SearchResultsViewState, - val isSearchButtonEnabled: Boolean, - val isUserInteractionEnabled: Boolean + val isSearchButtonEnabled: Boolean ) sealed interface SearchResultsViewState { - object Editing : SearchResultsViewState + object Idle : SearchResultsViewState object Loading : SearchResultsViewState object Error : SearchResultsViewState object Empty : SearchResultsViewState @@ -41,7 +40,7 @@ object SearchFeature { internal fun initialState(): State = State( query = "", - searchResultsState = SearchResultsState.Editing + searchResultsState = SearchResultsState.Idle ) sealed interface Message { @@ -68,7 +67,8 @@ object SearchFeature { } internal sealed interface InternalAction : Action { - data class PerformSearch(val query: String) : InternalAction + data class PerformSearch(val query: String, val withDelay: Boolean) : InternalAction + object CancelSearch : InternalAction data class LogAnalyticEvent(val analyticEvent: AnalyticEvent) : InternalAction } diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchReducer.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchReducer.kt index 748393f023..dc4d5dac8f 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchReducer.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchReducer.kt @@ -32,7 +32,7 @@ internal class SearchReducer( state.copy( searchResultsState = SearchFeature.SearchResultsState.Loading ) to setOf( - InternalAction.PerformSearch(state.query), + InternalAction.PerformSearch(query = state.query, withDelay = false), InternalAction.LogAnalyticEvent(SearchClickedRetrySearchHyperskillAnalyticEvent) ) } else { @@ -68,24 +68,19 @@ internal class SearchReducer( private fun handleQueryChangedMessage( state: State, message: Message.QueryChanged - ): SearchReducerResult? { - if (state.searchResultsState == SearchFeature.SearchResultsState.Loading) { - return null - } - - val newSearchResultsState = when (state.searchResultsState) { - SearchFeature.SearchResultsState.Editing -> SearchFeature.SearchResultsState.Editing - SearchFeature.SearchResultsState.Loading -> SearchFeature.SearchResultsState.Loading - SearchFeature.SearchResultsState.Error -> SearchFeature.SearchResultsState.Editing - is SearchFeature.SearchResultsState.Content -> SearchFeature.SearchResultsState.Editing + ): SearchReducerResult = + if (message.query.isBlank()) { + state.copy( + query = message.query, + searchResultsState = SearchFeature.SearchResultsState.Idle + ) to setOf(InternalAction.CancelSearch) + } else { + state.copy( + query = message.query, + searchResultsState = SearchFeature.SearchResultsState.Loading + ) to setOf(InternalAction.PerformSearch(query = message.query, withDelay = true)) } - return state.copy( - query = message.query, - searchResultsState = newSearchResultsState - ) to emptySet() - } - private fun handleSearchClickedMessage(state: State): SearchReducerResult { val analyticActions = setOf( InternalAction.LogAnalyticEvent(SearchClickedSearchHyperskillAnalyticEvent(state.query)) @@ -96,11 +91,11 @@ internal class SearchReducer( } return when (state.searchResultsState) { - SearchFeature.SearchResultsState.Editing, SearchFeature.SearchResultsState.Error -> state.copy( searchResultsState = SearchFeature.SearchResultsState.Loading - ) to (analyticActions + InternalAction.PerformSearch(state.query)) + ) to (analyticActions + InternalAction.PerformSearch(query = state.query, withDelay = false)) + SearchFeature.SearchResultsState.Idle, SearchFeature.SearchResultsState.Loading, is SearchFeature.SearchResultsState.Content -> state to analyticActions diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search/view/mapper/SearchViewStateMapper.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search/view/mapper/SearchViewStateMapper.kt index b3b27994a0..5a34e6bf22 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/search/view/mapper/SearchViewStateMapper.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search/view/mapper/SearchViewStateMapper.kt @@ -3,25 +3,18 @@ package org.hyperskill.app.search.view.mapper import org.hyperskill.app.search.presentation.SearchFeature internal object SearchViewStateMapper { - fun map(state: SearchFeature.State): SearchFeature.ViewState { - val searchResultsViewState = mapSearchResultsState(state.searchResultsState) - - val isSearchButtonEnabled = state.query.isNotBlank() && - searchResultsViewState != SearchFeature.SearchResultsViewState.Loading - - return SearchFeature.ViewState( + fun map(state: SearchFeature.State): SearchFeature.ViewState = + SearchFeature.ViewState( query = state.query, - searchResultsViewState = searchResultsViewState, - isSearchButtonEnabled = isSearchButtonEnabled, - isUserInteractionEnabled = searchResultsViewState != SearchFeature.SearchResultsViewState.Loading + searchResultsViewState = mapSearchResultsState(state.searchResultsState), + isSearchButtonEnabled = state.query.isNotBlank() ) - } private fun mapSearchResultsState( state: SearchFeature.SearchResultsState ): SearchFeature.SearchResultsViewState = when (state) { - SearchFeature.SearchResultsState.Editing -> SearchFeature.SearchResultsViewState.Editing + SearchFeature.SearchResultsState.Idle -> SearchFeature.SearchResultsViewState.Idle SearchFeature.SearchResultsState.Loading -> SearchFeature.SearchResultsViewState.Loading SearchFeature.SearchResultsState.Error -> SearchFeature.SearchResultsViewState.Error is SearchFeature.SearchResultsState.Content -> { From 6a2c66a8375bc9cf32bbd21e96aab14aa3478264 Mon Sep 17 00:00:00 2001 From: Ivan Magda Date: Tue, 12 Dec 2023 13:41:15 +0700 Subject: [PATCH 16/24] Add search bar button item --- .../Views/GamificationToolbarContent.swift | 19 +++++++++++++++++++ .../Sources/Modules/Home/HomeViewModel.swift | 8 ++++++++ .../Sources/Modules/Home/Views/HomeView.swift | 3 ++- .../Leaderboard/LeaderboardViewModel.swift | 8 ++++++++ .../Leaderboard/Views/LeaderboardView.swift | 3 ++- .../StudyPlan/StudyPlanViewModel.swift | 8 ++++++++ .../StudyPlan/Views/StudyPlanView.swift | 3 ++- 7 files changed, 49 insertions(+), 3 deletions(-) diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/GamificationToolbar/Views/GamificationToolbarContent.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/GamificationToolbar/Views/GamificationToolbarContent.swift index 56d99edce0..2782d87322 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,7 @@ struct GamificationToolbarContent: ToolbarContent { HStack { SkeletonRoundedView(appearance: .init(size: appearance.skeletonSize)) SkeletonRoundedView(appearance: .init(size: appearance.skeletonSize)) + SkeletonRoundedView(appearance: .init(size: appearance.skeletonSize)) } case .error: HStack {} @@ -41,6 +47,19 @@ struct GamificationToolbarContent: ToolbarContent { isCompletedToday: data.streak.isCompleted, onTap: onStreakTap ) + + 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 Date: Tue, 12 Dec 2023 15:17:45 +0700 Subject: [PATCH 17/24] Add search module --- .../project.pbxproj | 48 ++++++++ .../NavigationLink/NavigationLink+Empty.swift | 8 ++ .../Sources/Models/Constants/Strings.swift | 14 +++ ...GamificationToolbarViewActionHandler.swift | 5 +- .../Views/GamificationToolbarContent.swift | 30 ++--- .../Modules/Search/SearchAssembly.swift | 26 +++++ .../Modules/Search/SearchViewModel.swift | 41 +++++++ .../Views/SearchPlaceholderEmptyView.swift | 22 ++++ .../SearchPlaceholderSuggestionsView.swift | 22 ++++ .../Modules/Search/Views/SearchView.swift | 107 ++++++++++++++++++ .../commonMain/resources/MR/base/strings.xml | 4 + 11 files changed, 313 insertions(+), 14 deletions(-) create mode 100644 iosHyperskillApp/iosHyperskillApp/Sources/Extensions/SwiftUI/NavigationLink/NavigationLink+Empty.swift create mode 100644 iosHyperskillApp/iosHyperskillApp/Sources/Modules/Search/SearchAssembly.swift create mode 100644 iosHyperskillApp/iosHyperskillApp/Sources/Modules/Search/SearchViewModel.swift create mode 100644 iosHyperskillApp/iosHyperskillApp/Sources/Modules/Search/Views/SearchPlaceholderEmptyView.swift create mode 100644 iosHyperskillApp/iosHyperskillApp/Sources/Modules/Search/Views/SearchPlaceholderSuggestionsView.swift create mode 100644 iosHyperskillApp/iosHyperskillApp/Sources/Modules/Search/Views/SearchView.swift diff --git a/iosHyperskillApp/iosHyperskillApp.xcodeproj/project.pbxproj b/iosHyperskillApp/iosHyperskillApp.xcodeproj/project.pbxproj index 89f78b8cad..16f493779e 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 */; }; @@ -491,6 +494,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 +626,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 +674,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 +857,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 = ""; }; @@ -1162,6 +1172,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 +1641,7 @@ 2C963BC82812D3410036DD53 /* ProfileSettings */, 69443CBBFA46C4A121EA173F /* ProgressScreen */, 2C5CA2452A203C4500DBF2F9 /* ProjectSelection */, + 3C00014807122833363E303F /* Search */, 2C9E5E8229B211DD003AEC16 /* StageImplement */, 2CAE8CEE280525A100E6C83D /* Step */, 2C41127428376DE3004948A3 /* StepQuiz */, @@ -2185,6 +2198,24 @@ path = Input; sourceTree = ""; }; + 2C58379F2B28412C0096B89B /* Views */ = { + isa = PBXGroup; + children = ( + 2C5837A02B28413C0096B89B /* SearchPlaceholderEmptyView.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 +2372,7 @@ 2C725B5C28090CE500A49043 /* SwiftUI */ = { isa = PBXGroup; children = ( + 2C5837A42B284E4C0096B89B /* NavigationLink */, 2C323750283808300062CAF6 /* View */, ); path = SwiftUI; @@ -3511,6 +3543,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 +4349,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 +4709,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 */, @@ -4792,6 +4836,7 @@ 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 +4850,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 2782d87322..257a14c8e7 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/GamificationToolbar/Views/GamificationToolbarContent.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/GamificationToolbar/Views/GamificationToolbarContent.swift @@ -27,7 +27,9 @@ struct GamificationToolbarContent: ToolbarContent { HStack { SkeletonRoundedView(appearance: .init(size: appearance.skeletonSize)) 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 {} @@ -48,18 +50,20 @@ struct GamificationToolbarContent: ToolbarContent { onTap: onStreakTap ) - 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)) - } - ) + 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/Search/SearchAssembly.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Search/SearchAssembly.swift new file mode 100644 index 0000000000..0ff4a2ac28 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Search/SearchAssembly.swift @@ -0,0 +1,26 @@ +import shared +import SwiftUI + +@available(iOS 15.0, *) +final class SearchAssembly: UIKitAssembly { + func makeModule() -> UIViewController { + let searchComponent = AppGraphBridge.sharedAppGraph.buildSearchComponent() + + let searchViewModel = SearchViewModel( + feature: searchComponent.searchFeature + ) + + let searchView = SearchView( + viewModel: searchViewModel + ) + + let hostingController = StyledHostingController( + rootView: searchView + ) + hostingController.hidesBottomBarWhenPushed = true + hostingController.navigationItem.largeTitleDisplayMode = .always + hostingController.title = Strings.Search.title + + 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..ef511860c9 --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Search/SearchViewModel.swift @@ -0,0 +1,41 @@ +import Foundation +import shared + +final class SearchViewModel: FeatureViewModel< + SearchFeature.ViewState, + SearchFeatureMessage, + SearchFeatureActionViewAction +> { + var searchResultsViewStateKs: SearchFeatureSearchResultsViewStateKs { .init(state.searchResultsViewState) } + + override func shouldNotifyStateDidChange( + oldState: SearchFeature.ViewState, + newState: SearchFeature.ViewState + ) -> Bool { + !oldState.isEqual(newState) + } + + 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/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..26cc84120b --- /dev/null +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Search/Views/SearchView.swift @@ -0,0 +1,107 @@ +import shared +import SwiftUI + +@available(iOS 15.0, *) +extension SearchView { + struct Appearance { + let backgroundColor = Color.background + } +} + +@available(iOS 15.0, *) +struct SearchView: View { + private(set) var appearance = Appearance() + + @StateObject var viewModel: SearchViewModel + + 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 + 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() + } + 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: + break + case .openStepScreenFailed(let openStepScreenFailedViewAction): + ProgressHUD.showError(status: openStepScreenFailedViewAction.message) + } + } +} + +// MARK: - SearchView (Preview) - + +@available(iOS 17, *) +#Preview { + SearchAssembly().makeModule() +} 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 From 7be1289253f7d290a6cd72f368cc58f4c8013aac Mon Sep 17 00:00:00 2001 From: Ivan Magda Date: Tue, 12 Dec 2023 15:20:05 +0700 Subject: [PATCH 18/24] Handle search query the same --- .../org/hyperskill/app/search/presentation/SearchReducer.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchReducer.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchReducer.kt index dc4d5dac8f..87bb53ebb1 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchReducer.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchReducer.kt @@ -69,7 +69,9 @@ internal class SearchReducer( state: State, message: Message.QueryChanged ): SearchReducerResult = - if (message.query.isBlank()) { + if (message.query == state.query) { + state to emptySet() + } else if (message.query.isBlank()) { state.copy( query = message.query, searchResultsState = SearchFeature.SearchResultsState.Idle From 38919e7fb6495ae7415330c6a99acdb015d765b7 Mon Sep 17 00:00:00 2001 From: Ivan Magda Date: Tue, 12 Dec 2023 15:20:48 +0700 Subject: [PATCH 19/24] Delete isSearchButtonEnabled --- .../org/hyperskill/app/search/presentation/SearchFeature.kt | 3 +-- .../hyperskill/app/search/view/mapper/SearchViewStateMapper.kt | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchFeature.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchFeature.kt index 1b9aa43204..6c50cbd65d 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchFeature.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchFeature.kt @@ -20,8 +20,7 @@ object SearchFeature { data class ViewState( val query: String, - val searchResultsViewState: SearchResultsViewState, - val isSearchButtonEnabled: Boolean + val searchResultsViewState: SearchResultsViewState ) sealed interface SearchResultsViewState { diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search/view/mapper/SearchViewStateMapper.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search/view/mapper/SearchViewStateMapper.kt index 5a34e6bf22..1637cda0e5 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/search/view/mapper/SearchViewStateMapper.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search/view/mapper/SearchViewStateMapper.kt @@ -6,8 +6,7 @@ internal object SearchViewStateMapper { fun map(state: SearchFeature.State): SearchFeature.ViewState = SearchFeature.ViewState( query = state.query, - searchResultsViewState = mapSearchResultsState(state.searchResultsState), - isSearchButtonEnabled = state.query.isNotBlank() + searchResultsViewState = mapSearchResultsState(state.searchResultsState) ) private fun mapSearchResultsState( From ef04c7c87afd5e71f21260689f0218e95097dee5 Mon Sep 17 00:00:00 2001 From: Ivan Magda Date: Tue, 12 Dec 2023 15:37:16 +0700 Subject: [PATCH 20/24] Handle openStepScreen view action --- .../Sources/Modules/Search/SearchAssembly.swift | 7 ++++++- .../Sources/Modules/Search/SearchViewModel.swift | 11 +++++++++++ .../Sources/Modules/Search/Views/SearchView.swift | 11 +++++++++-- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Search/SearchAssembly.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Search/SearchAssembly.swift index 0ff4a2ac28..d26a51b216 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Search/SearchAssembly.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Search/SearchAssembly.swift @@ -10,8 +10,11 @@ final class SearchAssembly: UIKitAssembly { feature: searchComponent.searchFeature ) + let stackRouter = StackRouter() + let searchView = SearchView( - viewModel: searchViewModel + viewModel: searchViewModel, + stackRouter: stackRouter ) let hostingController = StyledHostingController( @@ -21,6 +24,8 @@ final class SearchAssembly: UIKitAssembly { 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 index ef511860c9..c1f376b634 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Search/SearchViewModel.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Search/SearchViewModel.swift @@ -8,6 +8,8 @@ final class SearchViewModel: FeatureViewModel< > { var searchResultsViewStateKs: SearchFeatureSearchResultsViewStateKs { .init(state.searchResultsViewState) } + private var isFirstTimeBecomeFirstResponder = true + override func shouldNotifyStateDidChange( oldState: SearchFeature.ViewState, newState: SearchFeature.ViewState @@ -15,6 +17,15 @@ final class SearchViewModel: FeatureViewModel< !oldState.isEqual(newState) } + func shouldBecomeFirstResponder() -> Bool { + if isFirstTimeBecomeFirstResponder { + isFirstTimeBecomeFirstResponder = false + return true + } else { + return false + } + } + func doQueryChanged(query: String) { onNewMessage(SearchFeatureMessageQueryChanged(query: query)) } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Search/Views/SearchView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Search/Views/SearchView.swift index 26cc84120b..3751399e3a 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Search/Views/SearchView.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Search/Views/SearchView.swift @@ -14,6 +14,8 @@ struct SearchView: View { @StateObject var viewModel: SearchViewModel + var stackRouter: StackRouterProtocol + var body: some View { ZStack { UIViewControllerEventsWrapper(onViewDidAppear: viewModel.logViewedEvent) @@ -28,6 +30,10 @@ struct SearchView: View { ) .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() } @@ -91,8 +97,9 @@ private extension SearchView { _ viewAction: SearchFeatureActionViewAction ) { switch SearchFeatureActionViewActionKs(viewAction) { - case .openStepScreen: - break + case .openStepScreen(let openStepScreenViewAction): + let assembly = StepAssembly(stepRoute: openStepScreenViewAction.stepRoute) + stackRouter.pushViewController(assembly.makeModule()) case .openStepScreenFailed(let openStepScreenFailedViewAction): ProgressHUD.showError(status: openStepScreenFailedViewAction.message) } From db778c3a42db3d01cef8af95d9651ee50328de0b Mon Sep 17 00:00:00 2001 From: Ivan Magda Date: Tue, 12 Dec 2023 15:47:51 +0700 Subject: [PATCH 21/24] Add SearchPlaceholderLoadingView --- .../project.pbxproj | 4 ++++ .../Views/SearchPlaceholderLoadingView.swift | 19 +++++++++++++++++++ .../Modules/Search/Views/SearchView.swift | 11 ++--------- 3 files changed, 25 insertions(+), 9 deletions(-) create mode 100644 iosHyperskillApp/iosHyperskillApp/Sources/Modules/Search/Views/SearchPlaceholderLoadingView.swift diff --git a/iosHyperskillApp/iosHyperskillApp.xcodeproj/project.pbxproj b/iosHyperskillApp/iosHyperskillApp.xcodeproj/project.pbxproj index 16f493779e..aaeae0fe59 100644 --- a/iosHyperskillApp/iosHyperskillApp.xcodeproj/project.pbxproj +++ b/iosHyperskillApp/iosHyperskillApp.xcodeproj/project.pbxproj @@ -476,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 */; }; @@ -1148,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 = ""; }; @@ -2202,6 +2204,7 @@ isa = PBXGroup; children = ( 2C5837A02B28413C0096B89B /* SearchPlaceholderEmptyView.swift */, + 2CF5DF902B2853DA006E4ED7 /* SearchPlaceholderLoadingView.swift */, 2C5837A22B2844E20096B89B /* SearchPlaceholderSuggestionsView.swift */, 907B10B0F7D4970530A478A2 /* SearchView.swift */, ); @@ -4833,6 +4836,7 @@ 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 */, 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/SearchView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Search/Views/SearchView.swift index 3751399e3a..07228162b3 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Search/Views/SearchView.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Search/Views/SearchView.swift @@ -1,17 +1,8 @@ import shared import SwiftUI -@available(iOS 15.0, *) -extension SearchView { - struct Appearance { - let backgroundColor = Color.background - } -} - @available(iOS 15.0, *) struct SearchView: View { - private(set) var appearance = Appearance() - @StateObject var viewModel: SearchViewModel var stackRouter: StackRouterProtocol @@ -56,6 +47,8 @@ struct SearchView: View { case .idle, .loading: if viewModel.state.query.isEmpty { SearchPlaceholderSuggestionsView() + } else { + SearchPlaceholderLoadingView() } case .error: PlaceholderView( From fe1da15704a020129a7f85598b9af66290ab261a Mon Sep 17 00:00:00 2001 From: Ivan Magda Date: Tue, 12 Dec 2023 15:52:39 +0700 Subject: [PATCH 22/24] Update ProfileStatisticsView position --- .../Profile/Views/ProfileSkeletonView.swift | 6 +++--- .../Modules/Profile/Views/ProfileView.swift | 14 +++++++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Profile/Views/ProfileSkeletonView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Profile/Views/ProfileSkeletonView.swift index c34383698b..495c95482c 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Profile/Views/ProfileSkeletonView.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Profile/Views/ProfileSkeletonView.swift @@ -13,9 +13,6 @@ struct ProfileSkeletonView: View { SkeletonRoundedView() .frame(height: 63) - SkeletonRoundedView() - .frame(height: 235) - HStack { SkeletonRoundedView() SkeletonRoundedView() @@ -23,6 +20,9 @@ struct ProfileSkeletonView: View { } .frame(height: 82) + SkeletonRoundedView() + .frame(height: 235) + SkeletonRoundedView() .frame(height: 379) } diff --git a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Profile/Views/ProfileView.swift b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Profile/Views/ProfileView.swift index 8719758500..a89a20ff8b 100644 --- a/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Profile/Views/ProfileView.swift +++ b/iosHyperskillApp/iosHyperskillApp/Sources/Modules/Profile/Views/ProfileView.swift @@ -125,13 +125,6 @@ struct ProfileView: View { onSelectedHourTapped: viewModel.logClickedDailyStudyRemindsTimeEvent ) - ProfileBadgesGridView( - appearance: .init(cornerRadius: appearance.cornerRadius), - badgesState: viewModel.makeBadgesViewState(badgesState: data.badgesState), - onBadgeTap: viewModel.doBadgeCardTapped(badgeKind:), - onVisibilityButtonTap: viewModel.doBadgesVisibilityButtonTapped(visibilityButton:) - ) - ProfileStatisticsView( appearance: .init(cornerRadius: appearance.cornerRadius), passedProjectsCount: Int(data.profile.gamification.passedProjectsCount), @@ -139,6 +132,13 @@ struct ProfileView: View { hypercoinsBalance: Int(data.profile.gamification.hypercoinsBalance) ) + ProfileBadgesGridView( + appearance: .init(cornerRadius: appearance.cornerRadius), + badgesState: viewModel.makeBadgesViewState(badgesState: data.badgesState), + onBadgeTap: viewModel.doBadgeCardTapped(badgeKind:), + onVisibilityButtonTap: viewModel.doBadgesVisibilityButtonTapped(visibilityButton:) + ) + ProfileAboutView( appearance: .init(cornerRadius: appearance.cornerRadius), livesInText: profileViewData.livesInText, From f46e8a13c07ce07647d87032417d1458fc926256 Mon Sep 17 00:00:00 2001 From: Ivan Magda Date: Tue, 12 Dec 2023 16:43:33 +0700 Subject: [PATCH 23/24] Handle CancellationException --- .../app/search/presentation/SearchActionDispatcher.kt | 2 +- .../hyperskill/app/search/presentation/SearchFeature.kt | 2 +- .../hyperskill/app/search/presentation/SearchReducer.kt | 7 +++++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchActionDispatcher.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchActionDispatcher.kt index c3a429712d..c3d2c17329 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchActionDispatcher.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchActionDispatcher.kt @@ -53,7 +53,7 @@ internal class SearchActionDispatcher( suspend fun search() { sentryInteractor.withTransaction( HyperskillSentryTransactionBuilder.buildSearchFeaturePerformSearch(), - onError = { InternalMessage.PerformSearchError } + onError = { InternalMessage.PerformSearchError(it) } ) { val topics = searchInteractor .searchTopics(query = action.query) diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchFeature.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchFeature.kt index 6c50cbd65d..f08065ceb2 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchFeature.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchFeature.kt @@ -54,7 +54,7 @@ object SearchFeature { } internal sealed interface InternalMessage : Message { - object PerformSearchError : InternalMessage + data class PerformSearchError(val error: Throwable) : InternalMessage data class PerformSearchSuccess(val topics: List) : InternalMessage } diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchReducer.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchReducer.kt index 87bb53ebb1..5bfe3a51e3 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchReducer.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchReducer.kt @@ -1,5 +1,6 @@ package org.hyperskill.app.search.presentation +import kotlinx.coroutines.CancellationException import org.hyperskill.app.SharedResources import org.hyperskill.app.core.view.mapper.ResourceProvider import org.hyperskill.app.search.domain.analytic.SearchClickedItemHyperskillAnalyticEvent @@ -39,8 +40,10 @@ internal class SearchReducer( null } } - InternalMessage.PerformSearchError -> { - if (state.searchResultsState == SearchFeature.SearchResultsState.Loading) { + is InternalMessage.PerformSearchError -> { + if (state.searchResultsState == SearchFeature.SearchResultsState.Loading && + message.error !is CancellationException + ) { state.copy( searchResultsState = SearchFeature.SearchResultsState.Error ) to emptySet() From 11f3580cb2745c9703f03cb42fe0786f37b0afbb Mon Sep 17 00:00:00 2001 From: Ivan Magda Date: Wed, 13 Dec 2023 10:01:55 +0700 Subject: [PATCH 24/24] Update cancelSearchJob --- .../app/search/presentation/SearchActionDispatcher.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchActionDispatcher.kt b/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchActionDispatcher.kt index c3d2c17329..2c1229f07c 100644 --- a/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchActionDispatcher.kt +++ b/shared/src/commonMain/kotlin/org/hyperskill/app/search/presentation/SearchActionDispatcher.kt @@ -35,7 +35,7 @@ internal class SearchActionDispatcher( handlePerformSearchAction(action, ::onNewMessage) } InternalAction.CancelSearch -> { - searchJob?.cancel() + cancelSearchJob() } is InternalAction.LogAnalyticEvent -> { analyticInteractor.logEvent(action.analyticEvent) @@ -62,7 +62,7 @@ internal class SearchActionDispatcher( }.let(onNewMessage) } - searchJob?.cancel() + cancelSearchJob() if (action.withDelay) { searchJob = actionScope.launch { @@ -73,4 +73,9 @@ internal class SearchActionDispatcher( search() } } + + private fun cancelSearchJob() { + searchJob?.cancel() + searchJob = null + } } \ No newline at end of file