diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 31da1ae7..96f51801 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -20,8 +20,8 @@ android { } packaging { resources { - merges += "META-INF/LICENSE.md" - merges += "META-INF/LICENSE-notice.md" + excludes += "META-INF/LICENSE.md" + excludes += "META-INF/LICENSE-notice.md" } } diff --git a/app/src/main/java/com/withpeace/withpeace/navigation/NavHost.kt b/app/src/main/java/com/withpeace/withpeace/navigation/NavHost.kt index 7538d7b7..9528f929 100644 --- a/app/src/main/java/com/withpeace/withpeace/navigation/NavHost.kt +++ b/app/src/main/java/com/withpeace/withpeace/navigation/NavHost.kt @@ -217,11 +217,27 @@ fun WithpeaceNavHost( }, ) searchGraph( - onShowSnackBar = {}, onAuthExpired = {}, onBackButtonClick = { navController.popBackStack() }, + onShowSnackBar = { onShowSnackBar(SnackbarState(it)) }, + onNavigationSnackBar = { + onShowSnackBar( + SnackbarState( + it, + SnackbarType.Navigator( + actionName = "목록 보러가기", + action = { + navController.navigatePolicyBookmarks() + }, + ), + ), + ) + }, + onPolicyClick = { + navController.navigateToPolicyDetail(policyId = it) + }, ) policyDetailNavGraph( onShowSnackBar = { onShowSnackBar(SnackbarState(it)) }, diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index d53dfab9..cbd96508 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -37,6 +37,7 @@ dependencies { implementation(project(":core:imagestorage")) implementation(project(":core:testing")) implementation(project(":core:analytics")) + implementation(project(":core:database")) implementation(libs.skydoves.sandwich) implementation(libs.androidx.paging) testImplementation(libs.androidx.paging.testing) diff --git a/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/di/RepositoryModule.kt b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/di/RepositoryModule.kt index 9eceac46..60e63ffd 100644 --- a/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/di/RepositoryModule.kt +++ b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/di/RepositoryModule.kt @@ -3,12 +3,14 @@ package com.withpeace.withpeace.core.data.di import com.withpeace.withpeace.core.data.repository.DefaultAppUpdateRepository import com.withpeace.withpeace.core.data.repository.DefaultImageRepository import com.withpeace.withpeace.core.data.repository.DefaultPostRepository +import com.withpeace.withpeace.core.data.repository.DefaultRecentSearchKeywordRepository import com.withpeace.withpeace.core.data.repository.DefaultTokenRepository import com.withpeace.withpeace.core.data.repository.DefaultUserRepository import com.withpeace.withpeace.core.data.repository.DefaultYouthPolicyRepository import com.withpeace.withpeace.core.domain.repository.AppUpdateRepository import com.withpeace.withpeace.core.domain.repository.ImageRepository import com.withpeace.withpeace.core.domain.repository.PostRepository +import com.withpeace.withpeace.core.domain.repository.RecentSearchKeywordRepository import com.withpeace.withpeace.core.domain.repository.TokenRepository import com.withpeace.withpeace.core.domain.repository.UserRepository import com.withpeace.withpeace.core.domain.repository.YouthPolicyRepository @@ -17,8 +19,6 @@ import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.android.components.ViewModelComponent import dagger.hilt.android.scopes.ViewModelScoped -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton @Module @InstallIn(ViewModelComponent::class) @@ -42,6 +42,13 @@ interface RepositoryModule { defaultUserRepository: DefaultUserRepository, ): UserRepository + @Binds + @ViewModelScoped + fun bindsRecentSearchKeywordRepository( + defaultRecentSearchKeywordRepository: DefaultRecentSearchKeywordRepository, + ): RecentSearchKeywordRepository + + @Binds @ViewModelScoped fun bindsYouthPolicyRepository( diff --git a/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/mapper/SearchKeywordMapper.kt b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/mapper/SearchKeywordMapper.kt new file mode 100644 index 00000000..453c244f --- /dev/null +++ b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/mapper/SearchKeywordMapper.kt @@ -0,0 +1,8 @@ +package com.withpeace.withpeace.core.data.mapper + +import com.withpeace.withpeace.core.database.SearchKeywordEntity +import com.withpeace.withpeace.core.domain.model.search.SearchKeyword + +fun SearchKeywordEntity.toDomain(): SearchKeyword { + return SearchKeyword(keyword) +} \ No newline at end of file diff --git a/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/paging/PolicySearchPagingSource.kt b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/paging/PolicySearchPagingSource.kt new file mode 100644 index 00000000..3ab6da1a --- /dev/null +++ b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/paging/PolicySearchPagingSource.kt @@ -0,0 +1,60 @@ +package com.withpeace.withpeace.core.data.paging + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.skydoves.sandwich.ApiResponse +import com.withpeace.withpeace.core.data.mapper.youthpolicy.toDomain +import com.withpeace.withpeace.core.domain.model.error.CheonghaError +import com.withpeace.withpeace.core.domain.model.error.NoSearchResultException +import com.withpeace.withpeace.core.domain.model.policy.YouthPolicy +import com.withpeace.withpeace.core.domain.repository.UserRepository +import com.withpeace.withpeace.core.network.di.service.YouthPolicyService + +class PolicySearchPagingSource( + private val pageSize: Int, + private val youthPolicyService: YouthPolicyService, + private val keyword: String, + private val onError: suspend (CheonghaError) -> Unit, + private val userRepository: UserRepository, + private val onReceiveTotalCount: suspend (Int) -> Unit, +) : PagingSource>() { + override suspend fun load(params: LoadParams): LoadResult> { + val pageIndex = params.key ?: 1 + val response = youthPolicyService.search( + keyword = keyword, + pageSize = params.loadSize, + pageIndex = pageIndex, + ) + + if (response is ApiResponse.Success) { + val successResponse = (response).data + onReceiveTotalCount(successResponse.data.totalCount) + if (response.data.data.totalCount == 0) { + return LoadResult.Error(NoSearchResultException()) + } + return LoadResult.Page( + data = successResponse.data.policies.map { + Pair( + successResponse.data.totalCount, + it.toDomain(), + ) + }, + prevKey = if (pageIndex == STARTING_PAGE_INDEX) null else pageIndex - 1, + nextKey = if (successResponse.data.policies.isEmpty()) null else pageIndex + (params.loadSize / pageSize), + ) + } else { + return LoadResult.Error(IllegalStateException("api state error")) + } + } + + override fun getRefreshKey(state: PagingState>): Int? { // 현재 포지션에서 Refresh pageKey 설정 + return state.anchorPosition?.let { anchorPosition -> + val anchorPage = state.closestPageToPosition(anchorPosition) + anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1) + } + } + + companion object { + private const val STARTING_PAGE_INDEX = 1 + } +} \ No newline at end of file diff --git a/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/repository/DefaultRecentSearchKeywordRepository.kt b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/repository/DefaultRecentSearchKeywordRepository.kt new file mode 100644 index 00000000..3836d0b0 --- /dev/null +++ b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/repository/DefaultRecentSearchKeywordRepository.kt @@ -0,0 +1,32 @@ +package com.withpeace.withpeace.core.data.repository + +import com.withpeace.withpeace.core.data.mapper.toDomain +import com.withpeace.withpeace.core.database.SearchKeywordDao +import com.withpeace.withpeace.core.database.SearchKeywordEntity +import com.withpeace.withpeace.core.domain.model.search.SearchKeyword +import com.withpeace.withpeace.core.domain.repository.RecentSearchKeywordRepository +import javax.inject.Inject + +class DefaultRecentSearchKeywordRepository @Inject constructor( + private val searchKeywordDao: SearchKeywordDao, +) : RecentSearchKeywordRepository { + override suspend fun insertKeyword(keyword: SearchKeyword) { + searchKeywordDao.insertKeyword(SearchKeywordEntity(keyword = keyword.value)) + } + + override suspend fun getAllKeywords(): List { + return searchKeywordDao.getAllKeywords().map { it.toDomain() } + } + + override suspend fun deleteKeyword(keyword: SearchKeyword) { + searchKeywordDao.deleteKeyword( + SearchKeywordEntity( + keyword = keyword.value + ), + ) + } + + override suspend fun clearAllKeywords() { + searchKeywordDao.clearAllKeywords() + } +} \ No newline at end of file diff --git a/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/repository/DefaultYouthPolicyRepository.kt b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/repository/DefaultYouthPolicyRepository.kt index 90ebdcdd..ad625e6e 100644 --- a/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/repository/DefaultYouthPolicyRepository.kt +++ b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/repository/DefaultYouthPolicyRepository.kt @@ -1,10 +1,12 @@ package com.withpeace.withpeace.core.data.repository +import android.util.Log import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingData import com.skydoves.sandwich.suspendMapSuccess import com.withpeace.withpeace.core.data.mapper.youthpolicy.toDomain +import com.withpeace.withpeace.core.data.paging.PolicySearchPagingSource import com.withpeace.withpeace.core.data.paging.YouthPolicyPagingSource import com.withpeace.withpeace.core.data.util.handleApiFailure import com.withpeace.withpeace.core.domain.model.error.CheonghaError @@ -14,6 +16,7 @@ import com.withpeace.withpeace.core.domain.model.policy.BookmarkedPolicy import com.withpeace.withpeace.core.domain.model.policy.PolicyFilters import com.withpeace.withpeace.core.domain.model.policy.YouthPolicy import com.withpeace.withpeace.core.domain.model.policy.YouthPolicyDetail +import com.withpeace.withpeace.core.domain.model.search.SearchKeyword import com.withpeace.withpeace.core.domain.repository.UserRepository import com.withpeace.withpeace.core.domain.repository.YouthPolicyRepository import com.withpeace.withpeace.core.network.di.service.YouthPolicyService @@ -102,6 +105,26 @@ class DefaultYouthPolicyRepository @Inject constructor( } } + override fun search( + searchKeyword: SearchKeyword, + onError: suspend (CheonghaError) -> Unit, + onReceiveTotalCount: (Int) -> Unit, + ): Flow>> { + return Pager( + config = PagingConfig(PAGE_SIZE), + pagingSourceFactory = { + PolicySearchPagingSource( + keyword = searchKeyword.value, + youthPolicyService = youthPolicyService, + onError = onError, + userRepository = userRepository, + pageSize = PAGE_SIZE, + onReceiveTotalCount = onReceiveTotalCount + ) + }, + ).flow + } + private suspend fun onErrorWithAuthExpired( it: ResponseError, onError: suspend (CheonghaError) -> Unit, diff --git a/core/database/.gitignore b/core/database/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/core/database/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/database/build.gradle.kts b/core/database/build.gradle.kts new file mode 100644 index 00000000..afd6d587 --- /dev/null +++ b/core/database/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + id("com.android.library") + id("convention.android.base") + id("convention.android.hilt") +} + +android { + namespace = "com.withpeace.withpeace.core.database" +} + +dependencies { + implementation(libs.room.ktx) + implementation(libs.room.runtime) + kapt (libs.room.compiler) +} \ No newline at end of file diff --git a/core/database/consumer-rules.pro b/core/database/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/core/database/proguard-rules.pro b/core/database/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/core/database/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/core/database/src/androidTest/java/com/withpeace/withpeace/core/database/ExampleInstrumentedTest.kt b/core/database/src/androidTest/java/com/withpeace/withpeace/core/database/ExampleInstrumentedTest.kt new file mode 100644 index 00000000..819dae13 --- /dev/null +++ b/core/database/src/androidTest/java/com/withpeace/withpeace/core/database/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.withpeace.withpeace.core.database + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.withpeace.withpeace.core.database.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/core/database/src/main/AndroidManifest.xml b/core/database/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a5918e68 --- /dev/null +++ b/core/database/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/core/database/src/main/java/com/withpeace/withpeace/core/database/CheonghaDatabase.kt b/core/database/src/main/java/com/withpeace/withpeace/core/database/CheonghaDatabase.kt new file mode 100644 index 00000000..5ff7a58d --- /dev/null +++ b/core/database/src/main/java/com/withpeace/withpeace/core/database/CheonghaDatabase.kt @@ -0,0 +1,12 @@ +package com.withpeace.withpeace.core.database + +import androidx.room.Database +import androidx.room.RoomDatabase + +@Database(entities = [SearchKeywordEntity::class], version = 1, exportSchema = false) +abstract class AppDatabase : RoomDatabase() { + abstract fun searchKeywordDao(): SearchKeywordDao +} + + + diff --git a/core/database/src/main/java/com/withpeace/withpeace/core/database/DatabaseModule.kt b/core/database/src/main/java/com/withpeace/withpeace/core/database/DatabaseModule.kt new file mode 100644 index 00000000..b1f109e6 --- /dev/null +++ b/core/database/src/main/java/com/withpeace/withpeace/core/database/DatabaseModule.kt @@ -0,0 +1,30 @@ +package com.withpeace.withpeace.core.database + +import android.content.Context +import androidx.room.Room +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object DatabaseModule { + + @Provides + @Singleton + fun provideDatabase(@ApplicationContext context: Context): AppDatabase { + return Room.databaseBuilder( + context, + AppDatabase::class.java, + "app_database", + ).build() + } + + @Provides + fun provideSearchKeywordDao(database: AppDatabase): SearchKeywordDao { + return database.searchKeywordDao() + } +} \ No newline at end of file diff --git a/core/database/src/main/java/com/withpeace/withpeace/core/database/SearchKeywordDao.kt b/core/database/src/main/java/com/withpeace/withpeace/core/database/SearchKeywordDao.kt new file mode 100644 index 00000000..5aed437b --- /dev/null +++ b/core/database/src/main/java/com/withpeace/withpeace/core/database/SearchKeywordDao.kt @@ -0,0 +1,26 @@ +package com.withpeace.withpeace.core.database + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +@Dao +interface SearchKeywordDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertKeyword(keyword: SearchKeywordEntity) + + @Query("SELECT * FROM recent_search_keywords ORDER BY timestamp DESC LIMIT 8") + suspend fun getAllKeywords(): List + + @Delete + suspend fun deleteKeyword(keyword: SearchKeywordEntity) + + @Query("DELETE FROM recent_search_keywords WHERE keyword = :keyword") + suspend fun deleteKeywordByValue(keyword: String) + + @Query("DELETE FROM recent_search_keywords") + suspend fun clearAllKeywords() +} \ No newline at end of file diff --git a/core/database/src/main/java/com/withpeace/withpeace/core/database/SearchKeywordEntity.kt b/core/database/src/main/java/com/withpeace/withpeace/core/database/SearchKeywordEntity.kt new file mode 100644 index 00000000..cc32b7d3 --- /dev/null +++ b/core/database/src/main/java/com/withpeace/withpeace/core/database/SearchKeywordEntity.kt @@ -0,0 +1,10 @@ +package com.withpeace.withpeace.core.database + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "recent_search_keywords") +data class SearchKeywordEntity( + @PrimaryKey(autoGenerate = false) val keyword: String, + val timestamp: Long = System.currentTimeMillis(), // 저장 시각 +) \ No newline at end of file diff --git a/core/database/src/test/java/com/withpeace/withpeace/core/database/ExampleUnitTest.kt b/core/database/src/test/java/com/withpeace/withpeace/core/database/ExampleUnitTest.kt new file mode 100644 index 00000000..ce36dd7a --- /dev/null +++ b/core/database/src/test/java/com/withpeace/withpeace/core/database/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.withpeace.withpeace.core.database + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/error/ClientError.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/error/ClientError.kt index 29cab42b..d35042bb 100644 --- a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/error/ClientError.kt +++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/error/ClientError.kt @@ -8,4 +8,13 @@ sealed interface ClientError : CheonghaError { data object AuthExpired : ClientError data object ProfileNotChanged : ClientError -} \ No newline at end of file + + sealed interface SearchError : ClientError { + data object NoSearchResult : SearchError + data object SingleCharacterSearch : SearchError + } +} + +class NoSearchResultException: IllegalStateException() +class SingleCharacterSearchException: IllegalStateException() + diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/policy/PolicySearchResult.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/policy/PolicySearchResult.kt new file mode 100644 index 00000000..76fa2989 --- /dev/null +++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/policy/PolicySearchResult.kt @@ -0,0 +1,8 @@ +package com.withpeace.withpeace.core.domain.model.policy + +import androidx.paging.PagingData + +data class PolicySearchResult( + val resultCount: Int, + val policies: PagingData, +) diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/search/SearchKeyword.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/search/SearchKeyword.kt new file mode 100644 index 00000000..de2282ce --- /dev/null +++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/search/SearchKeyword.kt @@ -0,0 +1,16 @@ +package com.withpeace.withpeace.core.domain.model.search + +@JvmInline +value class SearchKeyword( + val value: String, +) { + init { + require(validate(value)) { "검색어가 2글자 미만입니다." } + } + + companion object { + fun validate(keyword: String): Boolean { + return keyword.length > 1 + } + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/repository/RecentSearchKeywordRepository.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/repository/RecentSearchKeywordRepository.kt new file mode 100644 index 00000000..249c9541 --- /dev/null +++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/repository/RecentSearchKeywordRepository.kt @@ -0,0 +1,10 @@ +package com.withpeace.withpeace.core.domain.repository + +import com.withpeace.withpeace.core.domain.model.search.SearchKeyword + +interface RecentSearchKeywordRepository { + suspend fun insertKeyword(keyword: SearchKeyword) + suspend fun getAllKeywords(): List + suspend fun deleteKeyword(keyword: SearchKeyword) + suspend fun clearAllKeywords() +} diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/repository/YouthPolicyRepository.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/repository/YouthPolicyRepository.kt index b057e585..53d03498 100644 --- a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/repository/YouthPolicyRepository.kt +++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/repository/YouthPolicyRepository.kt @@ -4,8 +4,10 @@ import androidx.paging.PagingData import com.withpeace.withpeace.core.domain.model.error.CheonghaError import com.withpeace.withpeace.core.domain.model.policy.BookmarkedPolicy import com.withpeace.withpeace.core.domain.model.policy.PolicyFilters +import com.withpeace.withpeace.core.domain.model.policy.PolicySearchResult import com.withpeace.withpeace.core.domain.model.policy.YouthPolicy import com.withpeace.withpeace.core.domain.model.policy.YouthPolicyDetail +import com.withpeace.withpeace.core.domain.model.search.SearchKeyword import kotlinx.coroutines.flow.Flow interface YouthPolicyRepository { @@ -40,4 +42,10 @@ interface YouthPolicyRepository { fun getHotPolicy( onError: suspend (CheonghaError) -> Unit, ): Flow> + + fun search( + searchKeyword: SearchKeyword, + onError: suspend (CheonghaError) -> Unit, + onReceiveTotalCount: (Int) -> Unit, + ): Flow>> } \ No newline at end of file diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/AddKeywordUseCase.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/AddKeywordUseCase.kt new file mode 100644 index 00000000..25363020 --- /dev/null +++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/AddKeywordUseCase.kt @@ -0,0 +1,13 @@ +package com.withpeace.withpeace.core.domain.usecase + +import com.withpeace.withpeace.core.domain.model.search.SearchKeyword +import com.withpeace.withpeace.core.domain.repository.RecentSearchKeywordRepository +import javax.inject.Inject + +class AddKeywordUseCase @Inject constructor( + private val repository: RecentSearchKeywordRepository +) { + suspend operator fun invoke(keyword: SearchKeyword) { + repository.insertKeyword(keyword) + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/ClearAllKeywordsUseCase.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/ClearAllKeywordsUseCase.kt new file mode 100644 index 00000000..9a7f1d74 --- /dev/null +++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/ClearAllKeywordsUseCase.kt @@ -0,0 +1,12 @@ +package com.withpeace.withpeace.core.domain.usecase + +import com.withpeace.withpeace.core.domain.repository.RecentSearchKeywordRepository +import javax.inject.Inject + +class ClearAllKeywordsUseCase @Inject constructor( + private val repository: RecentSearchKeywordRepository +) { + suspend operator fun invoke() { + repository.clearAllKeywords() + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/DeleteKeywordUseCase.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/DeleteKeywordUseCase.kt new file mode 100644 index 00000000..8aca2dd2 --- /dev/null +++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/DeleteKeywordUseCase.kt @@ -0,0 +1,13 @@ +package com.withpeace.withpeace.core.domain.usecase + +import com.withpeace.withpeace.core.domain.model.search.SearchKeyword +import com.withpeace.withpeace.core.domain.repository.RecentSearchKeywordRepository +import javax.inject.Inject + +class DeleteKeywordUseCase @Inject constructor( + private val repository: RecentSearchKeywordRepository +) { + suspend fun invoke(keyword: SearchKeyword) { + repository.deleteKeyword(keyword) + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/GetAllKeywordsUseCase.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/GetAllKeywordsUseCase.kt new file mode 100644 index 00000000..2c916104 --- /dev/null +++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/GetAllKeywordsUseCase.kt @@ -0,0 +1,13 @@ +package com.withpeace.withpeace.core.domain.usecase + +import com.withpeace.withpeace.core.domain.model.search.SearchKeyword +import com.withpeace.withpeace.core.domain.repository.RecentSearchKeywordRepository +import javax.inject.Inject + +class GetAllKeywordsUseCase @Inject constructor( + private val repository: RecentSearchKeywordRepository, +) { + suspend operator fun invoke(): List { + return repository.getAllKeywords() + } +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/SearchUseCase.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/SearchUseCase.kt new file mode 100644 index 00000000..0e449917 --- /dev/null +++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/SearchUseCase.kt @@ -0,0 +1,31 @@ +package com.withpeace.withpeace.core.domain.usecase + +import androidx.paging.PagingData +import com.withpeace.withpeace.core.domain.model.error.CheonghaError +import com.withpeace.withpeace.core.domain.model.error.ClientError +import com.withpeace.withpeace.core.domain.model.error.SingleCharacterSearchException +import com.withpeace.withpeace.core.domain.model.policy.YouthPolicy +import com.withpeace.withpeace.core.domain.model.search.SearchKeyword +import com.withpeace.withpeace.core.domain.repository.YouthPolicyRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import javax.inject.Inject + +class SearchUseCase @Inject constructor( + private val youthPolicyRepository: YouthPolicyRepository, +) { + operator fun invoke( + onError: suspend (CheonghaError) -> Unit, + keyword: String, + onReceiveTotalCount: (Int) -> Unit, + ): Flow>> { + if (SearchKeyword.validate(keyword).not()) { + throw SingleCharacterSearchException() + } + return youthPolicyRepository.search( + searchKeyword = SearchKeyword(keyword), + onError = onError, + onReceiveTotalCount + ) + } +} \ No newline at end of file diff --git a/core/network/src/main/java/com/withpeace/withpeace/core/network/di/response/policy/PolicySearchResponse.kt b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/response/policy/PolicySearchResponse.kt new file mode 100644 index 00000000..6d44eead --- /dev/null +++ b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/response/policy/PolicySearchResponse.kt @@ -0,0 +1,9 @@ +package com.withpeace.withpeace.core.network.di.response.policy + +import kotlinx.serialization.Serializable + +@Serializable +data class PolicySearchResponse( + val policies: List, + val totalCount: Int, +) diff --git a/core/network/src/main/java/com/withpeace/withpeace/core/network/di/service/YouthPolicyService.kt b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/service/YouthPolicyService.kt index ec46aaec..5cad39f8 100644 --- a/core/network/src/main/java/com/withpeace/withpeace/core/network/di/service/YouthPolicyService.kt +++ b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/service/YouthPolicyService.kt @@ -5,6 +5,7 @@ import com.withpeace.withpeace.core.network.di.response.BaseResponse import com.withpeace.withpeace.core.network.di.response.policy.BookmarkedPolicyResponse import com.withpeace.withpeace.core.network.di.response.policy.PolicyDetailResponse import com.withpeace.withpeace.core.network.di.response.policy.PolicyResponse +import com.withpeace.withpeace.core.network.di.response.policy.PolicySearchResponse import retrofit2.http.DELETE import retrofit2.http.GET import retrofit2.http.POST @@ -37,4 +38,11 @@ interface YouthPolicyService { @GET("/api/v1/policies/hot") suspend fun getHots(): ApiResponse>> + + @GET("/api/v1/policies/search") + suspend fun search( + @Query("keyword") keyword: String, + @Query("pageIndex") pageIndex: Int, + @Query("pageSize") pageSize: Int, + ): ApiResponse> } \ No newline at end of file diff --git a/feature/policylist/src/main/java/com/withpeace/withpeace/feature/policylist/PolicyListScreen.kt b/feature/policylist/src/main/java/com/withpeace/withpeace/feature/policylist/PolicyListScreen.kt index 02652035..84df4d84 100644 --- a/feature/policylist/src/main/java/com/withpeace/withpeace/feature/policylist/PolicyListScreen.kt +++ b/feature/policylist/src/main/java/com/withpeace/withpeace/feature/policylist/PolicyListScreen.kt @@ -91,6 +91,7 @@ fun PolicyListRoute( } } } + PolicyListScreen( youthPolicies = youthPolicyPagingData, selectedFilterUiState = selectedFilterUiState.value, diff --git a/feature/search/src/main/java/com/withpeace/withpeace/feature/search/SearchScreen.kt b/feature/search/src/main/java/com/withpeace/withpeace/feature/search/SearchScreen.kt index 4837ea90..e8c3aeb8 100644 --- a/feature/search/src/main/java/com/withpeace/withpeace/feature/search/SearchScreen.kt +++ b/feature/search/src/main/java/com/withpeace/withpeace/feature/search/SearchScreen.kt @@ -20,49 +20,158 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.paging.LoadState +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import androidx.paging.compose.itemKey import com.withpeace.withpeace.core.designsystem.R import com.withpeace.withpeace.core.designsystem.theme.WithpeaceTheme +import com.withpeace.withpeace.core.domain.model.error.NoSearchResultException +import com.withpeace.withpeace.core.ui.policy.YouthPolicyCard +import com.withpeace.withpeace.core.ui.policy.YouthPolicyUiModel +@OptIn(ExperimentalMaterial3Api::class) @Composable fun SearchRoute( + onShowSnackBar: (message: String) -> Unit, + onNavigationSnackBar: (message: String) -> Unit = {}, + viewModel: SearchViewModel = hiltViewModel(), onBackButtonClick: () -> Unit, + onPolicyClick: (String) -> Unit, ) { + val recentSearchKeywords = viewModel.recentSearchKeywords.collectAsStateWithLifecycle() + val searchKeyword = viewModel.searchKeyword.collectAsStateWithLifecycle() + val youthPolicies = viewModel.youthPolicyPagingFlow.collectAsLazyPagingItems() + val uiState = viewModel.uiState.collectAsStateWithLifecycle() + val dialogState = remember { mutableStateOf(false) } + val totalItemCount = viewModel.totalCountFlow.collectAsStateWithLifecycle() + + LaunchedEffect(key1 = viewModel.uiEvent) { + viewModel.uiEvent.collect { + when (it) { + SearchUiEvent.BookmarkSuccess -> { + onNavigationSnackBar("관심 목록에 추가되었습니다.") + } + + SearchUiEvent.BookmarkFailure -> { + onShowSnackBar("찜하기에 실패했습니다. 다시 시도해주세요.") + } + + SearchUiEvent.UnBookmarkSuccess -> { + onShowSnackBar("관심목록에서 삭제되었습니다.") + } + + SearchUiEvent.SingleCharacteristicError -> { + dialogState.value = true + } + } + } + } + if (dialogState.value) { + BasicAlertDialog( + modifier = Modifier + .clip( + RoundedCornerShape(10.dp), + ) + .background(color = WithpeaceTheme.colors.SystemWhite) + .padding(vertical = 24.dp), + onDismissRequest = { + dialogState.value = false + }, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + "두 글자 이상 입력해주세요.", + style = WithpeaceTheme.typography.title2, + color = WithpeaceTheme.colors.SnackbarBlack, + ) + Spacer(modifier = Modifier.height(16.dp)) + TextButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .clip( + RoundedCornerShape(10.dp), + ) + .background(color = WithpeaceTheme.colors.MainPurple), + onClick = { + dialogState.value = false + }, + ) { + Text( + textAlign = TextAlign.Center, + text = "확인", + style = WithpeaceTheme.typography.caption, + color = WithpeaceTheme.colors.SystemWhite, + ) + } + } + } + } SearchScreen( - onKeywordTextChanged = {}, - searchKeyword = "", + onKeywordTextChanged = viewModel::onChangedKeyword, + searchKeyword = searchKeyword.value, + uiState = uiState.value, + recentSearchKeyword = recentSearchKeywords.value, onBackButtonClick = onBackButtonClick, - onClickSearchKeyword = {}, - onRemoveKeyword = {}, - - ) + onClickSearchKeyword = viewModel::onClickSearchKeyword, + onRemoveRecentKeyword = viewModel::removeKeywords, + onRecentKeywordClick = viewModel::search, + youthPolicies = youthPolicies, + onBookmarkClick = viewModel::bookmark, + onPolicyClick = onPolicyClick, + totalItemCount = totalItemCount.value, + ) } @Composable private fun SearchScreen( modifier: Modifier = Modifier, searchKeyword: String, - // youthPolicies: LazyPagingItems, + totalItemCount: Int, + youthPolicies: LazyPagingItems, + recentSearchKeyword: List, + uiState: SearchUiState, onBackButtonClick: () -> Unit, onKeywordTextChanged: (String) -> Unit, onClickSearchKeyword: (String) -> Unit, - onRemoveKeyword: () -> Unit, + onRemoveRecentKeyword: () -> Unit, + onRecentKeywordClick: () -> Unit, + onPolicyClick: (String) -> Unit, + onBookmarkClick: (id: String, isChecked: Boolean) -> Unit, ) { - val rememberKeyword = remember { mutableStateOf("") } val interactionSource = remember { MutableInteractionSource() } Column( modifier = modifier @@ -87,42 +196,144 @@ private fun SearchScreen( contentDescription = "뒤로 가기", ) Spacer(modifier = modifier.width(8.dp)) - SearchComponent(modifier, rememberKeyword, interactionSource) + SearchComponent( + modifier = modifier, + interactionSource = interactionSource, + onSearchKeywordChanged = onKeywordTextChanged, + onSearchClick = onRecentKeywordClick, + searchKeyword = searchKeyword, + ) } Spacer(modifier = modifier.height(7.dp)) HorizontalDivider( thickness = 1.dp, color = WithpeaceTheme.colors.SystemGray3, ) - // SearchCompleted(modifier) - Column( - modifier = modifier - .fillMaxSize() - .background(color = Color(0xFFF8F9FB)), - ) { + when (uiState) { + is SearchUiState.PagingData -> { + SearchCompleted( + youthPolicies = youthPolicies, + searchKeyword = searchKeyword, + onPolicyClick = onPolicyClick, + onBookmarkClick = onBookmarkClick, + totalItemCount = totalItemCount, + ) + } + else -> { + SearchIntro( + recentSearchKeyword = recentSearchKeyword, + onClickSearchKeyword = onClickSearchKeyword, + onRemoveKeyword = onRemoveRecentKeyword, + ) + } } - // SearchIntro( - // onClickSearchKeyword = onClickSearchKeyword, - // onRemoveKeyword = onRemoveKeyword, - // ) } } @Composable -private fun SearchCompleted(modifier: Modifier) { +private fun SearchCompleted( + modifier: Modifier = Modifier, + totalItemCount: Int, + searchKeyword: String, + youthPolicies: LazyPagingItems, + onPolicyClick: (String) -> Unit, + onBookmarkClick: (id: String, isChecked: Boolean) -> Unit, +) { + Column( modifier = modifier .fillMaxSize() .background(color = Color(0xFFF8F9FB)), ) { - Spacer(modifier = modifier.height(16.dp)) - Text("총 4개") - Spacer(modifier = modifier.height(16.dp)) - LazyColumn( - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { + if (youthPolicies.loadState.refresh is LoadState.Error) { + if ((youthPolicies.loadState.refresh as LoadState.Error).error is NoSearchResultException) { + Column( + modifier = modifier + .padding(horizontal = 24.dp) + .fillMaxSize() + .background(color = Color(0xFFF8F9FB)), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = modifier.height(76.dp)) + Image( + painter = painterResource(com.withpeace.withpeace.feature.search.R.drawable.ic_caution), + contentDescription = "결과 없음", + modifier = modifier.size(52.dp), + ) + Spacer(modifier = modifier.height(16.dp)) + Text( + text = buildAnnotatedString { + append("“") + withStyle( + style = SpanStyle( + color = WithpeaceTheme.colors.MainPurple, + ), + ) { // 키워드의 색상 설정 + append(searchKeyword) + } + append("”에 해당하는 검색 결과를 찾지 못했어요") + }, + style = WithpeaceTheme.typography.body, + color = WithpeaceTheme.colors.SystemGray1, + ) + Spacer(modifier.height(8.dp)) + Text( + "다른 검색어를 입력해보세요", + style = WithpeaceTheme.typography.body, + color = WithpeaceTheme.colors.SystemGray1, + ) + } + } + } else { + LazyColumn( + modifier = modifier + .fillMaxSize() + .testTag("home:policies"), + contentPadding = PaddingValues(start = 24.dp, end = 24.dp, bottom = 16.dp), + ) { + item { + Spacer(modifier = modifier.height(16.dp)) + Text( + "총 ${totalItemCount}개", + style = WithpeaceTheme.typography.caption, + color = WithpeaceTheme.colors.SystemGray1, + ) + Spacer(modifier = modifier.height(16.dp)) + } + items( + count = youthPolicies.itemCount, + key = youthPolicies.itemKey { it.id }, + ) { + val youthPolicy = youthPolicies[it] ?: throw IllegalStateException() + Spacer(modifier = modifier.height(8.dp)) + YouthPolicyCard( + modifier = modifier, + youthPolicy = youthPolicy, + onPolicyClick = onPolicyClick, + onBookmarkClick = onBookmarkClick, + ) + } + item { + if (youthPolicies.loadState.append is LoadState.Loading) { + Column( + modifier = modifier + .padding(top = 8.dp) + .fillMaxWidth() + .background( + Color.Transparent, + ), + ) { + CircularProgressIndicator( + modifier.align(Alignment.CenterHorizontally), + color = WithpeaceTheme.colors.MainPurple, + ) + } + } + } + } } + } } @@ -130,9 +341,12 @@ private fun SearchCompleted(modifier: Modifier) { @OptIn(ExperimentalMaterial3Api::class) private fun SearchComponent( modifier: Modifier, - rememberKeyword: MutableState, + searchKeyword: String, interactionSource: MutableInteractionSource, + onSearchKeywordChanged: (String) -> Unit, + onSearchClick: () -> Unit, ) { + val keyboardController = LocalSoftwareKeyboardController.current Row( verticalAlignment = Alignment.CenterVertically, modifier = modifier @@ -152,19 +366,25 @@ private fun SearchComponent( ) Spacer(modifier.width(8.dp)) BasicTextField( - value = rememberKeyword.value, + value = searchKeyword, onValueChange = { - rememberKeyword.value = it - // onSearchKeywordChanged(it) + onSearchKeywordChanged(it) }, modifier = modifier.fillMaxWidth(), enabled = true, textStyle = WithpeaceTheme.typography.caption, singleLine = true, maxLines = 1, + keyboardActions = KeyboardActions( + onSearch = { + onSearchClick() + keyboardController?.hide() + }, + ), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), ) { TextFieldDefaults.DecorationBox( - value = rememberKeyword.value, + value = searchKeyword, innerTextField = it, enabled = true, singleLine = false, @@ -196,6 +416,7 @@ private fun SearchComponent( @Composable private fun SearchIntro( modifier: Modifier = Modifier, + recentSearchKeyword: List, onClickSearchKeyword: (String) -> Unit, onRemoveKeyword: () -> Unit, ) { @@ -225,22 +446,24 @@ private fun SearchIntro( FlowRow( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - List(5) { + recentSearchKeyword.map { Text( modifier = modifier .background( color = WithpeaceTheme.colors.SubPurple, shape = RoundedCornerShape(999.dp), ) - .padding(horizontal = 8.dp, vertical = 6.dp) .clickable { - onClickSearchKeyword("") - }, + onClickSearchKeyword(it) + } + .padding(horizontal = 8.dp, vertical = 6.dp), style = WithpeaceTheme.typography.caption, color = WithpeaceTheme.colors.MainPurple, - text = "여행$it", + text = it, ) } } } } + +//TODO 문구, 다이얼로그, 데이터 갯수 \ No newline at end of file diff --git a/feature/search/src/main/java/com/withpeace/withpeace/feature/search/SearchUiEvent.kt b/feature/search/src/main/java/com/withpeace/withpeace/feature/search/SearchUiEvent.kt new file mode 100644 index 00000000..05793528 --- /dev/null +++ b/feature/search/src/main/java/com/withpeace/withpeace/feature/search/SearchUiEvent.kt @@ -0,0 +1,8 @@ +package com.withpeace.withpeace.feature.search + +sealed interface SearchUiEvent { + data object BookmarkFailure : SearchUiEvent + data object BookmarkSuccess : SearchUiEvent + data object SingleCharacteristicError: SearchUiEvent + data object UnBookmarkSuccess : SearchUiEvent +} \ No newline at end of file diff --git a/feature/search/src/main/java/com/withpeace/withpeace/feature/search/SearchUiState.kt b/feature/search/src/main/java/com/withpeace/withpeace/feature/search/SearchUiState.kt new file mode 100644 index 00000000..8e02718a --- /dev/null +++ b/feature/search/src/main/java/com/withpeace/withpeace/feature/search/SearchUiState.kt @@ -0,0 +1,7 @@ +package com.withpeace.withpeace.feature.search + +sealed interface SearchUiState { + data object Initialized : SearchUiState + data object PagingData : SearchUiState + data object SearchFailure : SearchUiState +} \ No newline at end of file diff --git a/feature/search/src/main/java/com/withpeace/withpeace/feature/search/SearchViewModel.kt b/feature/search/src/main/java/com/withpeace/withpeace/feature/search/SearchViewModel.kt new file mode 100644 index 00000000..67f9a5f9 --- /dev/null +++ b/feature/search/src/main/java/com/withpeace/withpeace/feature/search/SearchViewModel.kt @@ -0,0 +1,170 @@ +package com.withpeace.withpeace.feature.search + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn +import androidx.paging.map +import com.withpeace.withpeace.core.domain.extension.groupBy +import com.withpeace.withpeace.core.domain.model.error.SingleCharacterSearchException +import com.withpeace.withpeace.core.domain.model.policy.BookmarkInfo +import com.withpeace.withpeace.core.domain.model.search.SearchKeyword +import com.withpeace.withpeace.core.domain.usecase.AddKeywordUseCase +import com.withpeace.withpeace.core.domain.usecase.BookmarkPolicyUseCase +import com.withpeace.withpeace.core.domain.usecase.ClearAllKeywordsUseCase +import com.withpeace.withpeace.core.domain.usecase.GetAllKeywordsUseCase +import com.withpeace.withpeace.core.domain.usecase.SearchUseCase +import com.withpeace.withpeace.core.ui.policy.YouthPolicyUiModel +import com.withpeace.withpeace.core.ui.policy.toUiModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.flatMapMerge +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) +@HiltViewModel +class SearchViewModel @Inject constructor( + private val searchUseCase: SearchUseCase, + private val addKeywordUseCase: AddKeywordUseCase, + private val getAllKeywordsUseCase: GetAllKeywordsUseCase, + private val bookmarkPolicyUseCase: BookmarkPolicyUseCase, + private val clearAllKeywordsUseCase: ClearAllKeywordsUseCase, +) : ViewModel() { + + private val _uiEvent: Channel = Channel() + val uiEvent = _uiEvent.receiveAsFlow() + + private val _uiState: MutableStateFlow = + MutableStateFlow(SearchUiState.Initialized) + val uiState = _uiState.asStateFlow() + + private val bookmarkStateFlow = + MutableStateFlow(mapOf()) + + private val debounceFlow = MutableSharedFlow(replay = 1) + + private val lastByWhetherSuccessOfBookmarks = + mutableMapOf() // optimistic UI에서 실패시에 사용할 캐시 데이터 + + private val _youthPolicyPagingFlow = MutableStateFlow(PagingData.empty()) + val youthPolicyPagingFlow = + combine( + _youthPolicyPagingFlow.asStateFlow(), + bookmarkStateFlow, + ) { youthPolicyPagingFlow, bookmarkFlow -> + youthPolicyPagingFlow.map { + lastByWhetherSuccessOfBookmarks[it.id] = it.isBookmarked + val bookmarkState = bookmarkFlow[it.id] + it.copy(isBookmarked = bookmarkState ?: it.isBookmarked) + } + }.cachedIn(viewModelScope) + + private val _searchKeyword = MutableStateFlow("") + val searchKeyword = _searchKeyword.asStateFlow() + + private val _recentSearchKeywords = MutableStateFlow>(listOf()) + val recentSearchKeywords = _recentSearchKeywords.asStateFlow() + + private val _totalCountFlow = MutableStateFlow(0) + val totalCountFlow = _totalCountFlow.asStateFlow() + + init { + viewModelScope.launch { + _recentSearchKeywords.value = getAllKeywordsUseCase().map { it.value } + debounceFlow.groupBy { it.id }.flatMapMerge { + it.second.debounce(300L) + }.collectLatest { bookmarkInfo -> // policyBookmarkViewModel과 다른 이유를 찾아보기 + bookmarkPolicyUseCase( + bookmarkInfo.id, bookmarkInfo.isBookmarked, + onError = { + bookmarkStateFlow.update { + it + mapOf( + bookmarkInfo.id to (lastByWhetherSuccessOfBookmarks[bookmarkInfo.id] + ?: !bookmarkInfo.isBookmarked), + ) + } + _uiEvent.send(SearchUiEvent.BookmarkFailure) + }, + ).collect { result -> + lastByWhetherSuccessOfBookmarks[result.id] = result.isBookmarked + if (result.isBookmarked) { + _uiEvent.send(SearchUiEvent.BookmarkSuccess) + } else { + _uiEvent.send(SearchUiEvent.UnBookmarkSuccess) + } + } + } + } + } + + fun onChangedKeyword(keyword: String) { + _searchKeyword.value = keyword + } + + fun search() { + viewModelScope.launch { + runCatching { + _youthPolicyPagingFlow.update { + searchUseCase( + keyword = searchKeyword.value, + onError = { + }, + onReceiveTotalCount = { + Log.d("test",it.toString()) + _totalCountFlow.value = it + }, + ).onStart { + _uiState.update { + SearchUiState.PagingData + } + // 여기서 검색 state로 변경 + }.map { data -> + data.map { + it.second.toUiModel() + } + }.cachedIn(viewModelScope).firstOrNull() ?: PagingData.empty() + } + }.onFailure { + when (it) { + is SingleCharacterSearchException -> { + _uiEvent.send(SearchUiEvent.SingleCharacteristicError) + } + } + return@launch + } + addKeywordUseCase(SearchKeyword(searchKeyword.value)) + } + } + + fun bookmark(id: String, isChecked: Boolean) { + bookmarkStateFlow.update { it + mapOf(id to isChecked) } + viewModelScope.launch { + debounceFlow.emit(BookmarkInfo(id, isChecked)) + } + } + + fun onClickSearchKeyword(keyword: String) { + this._searchKeyword.value = keyword + search() + } + + fun removeKeywords() { + viewModelScope.launch { clearAllKeywordsUseCase() } + _recentSearchKeywords.value = listOf() + } +} diff --git a/feature/search/src/main/java/com/withpeace/withpeace/feature/search/navigation/SearchNavigation.kt b/feature/search/src/main/java/com/withpeace/withpeace/feature/search/navigation/SearchNavigation.kt index 1aead30b..904cbd38 100644 --- a/feature/search/src/main/java/com/withpeace/withpeace/feature/search/navigation/SearchNavigation.kt +++ b/feature/search/src/main/java/com/withpeace/withpeace/feature/search/navigation/SearchNavigation.kt @@ -17,6 +17,8 @@ fun NavGraphBuilder.searchGraph( onShowSnackBar: (String) -> Unit, onAuthExpired: () -> Unit, onBackButtonClick: () -> Unit, + onNavigationSnackBar: (message: String) -> Unit = {}, + onPolicyClick: (String) -> Unit, ) { composable( route = SEARCH_ROUTE, @@ -34,7 +36,10 @@ fun NavGraphBuilder.searchGraph( }, ) { SearchRoute( + onShowSnackBar = onShowSnackBar, + onNavigationSnackBar = onNavigationSnackBar, onBackButtonClick = onBackButtonClick, + onPolicyClick = onPolicyClick, ) } } diff --git a/feature/search/src/main/res/drawable/ic_caution.xml b/feature/search/src/main/res/drawable/ic_caution.xml new file mode 100644 index 00000000..bedcb46a --- /dev/null +++ b/feature/search/src/main/res/drawable/ic_caution.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/settings.gradle.kts b/settings.gradle.kts index 62045c3e..326f3cff 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -47,3 +47,4 @@ include(":feature:disablepolicy") include(":feature:policylist") include(":feature:policyfilter") include(":feature:search") +include(":core:database")