From e58e42fd76a5b035c0d20887b30505e931f0a018 Mon Sep 17 00:00:00 2001 From: Sehwan Yun <39579912+l5x5l@users.noreply.github.com> Date: Fri, 16 Aug 2024 16:54:01 +0900 Subject: [PATCH] =?UTF-8?q?[Feature]#36=20=EB=A7=81=ED=81=AC=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20API=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20=EB=A7=81?= =?UTF-8?q?=ED=81=AC=20=EC=B6=94=EA=B0=80/=EC=88=98=EC=A0=95=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4,=20=EA=B2=80=EC=83=89=20=ED=99=94=EB=A9=B4=EC=97=90?= =?UTF-8?q?=20=EC=97=B0=EA=B2=B0=20(#37)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [FEATURE] #36 링크 삭제, 링크 수정, 검색 링크 목록, 링크 상세 조회 API, Datasource, UseCase 구현 * [FEATURE] #36 검색화면 검색 API 연동 * [FEATURE] #36 검색화면 bottomSheet 내 포킷 목록 조회 API 연결 * [FEATURE] #36 검색화면의 link상세 bottomSheet 구현 및 기존 bottomSheet에 공유 제외 클릭 이벤트 연결 * [BASE] #36 data 모듈에 room, sharedPreferences 관련 세팅 및 데이터베이스 구성 * [FEATURE] #36 최근 검색어 관련 기능 구현 * [FEATURE] #36 즐겨찾기 등록/취소 API, datasource, useCase 구현 * [FEATURE] #36 검색 화면에 즐겨찾기 등록/취소 API 연결 * [FIX] #36 페이징 클래스에서 아이템 수정이 반영되지 않는 문제 수정 * [FEATURE] #36 링크 추가, open graph 링크 메타 정보 조회 api, datasource, repository, usecase 구현 * [FEATURE] #36 링크 추가 화면에 링크 추가, 링크 수정, 링크 메타 정보 조회 useCase 연결 * [FIX] #36 링크 추가 화면에서 링크 입력 도중 1초 이상 시간 소요시 키보드를 강제 종료시키는 문제 수정 * [CHORE] #36 ktlint 적용 * [FEATURE] #36 미분류 카테고리 컨텐츠 조회 API, datasource, api 구현 * [FIX] #36 rootNavHost에 링크 추가/삭제 화면 인자 변경 반영 --- .../pokitmons/pokit/navigation/RootNavHost.kt | 4 +- data/build.gradle.kts | 8 + .../java/pokitmons/pokit/data/api/LinkApi.kt | 55 +++++ .../local/search/LocalSearchWordDataSource.kt | 54 +++++ .../local/search/SearchDataSource.kt | 12 + .../datasource/remote/link/LinkDataSource.kt | 39 ++++ .../remote/link/RemoteLinkDataSource.kt | 76 ++++++ .../pokit/data/di/core/room/RoomModule.kt | 25 ++ .../SharedPreferencesModule.kt | 23 ++ .../pokit/data/di/search/SearchModule.kt | 23 ++ .../pokit/data/mapper/link/LinkMapper.kt | 16 ++ .../model/link/request/ModifyLinkRequest.kt | 13 ++ .../link/response/ApplyBookmarkResponse.kt | 6 + .../model/link/response/GetLinkResponse.kt | 15 ++ .../model/link/response/LinkCardResponse.kt | 7 + .../model/link/response/ModifyLinkResponse.kt | 15 ++ .../repository/link/LinkRepositoryImpl.kt | 128 ++++++++++- .../repository/search/SearchRepositoryImpl.kt | 34 +++ .../pokit/data/room/dao/SearchWordDao.kt | 23 ++ .../pokit/data/room/database/AppDatabase.kt | 11 + .../pokit/data/room/entity/SearchWord.kt | 10 + .../datasource/RemoteLinkDataSourceTest.kt | 22 ++ .../data/repository/LinkRepositoryImplTest.kt | 34 +++ domain/build.gradle.kts | 1 + .../pokitmons/pokit/domain/model/link/Link.kt | 5 +- .../pokit/domain/model/link/LinkCard.kt | 7 + .../domain/repository/link/LinkRepository.kt | 46 ++++ .../repository/search/SearchRepository.kt | 12 + .../domain/usecase/link/CreateLinkUseCase.kt | 27 +++ .../domain/usecase/link/DeleteLinkUseCase.kt | 13 ++ .../domain/usecase/link/GetLinkCardUseCase.kt | 14 ++ .../domain/usecase/link/GetLinkUseCase.kt | 14 ++ .../domain/usecase/link/GetLinksUseCase.kt | 12 + .../domain/usecase/link/ModifyLinkUseCase.kt | 29 +++ .../domain/usecase/link/SearchLinksUseCase.kt | 34 +++ .../domain/usecase/link/SetBookmarkUseCase.kt | 13 ++ .../search/AddRecentSearchWordUseCase.kt | 12 + .../search/GetRecentSearchWordsUseCase.kt | 13 ++ .../search/GetUseRecentSearchWordsUseCase.kt | 13 ++ .../search/RemoveRecentSearchWordUseCase.kt | 16 ++ .../search/SetUseRecentSearchWordsUseCase.kt | 12 + feature/addlink/build.gradle.kts | 4 + .../com/strayalpaca/addlink/AddLinkScreen.kt | 118 ++++------ .../strayalpaca/addlink/AddLinkViewModel.kt | 163 ++++++++----- .../java/com/strayalpaca/addlink/Preview.kt | 6 - .../addlink/components/block/Link.kt | 7 +- .../addlink/model/AddLinkScreenState.kt | 1 - .../com/strayalpaca/addlink/model/Link.kt | 23 +- .../com/strayalpaca/addlink/model/Pokit.kt | 22 +- .../strayalpaca/addlink/paging/PokitPaging.kt | 124 ++++++++++ .../addlink/paging/SimplePaging.kt | 13 ++ .../addlink/paging/SimplePagingState.kt | 5 + .../addpokit/paging/PokitPaging.kt | 2 +- .../pokitdetail/paging/LinkPaging.kt | 2 +- .../pokitdetail/paging/PokitPaging.kt | 2 +- feature/search/build.gradle.kts | 1 + .../java/pokitmons/pokit/search/Preview.kt | 69 +++++- .../pokitmons/pokit/search/SearchScreen.kt | 115 +++++++--- .../pokitmons/pokit/search/SearchViewModel.kt | 175 ++++++++++++-- .../filterbottomsheet/FilterBottomSheet.kt | 13 +- .../FilterBottomSheetContent.kt | 24 +- .../components/linkdetailbottomsheet/Link.kt | 70 ++++++ .../LinkDetailBottomSheet.kt | 217 ++++++++++++++++++ .../searchitemlist/SearchItemList.kt | 25 +- .../java/pokitmons/pokit/search/model/Date.kt | 4 + .../java/pokitmons/pokit/search/model/Link.kt | 101 +++----- .../pokitmons/pokit/search/model/Pokit.kt | 12 +- .../pokit/search/model/SearchScreenState.kt | 6 + .../pokit/search/paging/LinkPaging.kt | 160 +++++++++++++ .../pokit/search/paging/PokitPaging.kt | 124 ++++++++++ .../pokit/search/paging/SimplePaging.kt | 13 ++ .../pokit/search/paging/SimplePagingState.kt | 5 + feature/search/src/main/res/values/string.xml | 3 + gradle/libs.versions.toml | 2 + 74 files changed, 2288 insertions(+), 289 deletions(-) create mode 100644 data/src/main/java/pokitmons/pokit/data/datasource/local/search/LocalSearchWordDataSource.kt create mode 100644 data/src/main/java/pokitmons/pokit/data/datasource/local/search/SearchDataSource.kt create mode 100644 data/src/main/java/pokitmons/pokit/data/di/core/room/RoomModule.kt create mode 100644 data/src/main/java/pokitmons/pokit/data/di/core/sharedpreferences/SharedPreferencesModule.kt create mode 100644 data/src/main/java/pokitmons/pokit/data/di/search/SearchModule.kt create mode 100644 data/src/main/java/pokitmons/pokit/data/model/link/request/ModifyLinkRequest.kt create mode 100644 data/src/main/java/pokitmons/pokit/data/model/link/response/ApplyBookmarkResponse.kt create mode 100644 data/src/main/java/pokitmons/pokit/data/model/link/response/GetLinkResponse.kt create mode 100644 data/src/main/java/pokitmons/pokit/data/model/link/response/LinkCardResponse.kt create mode 100644 data/src/main/java/pokitmons/pokit/data/model/link/response/ModifyLinkResponse.kt create mode 100644 data/src/main/java/pokitmons/pokit/data/repository/search/SearchRepositoryImpl.kt create mode 100644 data/src/main/java/pokitmons/pokit/data/room/dao/SearchWordDao.kt create mode 100644 data/src/main/java/pokitmons/pokit/data/room/database/AppDatabase.kt create mode 100644 data/src/main/java/pokitmons/pokit/data/room/entity/SearchWord.kt create mode 100644 domain/src/main/java/pokitmons/pokit/domain/model/link/LinkCard.kt create mode 100644 domain/src/main/java/pokitmons/pokit/domain/repository/search/SearchRepository.kt create mode 100644 domain/src/main/java/pokitmons/pokit/domain/usecase/link/CreateLinkUseCase.kt create mode 100644 domain/src/main/java/pokitmons/pokit/domain/usecase/link/DeleteLinkUseCase.kt create mode 100644 domain/src/main/java/pokitmons/pokit/domain/usecase/link/GetLinkCardUseCase.kt create mode 100644 domain/src/main/java/pokitmons/pokit/domain/usecase/link/GetLinkUseCase.kt create mode 100644 domain/src/main/java/pokitmons/pokit/domain/usecase/link/ModifyLinkUseCase.kt create mode 100644 domain/src/main/java/pokitmons/pokit/domain/usecase/link/SearchLinksUseCase.kt create mode 100644 domain/src/main/java/pokitmons/pokit/domain/usecase/link/SetBookmarkUseCase.kt create mode 100644 domain/src/main/java/pokitmons/pokit/domain/usecase/search/AddRecentSearchWordUseCase.kt create mode 100644 domain/src/main/java/pokitmons/pokit/domain/usecase/search/GetRecentSearchWordsUseCase.kt create mode 100644 domain/src/main/java/pokitmons/pokit/domain/usecase/search/GetUseRecentSearchWordsUseCase.kt create mode 100644 domain/src/main/java/pokitmons/pokit/domain/usecase/search/RemoveRecentSearchWordUseCase.kt create mode 100644 domain/src/main/java/pokitmons/pokit/domain/usecase/search/SetUseRecentSearchWordsUseCase.kt create mode 100644 feature/addlink/src/main/java/com/strayalpaca/addlink/paging/PokitPaging.kt create mode 100644 feature/addlink/src/main/java/com/strayalpaca/addlink/paging/SimplePaging.kt create mode 100644 feature/addlink/src/main/java/com/strayalpaca/addlink/paging/SimplePagingState.kt create mode 100644 feature/search/src/main/java/pokitmons/pokit/search/components/linkdetailbottomsheet/Link.kt create mode 100644 feature/search/src/main/java/pokitmons/pokit/search/components/linkdetailbottomsheet/LinkDetailBottomSheet.kt create mode 100644 feature/search/src/main/java/pokitmons/pokit/search/paging/LinkPaging.kt create mode 100644 feature/search/src/main/java/pokitmons/pokit/search/paging/PokitPaging.kt create mode 100644 feature/search/src/main/java/pokitmons/pokit/search/paging/SimplePaging.kt create mode 100644 feature/search/src/main/java/pokitmons/pokit/search/paging/SimplePagingState.kt diff --git a/app/src/main/java/pokitmons/pokit/navigation/RootNavHost.kt b/app/src/main/java/pokitmons/pokit/navigation/RootNavHost.kt index c3a39f0c..2e4e4151 100644 --- a/app/src/main/java/pokitmons/pokit/navigation/RootNavHost.kt +++ b/app/src/main/java/pokitmons/pokit/navigation/RootNavHost.kt @@ -43,11 +43,9 @@ fun RootNavHost( composable( route = AddLink.routeWithArgs, arguments = AddLink.arguments - ) { navBackStackEntry -> + ) { val viewModel: AddLinkViewModel = hiltViewModel() - val linkId = navBackStackEntry.arguments?.getString(AddLink.linkIdArg) AddLinkScreenContainer( - linkId = linkId, viewModel = viewModel, onBackPressed = navHostController::popBackStack, onNavigateToAddPokit = { diff --git a/data/build.gradle.kts b/data/build.gradle.kts index b81abbc7..3d906cb8 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -49,6 +49,11 @@ dependencies { androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) + // room + implementation(libs.room.runtime) + annotationProcessor(libs.room.compiler) + kapt(libs.room.compiler) + // kotest testImplementation(libs.kotest.runner.junit5) testImplementation(libs.kotlin.reflect) @@ -68,6 +73,9 @@ dependencies { implementation(project(":domain")) + // jsoup + implementation(libs.jsoup) + // mockk testImplementation(libs.mockk) androidTestImplementation(libs.mockk.android) diff --git a/data/src/main/java/pokitmons/pokit/data/api/LinkApi.kt b/data/src/main/java/pokitmons/pokit/data/api/LinkApi.kt index 0d79617c..a7ac524f 100644 --- a/data/src/main/java/pokitmons/pokit/data/api/LinkApi.kt +++ b/data/src/main/java/pokitmons/pokit/data/api/LinkApi.kt @@ -1,8 +1,16 @@ package pokitmons.pokit.data.api +import pokitmons.pokit.data.model.link.request.ModifyLinkRequest +import pokitmons.pokit.data.model.link.response.ApplyBookmarkResponse +import pokitmons.pokit.data.model.link.response.GetLinkResponse import pokitmons.pokit.data.model.link.response.GetLinksResponse +import pokitmons.pokit.data.model.link.response.ModifyLinkResponse import pokitmons.pokit.domain.model.link.LinksSort +import retrofit2.http.Body import retrofit2.http.GET +import retrofit2.http.PATCH +import retrofit2.http.POST +import retrofit2.http.PUT import retrofit2.http.Path import retrofit2.http.Query @@ -19,4 +27,51 @@ interface LinkApi { @Query("endDate") endDate: String? = null, @Query("categoryIds") categoryIds: List? = null, ): GetLinksResponse + + @GET("content") + suspend fun searchLinks( + @Query("page") page: Int = 0, + @Query("size") size: Int = 10, + @Query("sort") sort: List = listOf(LinksSort.RECENT.value), + @Query("isRead") isRead: Boolean = false, + @Query("favorites") favorites: Boolean = false, + @Query("startDate") startDate: String? = null, + @Query("endDate") endDate: String? = null, + @Query("categoryIds") categoryIds: List? = null, + @Query("searchWord") searchWord: String = "", + ): GetLinksResponse + + @PUT("content/{contentId}") + suspend fun deleteLink( + @Path("contentId") contentId: Int = 0, + ) + + @POST("content/{contentId}") + suspend fun getLink( + @Path("contentId") contentId: Int = 0, + ): GetLinkResponse + + @PATCH("content/{contentId}") + suspend fun modifyLink( + @Path("contentId") contentId: Int, + @Body modifyLinkRequest: ModifyLinkRequest, + ): ModifyLinkResponse + + @POST("content") + suspend fun createLink( + @Body createLinkRequest: ModifyLinkRequest, + ): ModifyLinkResponse + + @PUT("content/{contentId}/bookmark") + suspend fun cancelBookmark(@Path("contentId") contentId: Int) + + @POST("content/{contentId}/bookmark") + suspend fun applyBookmark(@Path("contentId") contentId: Int): ApplyBookmarkResponse + + @GET("content/uncategorized") + suspend fun getUncategorizedLinks( + @Query("page") page: Int = 0, + @Query("size") size: Int = 10, + @Query("sort") sort: List = listOf(LinksSort.RECENT.value), + ): GetLinksResponse } diff --git a/data/src/main/java/pokitmons/pokit/data/datasource/local/search/LocalSearchWordDataSource.kt b/data/src/main/java/pokitmons/pokit/data/datasource/local/search/LocalSearchWordDataSource.kt new file mode 100644 index 00000000..34b39370 --- /dev/null +++ b/data/src/main/java/pokitmons/pokit/data/datasource/local/search/LocalSearchWordDataSource.kt @@ -0,0 +1,54 @@ +package pokitmons.pokit.data.datasource.local.search + +import android.content.SharedPreferences +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import pokitmons.pokit.data.room.dao.SearchWordDao +import pokitmons.pokit.data.room.entity.SearchWord +import java.util.Calendar +import javax.inject.Inject + +class LocalSearchWordDataSource @Inject constructor( + private val searchWordDao: SearchWordDao, + private val sharedPreferences: SharedPreferences, +) : SearchDataSource { + companion object { + const val USE_RECENT_WORD_SP_KEY = "use_recent_word" + } + + private val useRecentSearchWords = MutableStateFlow( + sharedPreferences.getBoolean(USE_RECENT_WORD_SP_KEY, false) + ) + + override fun getSearchWord(): Flow> { + return searchWordDao.getRecentSearchWords() + } + + override suspend fun addSearchWord(searchWord: String) { + val currentDateString = Calendar.getInstance() + val searchWordEntity = SearchWord( + word = searchWord, + searchedAt = currentDateString.timeInMillis.toString() + ) + searchWordDao.addSearchWord(searchWord = searchWordEntity) + } + + override suspend fun removeSearchWord(searchWord: String) { + searchWordDao.removeSearchWord(searchWord) + } + + override suspend fun removeAllSearchWords() { + searchWordDao.removeAllSearchWords() + } + + override suspend fun setUseRecentSearchWord(use: Boolean): Boolean { + sharedPreferences.edit().putBoolean(USE_RECENT_WORD_SP_KEY, use).apply() + useRecentSearchWords.update { use } + return use + } + + override fun getUseRecentSearchWord(): Flow { + return useRecentSearchWords + } +} diff --git a/data/src/main/java/pokitmons/pokit/data/datasource/local/search/SearchDataSource.kt b/data/src/main/java/pokitmons/pokit/data/datasource/local/search/SearchDataSource.kt new file mode 100644 index 00000000..77e8f99c --- /dev/null +++ b/data/src/main/java/pokitmons/pokit/data/datasource/local/search/SearchDataSource.kt @@ -0,0 +1,12 @@ +package pokitmons.pokit.data.datasource.local.search + +import kotlinx.coroutines.flow.Flow + +interface SearchDataSource { + fun getSearchWord(): Flow> + suspend fun addSearchWord(searchWord: String) + suspend fun removeSearchWord(searchWord: String) + suspend fun removeAllSearchWords() + suspend fun setUseRecentSearchWord(use: Boolean): Boolean + fun getUseRecentSearchWord(): Flow +} diff --git a/data/src/main/java/pokitmons/pokit/data/datasource/remote/link/LinkDataSource.kt b/data/src/main/java/pokitmons/pokit/data/datasource/remote/link/LinkDataSource.kt index c145dc0d..3bca87ee 100644 --- a/data/src/main/java/pokitmons/pokit/data/datasource/remote/link/LinkDataSource.kt +++ b/data/src/main/java/pokitmons/pokit/data/datasource/remote/link/LinkDataSource.kt @@ -1,6 +1,10 @@ package pokitmons.pokit.data.datasource.remote.link +import pokitmons.pokit.data.model.link.request.ModifyLinkRequest +import pokitmons.pokit.data.model.link.response.GetLinkResponse import pokitmons.pokit.data.model.link.response.GetLinksResponse +import pokitmons.pokit.data.model.link.response.LinkCardResponse +import pokitmons.pokit.data.model.link.response.ModifyLinkResponse import pokitmons.pokit.domain.model.link.LinksSort interface LinkDataSource { @@ -15,4 +19,39 @@ interface LinkDataSource { endDate: String? = null, categoryIds: List? = null, ): GetLinksResponse + + suspend fun searchLinks( + page: Int = 0, + size: Int = 10, + sort: List = listOf(LinksSort.RECENT.value), + isRead: Boolean = false, + favorites: Boolean = false, + startDate: String? = null, + endDate: String? = null, + categoryIds: List? = null, + searchWord: String = "", + ): GetLinksResponse + + suspend fun deleteLink(contentId: Int) + + suspend fun getLink(contentId: Int): GetLinkResponse + + suspend fun modifyLink( + contentId: Int, + modifyLinkRequest: ModifyLinkRequest, + ): ModifyLinkResponse + + suspend fun createLink( + createLinkRequest: ModifyLinkRequest, + ): ModifyLinkResponse + + suspend fun setBookmark(contentId: Int, bookmarked: Boolean) + + suspend fun getLinkCard(url: String): LinkCardResponse + + suspend fun getUncategorizedLinks( + page: Int = 0, + size: Int = 10, + sort: List = listOf(LinksSort.RECENT.value), + ): GetLinksResponse } diff --git a/data/src/main/java/pokitmons/pokit/data/datasource/remote/link/RemoteLinkDataSource.kt b/data/src/main/java/pokitmons/pokit/data/datasource/remote/link/RemoteLinkDataSource.kt index b1c37b75..a0b965ef 100644 --- a/data/src/main/java/pokitmons/pokit/data/datasource/remote/link/RemoteLinkDataSource.kt +++ b/data/src/main/java/pokitmons/pokit/data/datasource/remote/link/RemoteLinkDataSource.kt @@ -1,7 +1,12 @@ package pokitmons.pokit.data.datasource.remote.link +import org.jsoup.Jsoup import pokitmons.pokit.data.api.LinkApi +import pokitmons.pokit.data.model.link.request.ModifyLinkRequest +import pokitmons.pokit.data.model.link.response.GetLinkResponse import pokitmons.pokit.data.model.link.response.GetLinksResponse +import pokitmons.pokit.data.model.link.response.LinkCardResponse +import pokitmons.pokit.data.model.link.response.ModifyLinkResponse import javax.inject.Inject class RemoteLinkDataSource @Inject constructor( @@ -30,4 +35,75 @@ class RemoteLinkDataSource @Inject constructor( categoryIds = categoryIds ) } + + override suspend fun searchLinks( + page: Int, + size: Int, + sort: List, + isRead: Boolean, + favorites: Boolean, + startDate: String?, + endDate: String?, + categoryIds: List?, + searchWord: String, + ): GetLinksResponse { + return linkApi.searchLinks( + page = page, + size = size, + sort = sort, + isRead = isRead, + favorites = favorites, + startDate = startDate, + endDate = endDate, + categoryIds = categoryIds, + searchWord = searchWord + ) + } + + override suspend fun deleteLink(contentId: Int) { + return linkApi.deleteLink(contentId = contentId) + } + + override suspend fun getLink(contentId: Int): GetLinkResponse { + return linkApi.getLink(contentId) + } + + override suspend fun modifyLink( + contentId: Int, + modifyLinkRequest: ModifyLinkRequest, + ): ModifyLinkResponse { + return linkApi.modifyLink( + contentId = contentId, + modifyLinkRequest = modifyLinkRequest + ) + } + + override suspend fun createLink(createLinkRequest: ModifyLinkRequest): ModifyLinkResponse { + return linkApi.createLink( + createLinkRequest = createLinkRequest + ) + } + + override suspend fun setBookmark(contentId: Int, bookmarked: Boolean) { + if (bookmarked) { + linkApi.applyBookmark(contentId) + } else { + linkApi.cancelBookmark(contentId) + } + } + + override suspend fun getLinkCard(url: String): LinkCardResponse { + val document = Jsoup.connect(url).get() + val image = document.select("meta[property=og:image]").attr("content").ifEmpty { null } + val title = document.select("meta[property=og:title]").attr("content") + return LinkCardResponse( + url = url, + image = image, + title = title + ) + } + + override suspend fun getUncategorizedLinks(page: Int, size: Int, sort: List): GetLinksResponse { + return linkApi.getUncategorizedLinks(page = page, size = size, sort = sort) + } } diff --git a/data/src/main/java/pokitmons/pokit/data/di/core/room/RoomModule.kt b/data/src/main/java/pokitmons/pokit/data/di/core/room/RoomModule.kt new file mode 100644 index 00000000..cc7126ee --- /dev/null +++ b/data/src/main/java/pokitmons/pokit/data/di/core/room/RoomModule.kt @@ -0,0 +1,25 @@ +package pokitmons.pokit.data.di.core.room + +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 pokitmons.pokit.data.room.database.AppDatabase +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object RoomModule { + @Provides + @Singleton + fun provideDatabase(@ApplicationContext context: Context): AppDatabase { + return Room.databaseBuilder(context, AppDatabase::class.java, "pokitDatabase.db").build() + } + + @Provides + @Singleton + fun providerSearchWordDao(database: AppDatabase) = database.searchWordDao() +} diff --git a/data/src/main/java/pokitmons/pokit/data/di/core/sharedpreferences/SharedPreferencesModule.kt b/data/src/main/java/pokitmons/pokit/data/di/core/sharedpreferences/SharedPreferencesModule.kt new file mode 100644 index 00000000..7d6c6419 --- /dev/null +++ b/data/src/main/java/pokitmons/pokit/data/di/core/sharedpreferences/SharedPreferencesModule.kt @@ -0,0 +1,23 @@ +package pokitmons.pokit.data.di.core.sharedpreferences + +import android.content.Context +import android.content.Context.MODE_PRIVATE +import android.content.SharedPreferences +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 SharedPreferencesModule { + @Provides + @Singleton + fun provideSharedPreferences( + @ApplicationContext context: Context, + ): SharedPreferences { + return context.getSharedPreferences("pokit_shared_preferences", MODE_PRIVATE) + } +} diff --git a/data/src/main/java/pokitmons/pokit/data/di/search/SearchModule.kt b/data/src/main/java/pokitmons/pokit/data/di/search/SearchModule.kt new file mode 100644 index 00000000..dbd7a4a4 --- /dev/null +++ b/data/src/main/java/pokitmons/pokit/data/di/search/SearchModule.kt @@ -0,0 +1,23 @@ +package pokitmons.pokit.data.di.search + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import pokitmons.pokit.data.datasource.local.search.LocalSearchWordDataSource +import pokitmons.pokit.data.datasource.local.search.SearchDataSource +import pokitmons.pokit.data.repository.search.SearchRepositoryImpl +import pokitmons.pokit.domain.repository.search.SearchRepository +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class SearchModule { + @Binds + @Singleton + abstract fun bindSearchRepository(searchRepositoryImpl: SearchRepositoryImpl): SearchRepository + + @Binds + @Singleton + abstract fun bindSearchDataSource(searchDataSourceImpl: LocalSearchWordDataSource): SearchDataSource +} diff --git a/data/src/main/java/pokitmons/pokit/data/mapper/link/LinkMapper.kt b/data/src/main/java/pokitmons/pokit/data/mapper/link/LinkMapper.kt index 8c0c3d94..74fef49a 100644 --- a/data/src/main/java/pokitmons/pokit/data/mapper/link/LinkMapper.kt +++ b/data/src/main/java/pokitmons/pokit/data/mapper/link/LinkMapper.kt @@ -1,5 +1,6 @@ package pokitmons.pokit.data.mapper.link +import pokitmons.pokit.data.model.link.response.GetLinkResponse import pokitmons.pokit.data.model.link.response.GetLinksResponse import pokitmons.pokit.domain.model.link.Link @@ -21,4 +22,19 @@ object LinkMapper { ) } } + + fun mapperToLink(linkResponse: GetLinkResponse): Link { + return Link( + id = linkResponse.contentId, + categoryId = linkResponse.categoryId, + categoryName = "", + data = linkResponse.data, + domain = "", + title = linkResponse.title, + memo = linkResponse.memo, + alertYn = linkResponse.alertYn, + createdAt = linkResponse.createdAt, + favorites = linkResponse.favorites + ) + } } diff --git a/data/src/main/java/pokitmons/pokit/data/model/link/request/ModifyLinkRequest.kt b/data/src/main/java/pokitmons/pokit/data/model/link/request/ModifyLinkRequest.kt new file mode 100644 index 00000000..7144bae4 --- /dev/null +++ b/data/src/main/java/pokitmons/pokit/data/model/link/request/ModifyLinkRequest.kt @@ -0,0 +1,13 @@ +package pokitmons.pokit.data.model.link.request + +import kotlinx.serialization.Serializable + +@Serializable +class ModifyLinkRequest( + val data: String = "", + val title: String = "", + val categoryId: Int = 0, + val memo: String = "", + val alertYn: String = "", + val thumbNail: String = "", +) diff --git a/data/src/main/java/pokitmons/pokit/data/model/link/response/ApplyBookmarkResponse.kt b/data/src/main/java/pokitmons/pokit/data/model/link/response/ApplyBookmarkResponse.kt new file mode 100644 index 00000000..bf54df13 --- /dev/null +++ b/data/src/main/java/pokitmons/pokit/data/model/link/response/ApplyBookmarkResponse.kt @@ -0,0 +1,6 @@ +package pokitmons.pokit.data.model.link.response + +import kotlinx.serialization.Serializable + +@Serializable +data class ApplyBookmarkResponse(val contentId: Int = 0) diff --git a/data/src/main/java/pokitmons/pokit/data/model/link/response/GetLinkResponse.kt b/data/src/main/java/pokitmons/pokit/data/model/link/response/GetLinkResponse.kt new file mode 100644 index 00000000..b484c9bf --- /dev/null +++ b/data/src/main/java/pokitmons/pokit/data/model/link/response/GetLinkResponse.kt @@ -0,0 +1,15 @@ +package pokitmons.pokit.data.model.link.response + +import kotlinx.serialization.Serializable + +@Serializable +data class GetLinkResponse( + val contentId: Int = 0, + val categoryId: Int = 0, + val data: String = "", + val title: String = "", + val memo: String = "", + val alertYn: String = "", + val createdAt: String = "", + val favorites: Boolean = true, +) diff --git a/data/src/main/java/pokitmons/pokit/data/model/link/response/LinkCardResponse.kt b/data/src/main/java/pokitmons/pokit/data/model/link/response/LinkCardResponse.kt new file mode 100644 index 00000000..41abd42d --- /dev/null +++ b/data/src/main/java/pokitmons/pokit/data/model/link/response/LinkCardResponse.kt @@ -0,0 +1,7 @@ +package pokitmons.pokit.data.model.link.response + +data class LinkCardResponse( + val url: String = "", + val title: String = "", + val image: String? = null, +) diff --git a/data/src/main/java/pokitmons/pokit/data/model/link/response/ModifyLinkResponse.kt b/data/src/main/java/pokitmons/pokit/data/model/link/response/ModifyLinkResponse.kt new file mode 100644 index 00000000..b2ead0e6 --- /dev/null +++ b/data/src/main/java/pokitmons/pokit/data/model/link/response/ModifyLinkResponse.kt @@ -0,0 +1,15 @@ +package pokitmons.pokit.data.model.link.response + +import kotlinx.serialization.Serializable + +@Serializable +data class ModifyLinkResponse( + val contentId: Int = 0, + val categoryId: Int = 0, + val data: String = "", + val title: String = "", + val memo: String = "", + val alertYn: String = "", + val createdAt: String = "", + val favorites: Boolean = true, +) diff --git a/data/src/main/java/pokitmons/pokit/data/repository/link/LinkRepositoryImpl.kt b/data/src/main/java/pokitmons/pokit/data/repository/link/LinkRepositoryImpl.kt index 9dd6f10e..abff80f5 100644 --- a/data/src/main/java/pokitmons/pokit/data/repository/link/LinkRepositoryImpl.kt +++ b/data/src/main/java/pokitmons/pokit/data/repository/link/LinkRepositoryImpl.kt @@ -3,8 +3,10 @@ package pokitmons.pokit.data.repository.link import pokitmons.pokit.data.datasource.remote.link.LinkDataSource import pokitmons.pokit.data.mapper.link.LinkMapper import pokitmons.pokit.data.model.common.parseErrorResult +import pokitmons.pokit.data.model.link.request.ModifyLinkRequest import pokitmons.pokit.domain.commom.PokitResult import pokitmons.pokit.domain.model.link.Link +import pokitmons.pokit.domain.model.link.LinkCard import pokitmons.pokit.domain.model.link.LinksSort import pokitmons.pokit.domain.repository.link.LinkRepository import javax.inject.Inject @@ -23,7 +25,7 @@ class LinkRepositoryImpl @Inject constructor( endDate: String?, categoryIds: List?, ): PokitResult> { - return kotlin.runCatching { + return runCatching { val response = dataSource.getLinks( categoryId = categoryId, size = size, @@ -41,4 +43,128 @@ class LinkRepositoryImpl @Inject constructor( parseErrorResult(throwable) } } + + override suspend fun searchLinks( + page: Int, + size: Int, + sort: List, + isRead: Boolean, + favorites: Boolean, + startDate: String?, + endDate: String?, + categoryIds: List?, + searchWord: String, + ): PokitResult> { + return runCatching { + val response = dataSource.searchLinks( + page = page, + size = size, + sort = sort, + isRead = isRead, + favorites = favorites, + startDate = startDate, + endDate = endDate, + categoryIds = categoryIds, + searchWord = searchWord + ) + val mappedResponse = LinkMapper.mapperToLinks(response) + PokitResult.Success(mappedResponse) + }.getOrElse { throwable -> + parseErrorResult(throwable) + } + } + + override suspend fun deleteLink(linkId: Int): PokitResult { + return runCatching { + dataSource.deleteLink(linkId) + PokitResult.Success(linkId) + }.getOrElse { throwable -> + parseErrorResult(throwable) + } + } + + override suspend fun getLink(linkId: Int): PokitResult { + return runCatching { + val response = dataSource.getLink(linkId) + val mappedResponse = LinkMapper.mapperToLink(response) + PokitResult.Success(mappedResponse) + }.getOrElse { throwable -> + parseErrorResult(throwable) + } + } + + override suspend fun modifyLink( + linkId: Int, + data: String, + title: String, + categoryId: Int, + memo: String, + alertYn: String, + thumbNail: String, + ): PokitResult { + return runCatching { + val modifyLinkRequest = ModifyLinkRequest( + data = data, + title = title, + categoryId = categoryId, + memo = memo, + alertYn = alertYn, + thumbNail = thumbNail + ) + val response = dataSource.modifyLink(contentId = linkId, modifyLinkRequest = modifyLinkRequest) + PokitResult.Success(response.contentId) + }.getOrElse { throwable -> + parseErrorResult(throwable) + } + } + + override suspend fun createLink(data: String, title: String, categoryId: Int, memo: String, alertYn: String, thumbNail: String): PokitResult { + return runCatching { + val createLinkRequest = ModifyLinkRequest( + data = data, + title = title, + categoryId = categoryId, + memo = memo, + alertYn = alertYn, + thumbNail = thumbNail + ) + val response = dataSource.createLink(createLinkRequest = createLinkRequest) + PokitResult.Success(response.contentId) + }.getOrElse { throwable -> + parseErrorResult(throwable) + } + } + + override suspend fun setBookmark(linkId: Int, bookmarked: Boolean): PokitResult { + return runCatching { + dataSource.setBookmark(contentId = linkId, bookmarked = bookmarked) + PokitResult.Success(Unit) + }.getOrElse { throwable -> + parseErrorResult(throwable) + } + } + + override suspend fun getLinkCard(url: String): PokitResult { + return runCatching { + val response = dataSource.getLinkCard(url) + val mappedResponse = LinkCard( + url = response.url, + title = response.title, + thumbnailUrl = response.image + ) + PokitResult.Success(result = mappedResponse) + }.getOrElse { throwable -> + parseErrorResult(throwable) + } + } + + override suspend fun getUncategorizedLinks(size: Int, page: Int, sort: LinksSort): PokitResult> { + return runCatching { + val response = dataSource.getUncategorizedLinks(size = size, page = page, sort = listOf(sort.value)) + val mappedResponse = LinkMapper.mapperToLinks(response) + PokitResult.Success(mappedResponse) + }.getOrElse { throwable -> + parseErrorResult(throwable) + } + } } diff --git a/data/src/main/java/pokitmons/pokit/data/repository/search/SearchRepositoryImpl.kt b/data/src/main/java/pokitmons/pokit/data/repository/search/SearchRepositoryImpl.kt new file mode 100644 index 00000000..5ce7c2b8 --- /dev/null +++ b/data/src/main/java/pokitmons/pokit/data/repository/search/SearchRepositoryImpl.kt @@ -0,0 +1,34 @@ +package pokitmons.pokit.data.repository.search + +import kotlinx.coroutines.flow.Flow +import pokitmons.pokit.data.datasource.local.search.SearchDataSource +import pokitmons.pokit.domain.repository.search.SearchRepository +import javax.inject.Inject + +class SearchRepositoryImpl @Inject constructor( + private val dataSource: SearchDataSource, +) : SearchRepository { + override fun getRecentSearchWords(): Flow> { + return dataSource.getSearchWord() + } + + override suspend fun removeSearchWord(word: String) { + dataSource.removeSearchWord(word) + } + + override suspend fun removeAllSearchWords() { + dataSource.removeAllSearchWords() + } + + override suspend fun setUseRecentSearchWord(use: Boolean): Boolean { + return dataSource.setUseRecentSearchWord(use = use) + } + + override fun getUseRecentSearchWord(): Flow { + return dataSource.getUseRecentSearchWord() + } + + override suspend fun addRecentSearchWord(word: String) { + dataSource.addSearchWord(word) + } +} diff --git a/data/src/main/java/pokitmons/pokit/data/room/dao/SearchWordDao.kt b/data/src/main/java/pokitmons/pokit/data/room/dao/SearchWordDao.kt new file mode 100644 index 00000000..d1506b5f --- /dev/null +++ b/data/src/main/java/pokitmons/pokit/data/room/dao/SearchWordDao.kt @@ -0,0 +1,23 @@ +package pokitmons.pokit.data.room.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow +import pokitmons.pokit.data.room.entity.SearchWord + +@Dao +interface SearchWordDao { + @Query("SELECT word from SearchWord order by searchedAt desc limit 10") + fun getRecentSearchWords(): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun addSearchWord(searchWord: SearchWord) + + @Query("DELETE from SearchWord where word = :word") + suspend fun removeSearchWord(word: String) + + @Query("DELETE from SearchWord") + suspend fun removeAllSearchWords() +} diff --git a/data/src/main/java/pokitmons/pokit/data/room/database/AppDatabase.kt b/data/src/main/java/pokitmons/pokit/data/room/database/AppDatabase.kt new file mode 100644 index 00000000..0f3e4b39 --- /dev/null +++ b/data/src/main/java/pokitmons/pokit/data/room/database/AppDatabase.kt @@ -0,0 +1,11 @@ +package pokitmons.pokit.data.room.database + +import androidx.room.Database +import androidx.room.RoomDatabase +import pokitmons.pokit.data.room.dao.SearchWordDao +import pokitmons.pokit.data.room.entity.SearchWord + +@Database(entities = [SearchWord::class], version = 1) +abstract class AppDatabase : RoomDatabase() { + abstract fun searchWordDao(): SearchWordDao +} diff --git a/data/src/main/java/pokitmons/pokit/data/room/entity/SearchWord.kt b/data/src/main/java/pokitmons/pokit/data/room/entity/SearchWord.kt new file mode 100644 index 00000000..aedcc90f --- /dev/null +++ b/data/src/main/java/pokitmons/pokit/data/room/entity/SearchWord.kt @@ -0,0 +1,10 @@ +package pokitmons.pokit.data.room.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity + +@Entity(primaryKeys = ["word"]) +data class SearchWord( + @ColumnInfo("word") val word: String, + @ColumnInfo("searchedAt") val searchedAt: String, +) diff --git a/data/src/test/java/pokitmons/pokit/data/datasource/RemoteLinkDataSourceTest.kt b/data/src/test/java/pokitmons/pokit/data/datasource/RemoteLinkDataSourceTest.kt index 62707ce8..011fcc28 100644 --- a/data/src/test/java/pokitmons/pokit/data/datasource/RemoteLinkDataSourceTest.kt +++ b/data/src/test/java/pokitmons/pokit/data/datasource/RemoteLinkDataSourceTest.kt @@ -33,4 +33,26 @@ class RemoteLinkDataSourceTest : DescribeSpec({ } } } + + describe("북마크 변경") { + context("북마크 취소 도중 예외가 발생했다면") { + coEvery { linkApi.cancelBookmark(0) } throws IllegalArgumentException("error") + it("동일한 에러가 발생한다.") { + val exception = shouldThrow { + remoteLinkDataSource.setBookmark(contentId = 0, bookmarked = false) + } + exception.message shouldBe "error" + } + } + + context("북마크 등록 도중 예외가 발생했다면") { + coEvery { linkApi.applyBookmark(0) } throws IllegalArgumentException("error") + it("동일한 에러가 발생한다.") { + val exception = shouldThrow { + remoteLinkDataSource.setBookmark(contentId = 0, bookmarked = true) + } + exception.message shouldBe "error" + } + } + } }) diff --git a/data/src/test/java/pokitmons/pokit/data/repository/LinkRepositoryImplTest.kt b/data/src/test/java/pokitmons/pokit/data/repository/LinkRepositoryImplTest.kt index 69df30b3..9cee6ade 100644 --- a/data/src/test/java/pokitmons/pokit/data/repository/LinkRepositoryImplTest.kt +++ b/data/src/test/java/pokitmons/pokit/data/repository/LinkRepositoryImplTest.kt @@ -31,4 +31,38 @@ class LinkRepositoryImplTest : DescribeSpec({ } } } + + describe("북마크 변경") { + context("북마크 취소가 정상적으로 수행된다면") { + coEvery { linkDataSource.setBookmark(contentId = 0, bookmarked = false) } returns Unit + it("빈 성공 결과 인스턴스가 반환된다.") { + val response = linkRepository.setBookmark(linkId = 0, bookmarked = false) + response.shouldBeInstanceOf>() + } + } + + context("북마크 취소 도중 예외가 발생했다면") { + coEvery { linkDataSource.setBookmark(contentId = 0, bookmarked = false) } throws IllegalArgumentException() + it("에러코드, 메세지가 반환된다.") { + val response = linkRepository.setBookmark(linkId = 0, bookmarked = false) + response.shouldBeInstanceOf() + } + } + + context("북마크 등록이 정상적으로 수행된다면") { + coEvery { linkDataSource.setBookmark(contentId = 0, bookmarked = true) } returns Unit + it("빈 성공 결과 인스턴스가 반환된다.") { + val response = linkRepository.setBookmark(linkId = 0, bookmarked = true) + response.shouldBeInstanceOf>() + } + } + + context("북마크 등록 도중 예외가 발생했다면") { + coEvery { linkDataSource.setBookmark(contentId = 0, bookmarked = true) } throws IllegalArgumentException() + it("에러코드, 메세지가 반환된다.") { + val response = linkRepository.setBookmark(linkId = 0, bookmarked = true) + response.shouldBeInstanceOf() + } + } + } }) diff --git a/domain/build.gradle.kts b/domain/build.gradle.kts index 9f63a980..05310eff 100644 --- a/domain/build.gradle.kts +++ b/domain/build.gradle.kts @@ -10,4 +10,5 @@ java { dependencies { implementation(libs.javax.inject) + implementation(libs.coroutines.core) } diff --git a/domain/src/main/java/pokitmons/pokit/domain/model/link/Link.kt b/domain/src/main/java/pokitmons/pokit/domain/model/link/Link.kt index 1505c3fb..c4933dd0 100644 --- a/domain/src/main/java/pokitmons/pokit/domain/model/link/Link.kt +++ b/domain/src/main/java/pokitmons/pokit/domain/model/link/Link.kt @@ -10,6 +10,7 @@ data class Link( val memo: String, val alertYn: String, val createdAt: String, - val isRead: Boolean, - val thumbnail: String, + val isRead: Boolean = false, + val favorites: Boolean = false, + val thumbnail: String = "", ) diff --git a/domain/src/main/java/pokitmons/pokit/domain/model/link/LinkCard.kt b/domain/src/main/java/pokitmons/pokit/domain/model/link/LinkCard.kt new file mode 100644 index 00000000..29b4147d --- /dev/null +++ b/domain/src/main/java/pokitmons/pokit/domain/model/link/LinkCard.kt @@ -0,0 +1,7 @@ +package pokitmons.pokit.domain.model.link + +data class LinkCard( + val url: String = "", + val thumbnailUrl: String? = null, + val title: String = "", +) diff --git a/domain/src/main/java/pokitmons/pokit/domain/repository/link/LinkRepository.kt b/domain/src/main/java/pokitmons/pokit/domain/repository/link/LinkRepository.kt index a1daffe6..7bc829df 100644 --- a/domain/src/main/java/pokitmons/pokit/domain/repository/link/LinkRepository.kt +++ b/domain/src/main/java/pokitmons/pokit/domain/repository/link/LinkRepository.kt @@ -2,6 +2,7 @@ package pokitmons.pokit.domain.repository.link import pokitmons.pokit.domain.commom.PokitResult import pokitmons.pokit.domain.model.link.Link +import pokitmons.pokit.domain.model.link.LinkCard import pokitmons.pokit.domain.model.link.LinksSort interface LinkRepository { @@ -16,4 +17,49 @@ interface LinkRepository { endDate: String? = null, categoryIds: List? = null, ): PokitResult> + + suspend fun searchLinks( + page: Int, + size: Int, + sort: List, + isRead: Boolean, + favorites: Boolean, + startDate: String?, + endDate: String?, + categoryIds: List?, + searchWord: String, + ): PokitResult> + + suspend fun deleteLink(linkId: Int): PokitResult + + suspend fun getLink(linkId: Int): PokitResult + + suspend fun modifyLink( + linkId: Int, + data: String, + title: String, + categoryId: Int, + memo: String, + alertYn: String, + thumbNail: String, + ): PokitResult + + suspend fun createLink( + data: String, + title: String, + categoryId: Int, + memo: String, + alertYn: String, + thumbNail: String, + ): PokitResult + + suspend fun setBookmark(linkId: Int, bookmarked: Boolean): PokitResult + + suspend fun getLinkCard(url: String): PokitResult + + suspend fun getUncategorizedLinks( + size: Int = 10, + page: Int = 0, + sort: LinksSort = LinksSort.RECENT, + ): PokitResult> } diff --git a/domain/src/main/java/pokitmons/pokit/domain/repository/search/SearchRepository.kt b/domain/src/main/java/pokitmons/pokit/domain/repository/search/SearchRepository.kt new file mode 100644 index 00000000..26c7a6e6 --- /dev/null +++ b/domain/src/main/java/pokitmons/pokit/domain/repository/search/SearchRepository.kt @@ -0,0 +1,12 @@ +package pokitmons.pokit.domain.repository.search + +import kotlinx.coroutines.flow.Flow + +interface SearchRepository { + fun getRecentSearchWords(): Flow> + suspend fun removeSearchWord(word: String) + suspend fun removeAllSearchWords() + suspend fun setUseRecentSearchWord(use: Boolean): Boolean + fun getUseRecentSearchWord(): Flow + suspend fun addRecentSearchWord(word: String) +} diff --git a/domain/src/main/java/pokitmons/pokit/domain/usecase/link/CreateLinkUseCase.kt b/domain/src/main/java/pokitmons/pokit/domain/usecase/link/CreateLinkUseCase.kt new file mode 100644 index 00000000..9c934e1a --- /dev/null +++ b/domain/src/main/java/pokitmons/pokit/domain/usecase/link/CreateLinkUseCase.kt @@ -0,0 +1,27 @@ +package pokitmons.pokit.domain.usecase.link + +import pokitmons.pokit.domain.commom.PokitResult +import pokitmons.pokit.domain.repository.link.LinkRepository +import javax.inject.Inject + +class CreateLinkUseCase @Inject constructor( + private val repository: LinkRepository, +) { + suspend fun createLink( + data: String, + title: String, + categoryId: Int, + memo: String, + alertYn: String, + thumbNail: String, + ): PokitResult { + return repository.createLink( + data = data, + title = title, + categoryId = categoryId, + memo = memo, + alertYn = alertYn, + thumbNail = thumbNail + ) + } +} diff --git a/domain/src/main/java/pokitmons/pokit/domain/usecase/link/DeleteLinkUseCase.kt b/domain/src/main/java/pokitmons/pokit/domain/usecase/link/DeleteLinkUseCase.kt new file mode 100644 index 00000000..ac7d6a64 --- /dev/null +++ b/domain/src/main/java/pokitmons/pokit/domain/usecase/link/DeleteLinkUseCase.kt @@ -0,0 +1,13 @@ +package pokitmons.pokit.domain.usecase.link + +import pokitmons.pokit.domain.commom.PokitResult +import pokitmons.pokit.domain.repository.link.LinkRepository +import javax.inject.Inject + +class DeleteLinkUseCase @Inject constructor( + private val repository: LinkRepository, +) { + suspend fun deleteLink(linkId: Int): PokitResult { + return repository.deleteLink(linkId) + } +} diff --git a/domain/src/main/java/pokitmons/pokit/domain/usecase/link/GetLinkCardUseCase.kt b/domain/src/main/java/pokitmons/pokit/domain/usecase/link/GetLinkCardUseCase.kt new file mode 100644 index 00000000..10f820ec --- /dev/null +++ b/domain/src/main/java/pokitmons/pokit/domain/usecase/link/GetLinkCardUseCase.kt @@ -0,0 +1,14 @@ +package pokitmons.pokit.domain.usecase.link + +import pokitmons.pokit.domain.commom.PokitResult +import pokitmons.pokit.domain.model.link.LinkCard +import pokitmons.pokit.domain.repository.link.LinkRepository +import javax.inject.Inject + +class GetLinkCardUseCase @Inject constructor( + private val repository: LinkRepository, +) { + suspend fun getLinkCard(url: String): PokitResult { + return repository.getLinkCard(url = url) + } +} diff --git a/domain/src/main/java/pokitmons/pokit/domain/usecase/link/GetLinkUseCase.kt b/domain/src/main/java/pokitmons/pokit/domain/usecase/link/GetLinkUseCase.kt new file mode 100644 index 00000000..a9fdc869 --- /dev/null +++ b/domain/src/main/java/pokitmons/pokit/domain/usecase/link/GetLinkUseCase.kt @@ -0,0 +1,14 @@ +package pokitmons.pokit.domain.usecase.link + +import pokitmons.pokit.domain.commom.PokitResult +import pokitmons.pokit.domain.model.link.Link +import pokitmons.pokit.domain.repository.link.LinkRepository +import javax.inject.Inject + +class GetLinkUseCase @Inject constructor( + private val repository: LinkRepository, +) { + suspend fun getLink(linkId: Int): PokitResult { + return repository.getLink(linkId) + } +} diff --git a/domain/src/main/java/pokitmons/pokit/domain/usecase/link/GetLinksUseCase.kt b/domain/src/main/java/pokitmons/pokit/domain/usecase/link/GetLinksUseCase.kt index c8a97711..04bfb2ad 100644 --- a/domain/src/main/java/pokitmons/pokit/domain/usecase/link/GetLinksUseCase.kt +++ b/domain/src/main/java/pokitmons/pokit/domain/usecase/link/GetLinksUseCase.kt @@ -32,4 +32,16 @@ class GetLinksUseCase @Inject constructor( categoryIds = categoryIds ) } + + suspend fun getUncategorizedLinks( + size: Int = 10, + page: Int = 0, + sort: LinksSort = LinksSort.RECENT, + ): PokitResult> { + return repository.getUncategorizedLinks( + size = size, + page = page, + sort = sort + ) + } } diff --git a/domain/src/main/java/pokitmons/pokit/domain/usecase/link/ModifyLinkUseCase.kt b/domain/src/main/java/pokitmons/pokit/domain/usecase/link/ModifyLinkUseCase.kt new file mode 100644 index 00000000..cbe1ba72 --- /dev/null +++ b/domain/src/main/java/pokitmons/pokit/domain/usecase/link/ModifyLinkUseCase.kt @@ -0,0 +1,29 @@ +package pokitmons.pokit.domain.usecase.link + +import pokitmons.pokit.domain.commom.PokitResult +import pokitmons.pokit.domain.repository.link.LinkRepository +import javax.inject.Inject + +class ModifyLinkUseCase @Inject constructor( + private val repository: LinkRepository, +) { + suspend fun modifyLink( + linkId: Int, + data: String, + title: String, + categoryId: Int, + memo: String, + alertYn: String, + thumbNail: String, + ): PokitResult { + return repository.modifyLink( + linkId = linkId, + data = data, + title = title, + categoryId = categoryId, + memo = memo, + alertYn = alertYn, + thumbNail = thumbNail + ) + } +} diff --git a/domain/src/main/java/pokitmons/pokit/domain/usecase/link/SearchLinksUseCase.kt b/domain/src/main/java/pokitmons/pokit/domain/usecase/link/SearchLinksUseCase.kt new file mode 100644 index 00000000..3e213ebb --- /dev/null +++ b/domain/src/main/java/pokitmons/pokit/domain/usecase/link/SearchLinksUseCase.kt @@ -0,0 +1,34 @@ +package pokitmons.pokit.domain.usecase.link + +import pokitmons.pokit.domain.commom.PokitResult +import pokitmons.pokit.domain.model.link.Link +import pokitmons.pokit.domain.repository.link.LinkRepository +import javax.inject.Inject + +class SearchLinksUseCase @Inject constructor( + private val repository: LinkRepository, +) { + suspend fun searchLinks( + page: Int, + size: Int, + sort: List, + isRead: Boolean, + favorites: Boolean, + startDate: String?, + endDate: String?, + categoryIds: List?, + searchWord: String, + ): PokitResult> { + return repository.searchLinks( + page = page, + size = size, + sort = sort, + isRead = isRead, + favorites = favorites, + startDate = startDate, + endDate = endDate, + categoryIds = categoryIds, + searchWord = searchWord + ) + } +} diff --git a/domain/src/main/java/pokitmons/pokit/domain/usecase/link/SetBookmarkUseCase.kt b/domain/src/main/java/pokitmons/pokit/domain/usecase/link/SetBookmarkUseCase.kt new file mode 100644 index 00000000..c7e9cee3 --- /dev/null +++ b/domain/src/main/java/pokitmons/pokit/domain/usecase/link/SetBookmarkUseCase.kt @@ -0,0 +1,13 @@ +package pokitmons.pokit.domain.usecase.link + +import pokitmons.pokit.domain.commom.PokitResult +import pokitmons.pokit.domain.repository.link.LinkRepository +import javax.inject.Inject + +class SetBookmarkUseCase @Inject constructor( + private val repository: LinkRepository, +) { + suspend fun setBookMarked(linkId: Int, bookmarked: Boolean): PokitResult { + return repository.setBookmark(linkId = linkId, bookmarked = bookmarked) + } +} diff --git a/domain/src/main/java/pokitmons/pokit/domain/usecase/search/AddRecentSearchWordUseCase.kt b/domain/src/main/java/pokitmons/pokit/domain/usecase/search/AddRecentSearchWordUseCase.kt new file mode 100644 index 00000000..c63a11a4 --- /dev/null +++ b/domain/src/main/java/pokitmons/pokit/domain/usecase/search/AddRecentSearchWordUseCase.kt @@ -0,0 +1,12 @@ +package pokitmons.pokit.domain.usecase.search + +import pokitmons.pokit.domain.repository.search.SearchRepository +import javax.inject.Inject + +class AddRecentSearchWordUseCase @Inject constructor( + private val repository: SearchRepository, +) { + suspend fun addRecentSearchWord(word: String) { + repository.addRecentSearchWord(word) + } +} diff --git a/domain/src/main/java/pokitmons/pokit/domain/usecase/search/GetRecentSearchWordsUseCase.kt b/domain/src/main/java/pokitmons/pokit/domain/usecase/search/GetRecentSearchWordsUseCase.kt new file mode 100644 index 00000000..b7034c48 --- /dev/null +++ b/domain/src/main/java/pokitmons/pokit/domain/usecase/search/GetRecentSearchWordsUseCase.kt @@ -0,0 +1,13 @@ +package pokitmons.pokit.domain.usecase.search + +import kotlinx.coroutines.flow.Flow +import pokitmons.pokit.domain.repository.search.SearchRepository +import javax.inject.Inject + +class GetRecentSearchWordsUseCase @Inject constructor( + private val repository: SearchRepository, +) { + fun getWords(): Flow> { + return repository.getRecentSearchWords() + } +} diff --git a/domain/src/main/java/pokitmons/pokit/domain/usecase/search/GetUseRecentSearchWordsUseCase.kt b/domain/src/main/java/pokitmons/pokit/domain/usecase/search/GetUseRecentSearchWordsUseCase.kt new file mode 100644 index 00000000..da55a3fc --- /dev/null +++ b/domain/src/main/java/pokitmons/pokit/domain/usecase/search/GetUseRecentSearchWordsUseCase.kt @@ -0,0 +1,13 @@ +package pokitmons.pokit.domain.usecase.search + +import kotlinx.coroutines.flow.Flow +import pokitmons.pokit.domain.repository.search.SearchRepository +import javax.inject.Inject + +class GetUseRecentSearchWordsUseCase @Inject constructor( + private val repository: SearchRepository, +) { + fun getUse(): Flow { + return repository.getUseRecentSearchWord() + } +} diff --git a/domain/src/main/java/pokitmons/pokit/domain/usecase/search/RemoveRecentSearchWordUseCase.kt b/domain/src/main/java/pokitmons/pokit/domain/usecase/search/RemoveRecentSearchWordUseCase.kt new file mode 100644 index 00000000..1745d408 --- /dev/null +++ b/domain/src/main/java/pokitmons/pokit/domain/usecase/search/RemoveRecentSearchWordUseCase.kt @@ -0,0 +1,16 @@ +package pokitmons.pokit.domain.usecase.search + +import pokitmons.pokit.domain.repository.search.SearchRepository +import javax.inject.Inject + +class RemoveRecentSearchWordUseCase @Inject constructor( + private val repository: SearchRepository, +) { + suspend fun removeWord(word: String) { + repository.removeSearchWord(word) + } + + suspend fun removeAll() { + repository.removeAllSearchWords() + } +} diff --git a/domain/src/main/java/pokitmons/pokit/domain/usecase/search/SetUseRecentSearchWordsUseCase.kt b/domain/src/main/java/pokitmons/pokit/domain/usecase/search/SetUseRecentSearchWordsUseCase.kt new file mode 100644 index 00000000..25c7983e --- /dev/null +++ b/domain/src/main/java/pokitmons/pokit/domain/usecase/search/SetUseRecentSearchWordsUseCase.kt @@ -0,0 +1,12 @@ +package pokitmons.pokit.domain.usecase.search + +import pokitmons.pokit.domain.repository.search.SearchRepository +import javax.inject.Inject + +class SetUseRecentSearchWordsUseCase @Inject constructor( + private val repository: SearchRepository, +) { + suspend fun setUse(use: Boolean): Boolean { + return repository.setUseRecentSearchWord(use) + } +} diff --git a/feature/addlink/build.gradle.kts b/feature/addlink/build.gradle.kts index f86c99bc..ff240027 100644 --- a/feature/addlink/build.gradle.kts +++ b/feature/addlink/build.gradle.kts @@ -62,9 +62,13 @@ dependencies { implementation(libs.orbit.core) implementation(libs.orbit.viewmodel) + // coil + implementation(libs.coil.compose) + // hilt implementation(libs.hilt) kapt(libs.hilt.compiler) implementation(project(":core:ui")) + implementation(project(":domain")) } diff --git a/feature/addlink/src/main/java/com/strayalpaca/addlink/AddLinkScreen.kt b/feature/addlink/src/main/java/com/strayalpaca/addlink/AddLinkScreen.kt index 998b2298..41ec8b89 100644 --- a/feature/addlink/src/main/java/com/strayalpaca/addlink/AddLinkScreen.kt +++ b/feature/addlink/src/main/java/com/strayalpaca/addlink/AddLinkScreen.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Text @@ -20,6 +21,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -32,8 +34,8 @@ import com.strayalpaca.addlink.components.block.Link import com.strayalpaca.addlink.components.block.Toolbar import com.strayalpaca.addlink.model.AddLinkScreenSideEffect import com.strayalpaca.addlink.model.AddLinkScreenState -import com.strayalpaca.addlink.model.Pokit import com.strayalpaca.addlink.model.ScreenStep +import com.strayalpaca.addlink.paging.SimplePagingState import com.strayalpaca.addlink.utils.BackPressHandler import org.orbitmvi.orbit.compose.collectAsState import org.orbitmvi.orbit.compose.collectSideEffect @@ -53,7 +55,6 @@ import pokitmons.pokit.core.ui.theme.PokitTheme @Composable fun AddLinkScreenContainer( - linkId: String?, viewModel: AddLinkViewModel, onBackPressed: () -> Unit, onNavigateToAddPokit: () -> Unit, @@ -63,12 +64,6 @@ fun AddLinkScreenContainer( BackPressHandler(onBackPressed = viewModel::onBackPressed) - LaunchedEffect(Unit) { - linkId?.let { - viewModel.loadPokitLink(it) - } - } - viewModel.collectSideEffect { sideEffect -> when (sideEffect) { AddLinkScreenSideEffect.AddLinkSuccess -> { @@ -88,25 +83,60 @@ fun AddLinkScreenContainer( val url by viewModel.linkUrl.collectAsState() val title by viewModel.title.collectAsState() val memo by viewModel.memo.collectAsState() - val pokitName by viewModel.pokitName.collectAsState() + val pokitList by viewModel.pokitList.collectAsState() + val pokitListState by viewModel.pokitListState.collectAsState() + + PokitBottomSheet( + onHideBottomSheet = viewModel::hideSelectPokitBottomSheet, + show = state.step == ScreenStep.POKIT_SELECT + ) { + val lazyColumnListState = rememberLazyListState() + val startPaging = remember { + derivedStateOf { + lazyColumnListState.layoutInfo.visibleItemsInfo.lastOrNull()?.let { last -> + last.index >= lazyColumnListState.layoutInfo.totalItemsCount - 3 + } ?: false + } + } + + LaunchedEffect(Unit) { + viewModel.refreshPokits() + } + + LaunchedEffect(startPaging.value) { + if (startPaging.value && pokitListState == SimplePagingState.IDLE) { + viewModel.loadNextPokits() + } + } + + LazyColumn( + state = lazyColumnListState + ) { + items( + items = pokitList + ) { pokit -> + PokitList( + item = pokit, + title = pokit.title, + sub = stringResource(id = R.string.count_format, pokit.count), + onClickItem = viewModel::selectPokit, + state = PokitListState.ACTIVE + ) + } + } + } AddLinkScreen( - isModifyLink = (linkId != null), + isModifyLink = (viewModel.currentLinkId != null), url = url, title = title, memo = memo, state = state, - pokitName = pokitName, inputUrl = viewModel::inputLinkUrl, inputTitle = viewModel::inputTitle, inputMemo = viewModel::inputMemo, - inputNewPokitName = viewModel::inputNewPokitName, onClickAddPokit = onNavigateToAddPokit, - onClickSavePokit = viewModel::savePokit, - dismissPokitAddBottomSheet = viewModel::hideAddPokitBottomSheet, onClickSelectPokit = viewModel::showSelectPokitBottomSheet, - onClickSelectPokitItem = viewModel::selectPokit, - dismissPokitSelectBottomSheet = viewModel::hideSelectPokitBottomSheet, toggleRemindRadio = viewModel::setRemind, onBackPressed = viewModel::onBackPressed, onClickSaveButton = viewModel::saveLink @@ -120,18 +150,12 @@ fun AddLinkScreen( url: String, title: String, memo: String, - pokitName: String, state: AddLinkScreenState, inputUrl: (String) -> Unit, inputTitle: (String) -> Unit, inputMemo: (String) -> Unit, - inputNewPokitName: (String) -> Unit, onClickAddPokit: () -> Unit, - onClickSavePokit: () -> Unit, - dismissPokitAddBottomSheet: () -> Unit, onClickSelectPokit: () -> Unit, - onClickSelectPokitItem: (Pokit) -> Unit, - dismissPokitSelectBottomSheet: () -> Unit, toggleRemindRadio: (Boolean) -> Unit, onBackPressed: () -> Unit, onClickSaveButton: () -> Unit, @@ -141,8 +165,7 @@ fun AddLinkScreen( !( state.step == ScreenStep.SAVE_LOADING || state.step == ScreenStep.LOADING || - state.step == ScreenStep.POKIT_ADD_LOADING || - state.step == ScreenStep.LINK_LOADING + state.step == ScreenStep.POKIT_ADD_LOADING ) } @@ -294,52 +317,5 @@ fun AddLinkScreen( ) } } - - PokitBottomSheet( - onHideBottomSheet = dismissPokitSelectBottomSheet, - show = state.step == ScreenStep.POKIT_SELECT - ) { - LazyColumn { - items( - items = state.pokitList - ) { pokit -> - PokitList( - item = pokit, - title = pokit.title, - sub = stringResource(id = R.string.count_format, pokit.count), - onClickItem = onClickSelectPokitItem, - state = PokitListState.ACTIVE - ) - } - } - } - - PokitBottomSheet( - onHideBottomSheet = dismissPokitAddBottomSheet, - show = state.step == ScreenStep.POKIT_ADD - ) { - Column( - modifier = Modifier.padding(horizontal = 20.dp) - ) { - LabeledInput( - label = "", - inputText = pokitName, - hintText = stringResource(id = R.string.placeholder_input_pokit_name), - onChangeText = inputNewPokitName, - maxLength = 10 - ) - - Spacer(modifier = Modifier.height(12.dp)) - - PokitButton( - text = stringResource(id = R.string.add), - icon = null, - onClick = onClickSavePokit, - modifier = Modifier.fillMaxWidth(), - size = PokitButtonSize.LARGE, - enable = pokitName.isNotEmpty() - ) - } - } } } diff --git a/feature/addlink/src/main/java/com/strayalpaca/addlink/AddLinkViewModel.kt b/feature/addlink/src/main/java/com/strayalpaca/addlink/AddLinkViewModel.kt index 88b24f77..74d41475 100644 --- a/feature/addlink/src/main/java/com/strayalpaca/addlink/AddLinkViewModel.kt +++ b/feature/addlink/src/main/java/com/strayalpaca/addlink/AddLinkViewModel.kt @@ -1,13 +1,16 @@ package com.strayalpaca.addlink +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.strayalpaca.addlink.model.AddLinkScreenSideEffect import com.strayalpaca.addlink.model.AddLinkScreenState +import com.strayalpaca.addlink.model.Link import com.strayalpaca.addlink.model.Pokit import com.strayalpaca.addlink.model.ScreenStep -import com.strayalpaca.addlink.model.sampleLink -import com.strayalpaca.addlink.model.samplePokitList +import com.strayalpaca.addlink.model.ToastMessageEvent +import com.strayalpaca.addlink.paging.PokitPaging +import com.strayalpaca.addlink.paging.SimplePagingState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -23,12 +26,35 @@ import org.orbitmvi.orbit.syntax.simple.intent import org.orbitmvi.orbit.syntax.simple.postSideEffect import org.orbitmvi.orbit.syntax.simple.reduce import org.orbitmvi.orbit.viewmodel.container +import pokitmons.pokit.domain.commom.PokitResult +import pokitmons.pokit.domain.usecase.link.CreateLinkUseCase +import pokitmons.pokit.domain.usecase.link.GetLinkCardUseCase +import pokitmons.pokit.domain.usecase.link.GetLinkUseCase +import pokitmons.pokit.domain.usecase.link.ModifyLinkUseCase +import pokitmons.pokit.domain.usecase.pokit.GetPokitsUseCase import javax.inject.Inject @HiltViewModel -class AddLinkViewModel @Inject constructor() : ContainerHost, ViewModel() { +class AddLinkViewModel @Inject constructor( + private val getLinkUseCase: GetLinkUseCase, + private val getLinkCardUseCase: GetLinkCardUseCase, + private val createLinkUseCase: CreateLinkUseCase, + private val modifyLinkUseCase: ModifyLinkUseCase, + getPokitsUseCase: GetPokitsUseCase, + savedStateHandle: SavedStateHandle, +) : ContainerHost, ViewModel() { override val container: Container = container(AddLinkScreenState()) + private val pokitPaging = PokitPaging( + getPokits = getPokitsUseCase, + perPage = 10, + coroutineScope = viewModelScope, + initPage = 0 + ) + + val pokitList: StateFlow> = pokitPaging.pagingData + val pokitListState: StateFlow = pokitPaging.pagingState + private val _linkUrl = MutableStateFlow("") val linkUrl: StateFlow = _linkUrl.asStateFlow() @@ -38,38 +64,36 @@ class AddLinkViewModel @Inject constructor() : ContainerHost = _memo.asStateFlow() - private val _pokitName = MutableStateFlow("") - val pokitName: StateFlow = _pokitName.asStateFlow() + val currentLinkId: Int? = savedStateHandle.get("link_id")?.toIntOrNull() init { - loadPokitList() + currentLinkId?.let { linkId -> + loadPokitLink(linkId) + } } private var inputLinkJob: Job? = null - private fun loadPokitList() = intent { + private fun loadPokitLink(linkId: Int) = intent { viewModelScope.launch(Dispatchers.IO) { reduce { state.copy(step = ScreenStep.LOADING) } - // todo 포킷 목록 가져오기 api 연결 - delay(1000L) - reduce { - state.copy( - step = ScreenStep.IDLE, - pokitList = samplePokitList - ) - } - } - } - - fun loadPokitLink(linkId: String) = intent { - viewModelScope.launch(Dispatchers.IO) { - reduce { state.copy(step = ScreenStep.LOADING) } - // todo 포킷 링크 가져오기 api 연결 - delay(1000L) - reduce { - state.copy( - step = ScreenStep.IDLE - ) + val response = getLinkUseCase.getLink(linkId) + if (response is PokitResult.Success) { + val responseResult = response.result + reduce { + state.copy( + link = Link.fromDomainLink(responseResult), + useRemind = responseResult.alertYn == "Y", + currentPokit = Pokit( + title = responseResult.categoryName, + id = responseResult.categoryId.toString(), + count = 0 + ), + step = ScreenStep.IDLE + ) + } + } else { + postSideEffect(AddLinkScreenSideEffect.OnNavigationBack) } } } @@ -81,10 +105,14 @@ class AddLinkViewModel @Inject constructor() : ContainerHost = emptyList(), val useRemind: Boolean = false, val step: ScreenStep = ScreenStep.IDLE, ) diff --git a/feature/addlink/src/main/java/com/strayalpaca/addlink/model/Link.kt b/feature/addlink/src/main/java/com/strayalpaca/addlink/model/Link.kt index b05521d4..361cfcb0 100644 --- a/feature/addlink/src/main/java/com/strayalpaca/addlink/model/Link.kt +++ b/feature/addlink/src/main/java/com/strayalpaca/addlink/model/Link.kt @@ -1,9 +1,28 @@ package com.strayalpaca.addlink.model +import pokitmons.pokit.domain.model.link.Link as DomainLink +import pokitmons.pokit.domain.model.link.LinkCard as DomainLinkCard + data class Link( val url: String, val title: String, val imageUrl: String?, -) +) { + companion object { + fun fromDomainLink(domainLink: DomainLink): Link { + return Link( + url = domainLink.data, + title = domainLink.title, + imageUrl = domainLink.thumbnail + ) + } -internal val sampleLink = Link(url = "https://pokit.com/watch?v=xSTwqkUyM8k", title = "자연 친화적인 라이프스타일을 위한 환경 보호 방법", imageUrl = null) + fun fromDomainLinkCard(domainLinkCard: DomainLinkCard): Link { + return Link( + url = domainLinkCard.url, + title = domainLinkCard.title, + imageUrl = domainLinkCard.thumbnailUrl + ) + } + } +} diff --git a/feature/addlink/src/main/java/com/strayalpaca/addlink/model/Pokit.kt b/feature/addlink/src/main/java/com/strayalpaca/addlink/model/Pokit.kt index 0088afe6..79c6450d 100644 --- a/feature/addlink/src/main/java/com/strayalpaca/addlink/model/Pokit.kt +++ b/feature/addlink/src/main/java/com/strayalpaca/addlink/model/Pokit.kt @@ -1,15 +1,19 @@ package com.strayalpaca.addlink.model +import pokitmons.pokit.domain.model.pokit.Pokit as DomainPokit + data class Pokit( val title: String, val id: String, val count: Int, -) - -internal val samplePokitList = listOf( - Pokit(title = "안드로이드", id = "1", count = 2), - Pokit(title = "IOS", id = "2", count = 2), - Pokit(title = "디자인", id = "3", count = 2), - Pokit(title = "PM", id = "4", count = 1), - Pokit(title = "서버", id = "5", count = 2) -) +) { + companion object { + fun fromDomainPokit(pokit: DomainPokit): Pokit { + return Pokit( + title = pokit.name, + id = pokit.categoryId.toString(), + count = pokit.linkCount + ) + } + } +} diff --git a/feature/addlink/src/main/java/com/strayalpaca/addlink/paging/PokitPaging.kt b/feature/addlink/src/main/java/com/strayalpaca/addlink/paging/PokitPaging.kt new file mode 100644 index 00000000..aeeed146 --- /dev/null +++ b/feature/addlink/src/main/java/com/strayalpaca/addlink/paging/PokitPaging.kt @@ -0,0 +1,124 @@ +package com.strayalpaca.addlink.paging + +import com.strayalpaca.addlink.model.Pokit +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import pokitmons.pokit.domain.commom.PokitResult +import pokitmons.pokit.domain.usecase.pokit.GetPokitsUseCase +import kotlin.coroutines.cancellation.CancellationException + +class PokitPaging( + private val getPokits: GetPokitsUseCase, + private val perPage: Int = 10, + private val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.IO), + private val initPage: Int = 0, + private val firstRequestPage: Int = 3, +) : SimplePaging { + private val _pagingState = MutableStateFlow(SimplePagingState.IDLE) + override val pagingState: StateFlow = _pagingState.asStateFlow() + + private val _pagingData: MutableStateFlow> = MutableStateFlow(emptyList()) + override val pagingData: StateFlow> = _pagingData.asStateFlow() + private var currentPageIndex = initPage + private var requestJob: Job? = null + + override suspend fun refresh() { + requestJob?.cancel() + + _pagingData.update { emptyList() } + _pagingState.update { SimplePagingState.LOADING_INIT } + requestJob = coroutineScope.launch { + try { + currentPageIndex = initPage + val response = getPokits.getPokits(size = perPage * firstRequestPage, page = currentPageIndex) + when (response) { + is PokitResult.Success -> { + val pokitList = response.result.map { domainPokit -> + Pokit.fromDomainPokit(domainPokit) + } + applyResponse(pokitList, firstRequestPage) + } + + is PokitResult.Error -> { + _pagingState.update { SimplePagingState.FAILURE_INIT } + } + } + } catch (exception: Exception) { + if (exception !is CancellationException) { + _pagingState.update { SimplePagingState.FAILURE_INIT } + } + } + } + } + + override suspend fun load() { + if (pagingState.value != SimplePagingState.IDLE) return + + requestJob?.cancel() + _pagingState.update { SimplePagingState.LOADING_NEXT } + + requestJob = coroutineScope.launch { + try { + val response = getPokits.getPokits(size = perPage, page = currentPageIndex) + when (response) { + is PokitResult.Success -> { + val pokitList = response.result.map { domainPokit -> + Pokit.fromDomainPokit(domainPokit) + } + applyResponse(pokitList) + } + + is PokitResult.Error -> { + _pagingState.update { SimplePagingState.FAILURE_NEXT } + } + } + } catch (exception: Exception) { + if (exception !is CancellationException) { + _pagingState.update { SimplePagingState.FAILURE_NEXT } + } + } + } + } + + private fun applyResponse(dataInResponse: List, multiple: Int = 1) { + if (dataInResponse.size < perPage * multiple) { + _pagingState.update { SimplePagingState.LAST } + } else { + _pagingState.update { SimplePagingState.IDLE } + } + _pagingData.update { _pagingData.value + dataInResponse } + currentPageIndex += multiple + } + + override fun clear() { + requestJob?.cancel() + _pagingData.update { emptyList() } + _pagingState.update { SimplePagingState.IDLE } + } + + override suspend fun deleteItem(item: Pokit) { + val capturedDataList = _pagingData.value + _pagingData.update { capturedDataList.filter { it.id != item.id } } + } + + override suspend fun modifyItem(item: Pokit) { + val capturedDataList = _pagingData.value + val targetPokit = capturedDataList.find { it.id == item.id } ?: return + + _pagingData.update { + capturedDataList.map { pokit -> + if (targetPokit.id == pokit.id) { + item + } else { + pokit + } + } + } + } +} diff --git a/feature/addlink/src/main/java/com/strayalpaca/addlink/paging/SimplePaging.kt b/feature/addlink/src/main/java/com/strayalpaca/addlink/paging/SimplePaging.kt new file mode 100644 index 00000000..e89daa6a --- /dev/null +++ b/feature/addlink/src/main/java/com/strayalpaca/addlink/paging/SimplePaging.kt @@ -0,0 +1,13 @@ +package com.strayalpaca.addlink.paging + +import kotlinx.coroutines.flow.Flow + +interface SimplePaging { + val pagingData: Flow> + suspend fun refresh() + suspend fun load() + val pagingState: Flow + suspend fun modifyItem(item: T) + suspend fun deleteItem(item: T) + fun clear() +} diff --git a/feature/addlink/src/main/java/com/strayalpaca/addlink/paging/SimplePagingState.kt b/feature/addlink/src/main/java/com/strayalpaca/addlink/paging/SimplePagingState.kt new file mode 100644 index 00000000..4c1bfc2c --- /dev/null +++ b/feature/addlink/src/main/java/com/strayalpaca/addlink/paging/SimplePagingState.kt @@ -0,0 +1,5 @@ +package com.strayalpaca.addlink.paging + +enum class SimplePagingState { + IDLE, LOADING_NEXT, LOADING_INIT, FAILURE_NEXT, FAILURE_INIT, LAST +} diff --git a/feature/addpokit/src/main/java/com/strayalpaca/addpokit/paging/PokitPaging.kt b/feature/addpokit/src/main/java/com/strayalpaca/addpokit/paging/PokitPaging.kt index c7d64d91..e2661e50 100644 --- a/feature/addpokit/src/main/java/com/strayalpaca/addpokit/paging/PokitPaging.kt +++ b/feature/addpokit/src/main/java/com/strayalpaca/addpokit/paging/PokitPaging.kt @@ -114,7 +114,7 @@ class PokitPaging( _pagingData.update { capturedDataList.map { pokit -> if (targetPokit.id == pokit.id) { - targetPokit + item } else { pokit } diff --git a/feature/pokitdetail/src/main/java/com/strayalpaca/pokitdetail/paging/LinkPaging.kt b/feature/pokitdetail/src/main/java/com/strayalpaca/pokitdetail/paging/LinkPaging.kt index bec12bcd..89f19005 100644 --- a/feature/pokitdetail/src/main/java/com/strayalpaca/pokitdetail/paging/LinkPaging.kt +++ b/feature/pokitdetail/src/main/java/com/strayalpaca/pokitdetail/paging/LinkPaging.kt @@ -125,7 +125,7 @@ class LinkPaging( _pagingData.update { capturedDataList.map { pokit -> if (targetPokit.id == pokit.id) { - targetPokit + item } else { pokit } diff --git a/feature/pokitdetail/src/main/java/com/strayalpaca/pokitdetail/paging/PokitPaging.kt b/feature/pokitdetail/src/main/java/com/strayalpaca/pokitdetail/paging/PokitPaging.kt index b36b1b63..ca171162 100644 --- a/feature/pokitdetail/src/main/java/com/strayalpaca/pokitdetail/paging/PokitPaging.kt +++ b/feature/pokitdetail/src/main/java/com/strayalpaca/pokitdetail/paging/PokitPaging.kt @@ -114,7 +114,7 @@ class PokitPaging( _pagingData.update { capturedDataList.map { pokit -> if (targetPokit.id == pokit.id) { - targetPokit + item } else { pokit } diff --git a/feature/search/build.gradle.kts b/feature/search/build.gradle.kts index 1387bd7f..e18eb426 100644 --- a/feature/search/build.gradle.kts +++ b/feature/search/build.gradle.kts @@ -67,4 +67,5 @@ dependencies { kapt(libs.hilt.compiler) implementation(project(":core:ui")) + implementation(project(":domain")) } diff --git a/feature/search/src/main/java/pokitmons/pokit/search/Preview.kt b/feature/search/src/main/java/pokitmons/pokit/search/Preview.kt index d549c85f..b3a10829 100644 --- a/feature/search/src/main/java/pokitmons/pokit/search/Preview.kt +++ b/feature/search/src/main/java/pokitmons/pokit/search/Preview.kt @@ -6,6 +6,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import pokitmons.pokit.core.ui.theme.PokitTheme +import pokitmons.pokit.search.model.Link +import pokitmons.pokit.search.model.LinkType @Preview(showBackground = true) @Composable @@ -14,7 +16,72 @@ private fun Preview() { Column( modifier = Modifier.fillMaxSize() ) { - SearchScreen() + SearchScreen( + linkList = sampleLinks + ) } } } + +internal val sampleLinks = listOf( + Link( + id = "1", + title = "자연 친화적인 라이프스타일을 위한 환경 보호 방법", + imageUrl = null, + dateString = "2024.04.12", + domainUrl = "youtu.be", + isRead = true, + linkType = LinkType.TEXT, + url = "", + memo = "", + bookmark = true + ), + Link( + id = "2", + title = "자연 친화적인 라이프스타일을 위한 환경 보호 방법", + imageUrl = null, + dateString = "2024.05.12", + domainUrl = "youtu.be", + isRead = false, + linkType = LinkType.TEXT, + url = "", + memo = "", + bookmark = true + ), + Link( + id = "3", + title = "포킷포킷", + imageUrl = null, + dateString = "2024.04.12", + domainUrl = "pokitmons.pokit", + isRead = true, + linkType = LinkType.TEXT, + url = "", + memo = "", + bookmark = true + ), + Link( + id = "4", + title = "자연 친화적인 라이프스타일을 위한 환경 보호 방법", + imageUrl = null, + dateString = "2024.06.12", + domainUrl = "youtu.be", + isRead = true, + linkType = LinkType.TEXT, + url = "", + memo = "", + bookmark = true + ), + Link( + id = "5", + title = "마지막 링크입니다.", + imageUrl = null, + dateString = "2024.07.14", + domainUrl = "youtu.be", + isRead = false, + linkType = LinkType.TEXT, + url = "", + memo = "", + bookmark = true + ) +) diff --git a/feature/search/src/main/java/pokitmons/pokit/search/SearchScreen.kt b/feature/search/src/main/java/pokitmons/pokit/search/SearchScreen.kt index 358aa676..ee579efd 100644 --- a/feature/search/src/main/java/pokitmons/pokit/search/SearchScreen.kt +++ b/feature/search/src/main/java/pokitmons/pokit/search/SearchScreen.kt @@ -9,20 +9,25 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import pokitmons.pokit.core.ui.components.template.bottomsheet.PokitBottomSheet import pokitmons.pokit.core.ui.components.template.modifybottomsheet.ModifyBottomSheetContent +import pokitmons.pokit.core.ui.components.template.removeItemBottomSheet.TwoButtonBottomSheetContent import pokitmons.pokit.core.ui.theme.PokitTheme import pokitmons.pokit.search.components.filter.FilterArea import pokitmons.pokit.search.components.filterbottomsheet.FilterBottomSheet +import pokitmons.pokit.search.components.linkdetailbottomsheet.LinkDetailBottomSheet import pokitmons.pokit.search.components.recentsearchword.RecentSearchWord import pokitmons.pokit.search.components.searchitemlist.SearchItemList import pokitmons.pokit.search.components.toolbar.Toolbar +import pokitmons.pokit.search.model.BottomSheetType import pokitmons.pokit.search.model.Filter import pokitmons.pokit.search.model.FilterType import pokitmons.pokit.search.model.Link import pokitmons.pokit.search.model.SearchScreenState import pokitmons.pokit.search.model.SearchScreenStep +import pokitmons.pokit.search.paging.SimplePagingState @Composable fun SearchScreenContainer( @@ -33,11 +38,79 @@ fun SearchScreenContainer( val state by viewModel.state.collectAsState() val searchWord by viewModel.searchWord.collectAsState() val linkList by viewModel.linkList.collectAsState() + val linkPagingState by viewModel.linkPagingState.collectAsState() + val pokitList by viewModel.pokitList.collectAsState() + val pokitPagingState by viewModel.pokitPagingState.collectAsState() + + LinkDetailBottomSheet( + link = state.currentLink ?: Link(), + onHideBottomSheet = viewModel::hideLinkDetailBottomSheet, + show = state.showLinkDetailBottomSheet, + onClickModifyLink = remember { + { link -> + viewModel.hideLinkDetailBottomSheet() + onNavigateToLinkModify(link.id) + } + }, + onClickRemoveLink = viewModel::showLinkRemoveBottomSheet, + onClickBookmark = viewModel::toggleBookmark + ) + + FilterBottomSheet( + filter = state.filter ?: Filter(), + firstShowType = state.firstBottomSheetFilterType, + show = state.showFilterBottomSheet, + onDismissRequest = viewModel::hideFilterBottomSheet, + onSaveClilck = viewModel::setFilter, + pokits = pokitList, + pokitPagingState = pokitPagingState, + loadNextPokits = viewModel::loadNextPokits, + refreshPokits = viewModel::refreshPokits + ) + + PokitBottomSheet( + onHideBottomSheet = viewModel::hideLinkModifyBottomSheet, + show = state.linkBottomSheetType != null + ) { + if (state.linkBottomSheetType == BottomSheetType.MODIFY) { + ModifyBottomSheetContent( + onClickModify = remember { + { + state.currentLink?.let { link -> + viewModel.hideLinkModifyBottomSheet() + onNavigateToLinkModify(link.id) + } + } + }, + onClickRemove = remember { + { + state.currentLink?.let { link -> + viewModel.showLinkRemoveBottomSheet(link) + } + } + }, + onClickShare = remember { + { + } + } + ) + } + + if (state.linkBottomSheetType == BottomSheetType.REMOVE) { + TwoButtonBottomSheetContent( + title = stringResource(id = R.string.title_remove_link), + subText = stringResource(id = R.string.sub_remove_link), + onClickLeftButton = viewModel::hideLinkModifyBottomSheet, + onClickRightButton = {} + ) + } + } SearchScreen( state = state, currentSearchWord = searchWord, linkList = linkList, + linkPagingState = linkPagingState, onClickBack = onBackPressed, inputSearchWord = viewModel::inputSearchWord, onClickSearch = viewModel::applyCurrentSearchWord, @@ -47,12 +120,10 @@ fun SearchScreenContainer( onClickRemoveRecentSearchWord = viewModel::removeRecentSearchWord, onClickFilterSelect = viewModel::showFilterBottomSheet, onClickFilterItem = viewModel::showFilterBottomSheetWithType, - hideBottomSheet = viewModel::hideFilterBottomSheet, - onClickFilterSave = viewModel::setFilter, toggleSortOrder = viewModel::toggleSortOrder, showLinkModifyBottomSheet = viewModel::showLinkModifyBottomSheet, - hideLinkModifyBottomSheet = viewModel::hideLinkModifyBottomSheet, - onClickLinkModify = onNavigateToLinkModify + showLinkDetailBottomSheet = viewModel::showLinkDetailBottomSheet, + loadNextLinks = viewModel::loadNextLinks ) } @@ -61,6 +132,7 @@ fun SearchScreen( state: SearchScreenState = SearchScreenState(), currentSearchWord: String = "", linkList: List = emptyList(), + linkPagingState: SimplePagingState = SimplePagingState.IDLE, onClickBack: () -> Unit = {}, inputSearchWord: (String) -> Unit = {}, onClickSearch: () -> Unit = {}, @@ -70,12 +142,10 @@ fun SearchScreen( onClickRemoveRecentSearchWord: (String) -> Unit = {}, onClickFilterSelect: () -> Unit = {}, onClickFilterItem: (FilterType) -> Unit = {}, - hideBottomSheet: () -> Unit = {}, - onClickFilterSave: (Filter) -> Unit = {}, toggleSortOrder: () -> Unit = {}, showLinkModifyBottomSheet: (Link) -> Unit = {}, - hideLinkModifyBottomSheet: () -> Unit = {}, - onClickLinkModify: (String) -> Unit = {}, + showLinkDetailBottomSheet: (Link) -> Unit = {}, + loadNextLinks: () -> Unit = {}, ) { Column( modifier = Modifier.fillMaxSize() @@ -122,31 +192,10 @@ fun SearchScreen( onToggleSort = toggleSortOrder, useRecentOrder = state.sortRecent, onClickLinkKebab = showLinkModifyBottomSheet, - links = linkList - ) - } - - FilterBottomSheet( - filter = state.filter ?: Filter(), - firstShowType = state.firstBottomSheetFilterType, - show = state.showFilterBottomSheet, - onDismissRequest = hideBottomSheet, - onSaveClilck = onClickFilterSave - ) - - PokitBottomSheet( - onHideBottomSheet = hideLinkModifyBottomSheet, - show = state.currentLink != null - ) { - ModifyBottomSheetContent( - onClickModify = remember { - { - state.currentLink?.let { link -> - hideLinkModifyBottomSheet() - onClickLinkModify(link.id) - } - } - } + onClickLink = showLinkDetailBottomSheet, + links = linkList, + linkPagingState = linkPagingState, + loadNextLinks = loadNextLinks ) } } diff --git a/feature/search/src/main/java/pokitmons/pokit/search/SearchViewModel.kt b/feature/search/src/main/java/pokitmons/pokit/search/SearchViewModel.kt index 9d6d2210..7bd984fd 100644 --- a/feature/search/src/main/java/pokitmons/pokit/search/SearchViewModel.kt +++ b/feature/search/src/main/java/pokitmons/pokit/search/SearchViewModel.kt @@ -1,35 +1,87 @@ package pokitmons.pokit.search import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import pokitmons.pokit.domain.commom.PokitResult +import pokitmons.pokit.domain.usecase.link.SearchLinksUseCase +import pokitmons.pokit.domain.usecase.link.SetBookmarkUseCase +import pokitmons.pokit.domain.usecase.pokit.GetPokitsUseCase +import pokitmons.pokit.domain.usecase.search.AddRecentSearchWordUseCase +import pokitmons.pokit.domain.usecase.search.GetRecentSearchWordsUseCase +import pokitmons.pokit.domain.usecase.search.GetUseRecentSearchWordsUseCase +import pokitmons.pokit.domain.usecase.search.RemoveRecentSearchWordUseCase +import pokitmons.pokit.domain.usecase.search.SetUseRecentSearchWordsUseCase +import pokitmons.pokit.search.model.BottomSheetType import pokitmons.pokit.search.model.Filter import pokitmons.pokit.search.model.FilterType import pokitmons.pokit.search.model.Link +import pokitmons.pokit.search.model.Pokit import pokitmons.pokit.search.model.SearchScreenState import pokitmons.pokit.search.model.SearchScreenStep -import pokitmons.pokit.search.model.sampleLinks +import pokitmons.pokit.search.paging.LinkPaging +import pokitmons.pokit.search.paging.PokitPaging +import pokitmons.pokit.search.paging.SimplePagingState import javax.inject.Inject @HiltViewModel -class SearchViewModel @Inject constructor() : ViewModel() { +class SearchViewModel @Inject constructor( + searchLinksUseCase: SearchLinksUseCase, + getPokitsUseCase: GetPokitsUseCase, + getRecentSearchWordsUseCase: GetRecentSearchWordsUseCase, + getUseRecentSearchWordsUseCase: GetUseRecentSearchWordsUseCase, + private val setUseRecentSearchWordsUseCase: SetUseRecentSearchWordsUseCase, + private val addRecentSearchWordUseCase: AddRecentSearchWordUseCase, + private val removeRecentSearchWordUseCase: RemoveRecentSearchWordUseCase, + private val setBookmarkUseCase: SetBookmarkUseCase, +) : ViewModel() { + private val linkPaging = LinkPaging( + searchLinksUseCase = searchLinksUseCase, + filter = Filter(), + perPage = 10, + coroutineScope = viewModelScope, + initPage = 0 + ) + + private val pokitPaging = PokitPaging( + getPokits = getPokitsUseCase, + perPage = 10, + coroutineScope = viewModelScope, + initPage = 0 + ) + + val linkList: StateFlow> = linkPaging.pagingData + val linkPagingState: StateFlow = linkPaging.pagingState + + val pokitList: StateFlow> = pokitPaging.pagingData + val pokitPagingState: StateFlow = pokitPaging.pagingState + private val _searchWord = MutableStateFlow("") val searchWord = _searchWord.asStateFlow() private val _state = MutableStateFlow(SearchScreenState()) - val state = _state.asStateFlow() + val state = combine( + _state, + getRecentSearchWordsUseCase.getWords(), + getUseRecentSearchWordsUseCase.getUse() + ) { state, searchWords, useRecentSearchWord -> + state.copy(recentSearchWords = searchWords, useRecentSearchWord = useRecentSearchWord) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.Eagerly, + initialValue = SearchScreenState() + ) private var appliedSearchWord = "" - private val _linkList = MutableStateFlow>(emptyList()) - val linkList = _linkList.asStateFlow() - - init { - _linkList.update { sampleLinks } - } - fun inputSearchWord(newSearchWord: String) { _searchWord.update { newSearchWord } val currentState = state.value @@ -48,6 +100,11 @@ class SearchViewModel @Inject constructor() : ViewModel() { _state.update { state -> state.copy(step = SearchScreenStep.RESULT) } + viewModelScope.launch { + addRecentSearchWordUseCase.addRecentSearchWord(appliedSearchWord) + linkPaging.changeSearchWord(appliedSearchWord) + linkPaging.refresh() + } } } @@ -59,26 +116,30 @@ class SearchViewModel @Inject constructor() : ViewModel() { _state.update { state -> state.copy(step = SearchScreenStep.RESULT) } + viewModelScope.launch { + addRecentSearchWordUseCase.addRecentSearchWord(appliedSearchWord) + linkPaging.changeSearchWord(appliedSearchWord) + linkPaging.refresh() + } } } fun toggleUseRecentSearchWord() { - _state.update { state -> - state.copy(useRecentSearchWord = !state.useRecentSearchWord) + val currentUseRecentSearchWord = state.value.useRecentSearchWord + viewModelScope.launch { + setUseRecentSearchWordsUseCase.setUse(!currentUseRecentSearchWord) } } fun removeRecentSearchWord(word: String) { - _state.update { state -> - state.copy(recentSearchWords = state.recentSearchWords.filter { name -> name != word }) + viewModelScope.launch { + removeRecentSearchWordUseCase.removeWord(word) } } fun removeAllRecentSearchWord() { - _state.update { state -> - state.copy( - recentSearchWords = emptyList() - ) + viewModelScope.launch { + removeRecentSearchWordUseCase.removeAll() } } @@ -110,6 +171,17 @@ class SearchViewModel @Inject constructor() : ViewModel() { fun showLinkModifyBottomSheet(link: Link) { _state.update { state -> state.copy( + linkBottomSheetType = BottomSheetType.MODIFY, + currentLink = link + ) + } + } + + fun showLinkRemoveBottomSheet(link: Link) { + _state.update { state -> + state.copy( + linkBottomSheetType = BottomSheetType.REMOVE, + showLinkDetailBottomSheet = false, currentLink = link ) } @@ -118,11 +190,31 @@ class SearchViewModel @Inject constructor() : ViewModel() { fun hideLinkModifyBottomSheet() { _state.update { state -> state.copy( + linkBottomSheetType = null, currentLink = null ) } } + fun showLinkDetailBottomSheet(link: Link) { + _state.update { state -> + state.copy( + currentLink = link, + showLinkDetailBottomSheet = true, + linkBottomSheetType = null + ) + } + } + + fun hideLinkDetailBottomSheet() { + _state.update { state -> + state.copy( + currentLink = null, + showLinkDetailBottomSheet = false + ) + } + } + fun setFilter(filter: Filter) { _state.update { state -> state.copy( @@ -135,7 +227,10 @@ class SearchViewModel @Inject constructor() : ViewModel() { ) } - // todo refresh 기능 구현 + viewModelScope.launch { + linkPaging.changeFilter(filter) + linkPaging.refresh() + } } fun toggleSortOrder() { @@ -143,6 +238,46 @@ class SearchViewModel @Inject constructor() : ViewModel() { state.copy(sortRecent = !state.sortRecent) } - // todo refresh 기능 구현 + viewModelScope.launch { + linkPaging.changeRecentSort(state.value.sortRecent) + linkPaging.refresh() + } + } + + fun loadNextLinks() { + viewModelScope.launch { + linkPaging.load() + } + } + + fun loadNextPokits() { + viewModelScope.launch { + pokitPaging.load() + } + } + + fun refreshPokits() { + viewModelScope.launch { + pokitPaging.refresh() + } + } + + fun toggleBookmark() { + val currentLink = state.value.currentLink ?: return + val currentLinkId = currentLink.id.toIntOrNull() ?: return + val applyBookmarked = !currentLink.bookmark + + viewModelScope.launch { + val response = setBookmarkUseCase.setBookMarked(currentLinkId, applyBookmarked) + if (response is PokitResult.Success) { + val bookmarkChangedLink = currentLink.copy(bookmark = applyBookmarked) + _state.update { state -> + state.copy( + currentLink = bookmarkChangedLink + ) + } + linkPaging.modifyItem(bookmarkChangedLink) + } + } } } diff --git a/feature/search/src/main/java/pokitmons/pokit/search/components/filterbottomsheet/FilterBottomSheet.kt b/feature/search/src/main/java/pokitmons/pokit/search/components/filterbottomsheet/FilterBottomSheet.kt index 9ceff05a..2d0540a3 100644 --- a/feature/search/src/main/java/pokitmons/pokit/search/components/filterbottomsheet/FilterBottomSheet.kt +++ b/feature/search/src/main/java/pokitmons/pokit/search/components/filterbottomsheet/FilterBottomSheet.kt @@ -1,11 +1,13 @@ package pokitmons.pokit.search.components.filterbottomsheet import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import pokitmons.pokit.core.ui.components.template.bottomsheet.PokitBottomSheet import pokitmons.pokit.search.model.Filter import pokitmons.pokit.search.model.FilterType import pokitmons.pokit.search.model.Pokit import pokitmons.pokit.search.model.samplePokits +import pokitmons.pokit.search.paging.SimplePagingState @Composable fun FilterBottomSheet( @@ -13,6 +15,9 @@ fun FilterBottomSheet( firstShowType: FilterType = FilterType.Pokit, onSaveClilck: (Filter) -> Unit = {}, pokits: List = samplePokits, + pokitPagingState: SimplePagingState = SimplePagingState.IDLE, + loadNextPokits: () -> Unit = {}, + refreshPokits: () -> Unit = {}, show: Boolean = false, onDismissRequest: () -> Unit = {}, ) { @@ -20,11 +25,17 @@ fun FilterBottomSheet( onHideBottomSheet = onDismissRequest, show = show ) { + LaunchedEffect(Unit) { + refreshPokits() + } + FilterBottomSheetContent( filter = filter, firstShowType = firstShowType, onSaveClilck = onSaveClilck, - pokits = pokits + pokits = pokits, + pokitPagingState = pokitPagingState, + loadNextPokits = loadNextPokits ) } } diff --git a/feature/search/src/main/java/pokitmons/pokit/search/components/filterbottomsheet/FilterBottomSheetContent.kt b/feature/search/src/main/java/pokitmons/pokit/search/components/filterbottomsheet/FilterBottomSheetContent.kt index 63584cd0..64e927d0 100644 --- a/feature/search/src/main/java/pokitmons/pokit/search/components/filterbottomsheet/FilterBottomSheetContent.kt +++ b/feature/search/src/main/java/pokitmons/pokit/search/components/filterbottomsheet/FilterBottomSheetContent.kt @@ -16,12 +16,15 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -45,6 +48,7 @@ import pokitmons.pokit.search.model.Filter import pokitmons.pokit.search.model.FilterType import pokitmons.pokit.search.model.Pokit import pokitmons.pokit.search.model.samplePokits +import pokitmons.pokit.search.paging.SimplePagingState import pokitmons.pokit.core.ui.R.string as coreString @OptIn(ExperimentalFoundationApi::class) @@ -54,6 +58,8 @@ fun FilterBottomSheetContent( firstShowType: FilterType = FilterType.Pokit, onSaveClilck: (Filter) -> Unit = {}, pokits: List = samplePokits, + pokitPagingState: SimplePagingState = SimplePagingState.IDLE, + loadNextPokits: () -> Unit = {}, ) { var currentFilter by remember { mutableStateOf(filter) } var currentShowType by remember { mutableStateOf(firstShowType) } @@ -64,6 +70,21 @@ fun FilterBottomSheetContent( FilterType.entries.size } + val linkLazyColumnListState = rememberLazyListState() + val startLinkPaging = remember { + derivedStateOf { + linkLazyColumnListState.layoutInfo.visibleItemsInfo.lastOrNull()?.let { last -> + last.index >= linkLazyColumnListState.layoutInfo.totalItemsCount - 3 + } ?: false + } + } + + LaunchedEffect(startLinkPaging.value) { + if (startLinkPaging.value && pokitPagingState == SimplePagingState.IDLE) { + loadNextPokits() + } + } + Column( modifier = Modifier.fillMaxWidth() ) { @@ -98,7 +119,8 @@ fun FilterBottomSheetContent( when (currentShowType) { FilterType.Pokit -> { LazyColumn( - modifier = Modifier.fillMaxSize() + modifier = Modifier.fillMaxSize(), + state = linkLazyColumnListState ) { items(pokits) { pokit -> PokitList( diff --git a/feature/search/src/main/java/pokitmons/pokit/search/components/linkdetailbottomsheet/Link.kt b/feature/search/src/main/java/pokitmons/pokit/search/components/linkdetailbottomsheet/Link.kt new file mode 100644 index 00000000..7535141e --- /dev/null +++ b/feature/search/src/main/java/pokitmons/pokit/search/components/linkdetailbottomsheet/Link.kt @@ -0,0 +1,70 @@ +package pokitmons.pokit.search.components.linkdetailbottomsheet + +import androidx.compose.foundation.Image +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import pokitmons.pokit.core.ui.theme.PokitTheme +import pokitmons.pokit.search.model.Link +import pokitmons.pokit.core.ui.R.drawable as coreDrawable + +@Composable +internal fun Link( + link: Link, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .height(IntrinsicSize.Min) + .border( + width = 1.dp, + color = PokitTheme.colors.borderTertiary, + shape = RoundedCornerShape(12.dp) + ) + ) { + Image( + painter = painterResource(id = coreDrawable.icon_24_google), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.width(124.dp) + ) + + Column( + modifier = Modifier + .padding(start = 16.dp, end = 20.dp, top = 16.dp, bottom = 16.dp) + .weight(1f) + ) { + Text( + modifier = Modifier.fillMaxWidth(), + text = link.title, + maxLines = 2, + style = PokitTheme.typography.body3Medium.copy(color = PokitTheme.colors.textSecondary) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + modifier = Modifier.fillMaxWidth(), + text = link.url, + maxLines = 2, + style = PokitTheme.typography.detail2.copy(color = PokitTheme.colors.textTertiary) + ) + } + } +} diff --git a/feature/search/src/main/java/pokitmons/pokit/search/components/linkdetailbottomsheet/LinkDetailBottomSheet.kt b/feature/search/src/main/java/pokitmons/pokit/search/components/linkdetailbottomsheet/LinkDetailBottomSheet.kt new file mode 100644 index 00000000..650065e0 --- /dev/null +++ b/feature/search/src/main/java/pokitmons/pokit/search/components/linkdetailbottomsheet/LinkDetailBottomSheet.kt @@ -0,0 +1,217 @@ +package pokitmons.pokit.search.components.linkdetailbottomsheet + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import pokitmons.pokit.core.ui.R +import pokitmons.pokit.core.ui.components.template.bottomsheet.PokitBottomSheet +import pokitmons.pokit.core.ui.theme.PokitTheme +import pokitmons.pokit.core.ui.theme.color.Orange50 +import pokitmons.pokit.search.model.Link + +@Composable +fun LinkDetailBottomSheet( + link: Link, + onHideBottomSheet: () -> Unit, + show: Boolean = false, + onClickRemoveLink: (Link) -> Unit, + onClickModifyLink: (Link) -> Unit, + onClickBookmark: () -> Unit, +) { + PokitBottomSheet( + onHideBottomSheet = onHideBottomSheet, + show = show + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(id = R.drawable.icon_24_bell), + contentDescription = null, + modifier = Modifier + .size(20.dp) + .background( + color = PokitTheme.colors.brand, + shape = CircleShape + ) + .padding(2.dp), + colorFilter = ColorFilter.tint(PokitTheme.colors.inverseWh) + ) + + Spacer(modifier = Modifier.width(4.dp)) + + Text( + text = stringResource(id = link.linkType.textResourceId), + modifier = Modifier + .border( + width = 1.dp, + color = PokitTheme.colors.borderTertiary, + shape = RoundedCornerShape(4.dp) + ) + .background( + color = PokitTheme.colors.backgroundBase, + shape = RoundedCornerShape(4.dp) + ) + .padding(horizontal = 8.dp, vertical = 4.dp), + style = PokitTheme.typography.label4.copy(color = PokitTheme.colors.textTertiary) + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = link.title, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + style = PokitTheme.typography.title3.copy(color = PokitTheme.colors.textPrimary) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + modifier = Modifier.fillMaxWidth(), + text = link.dateString, + style = PokitTheme.typography.detail2.copy(color = PokitTheme.colors.textTertiary), + textAlign = TextAlign.End + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + HorizontalDivider( + thickness = 1.dp, + color = PokitTheme.colors.borderTertiary + ) + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 24.dp) + ) { + Link(link = link) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = link.memo, + modifier = Modifier + .fillMaxWidth() + .background( + color = Orange50, + shape = RoundedCornerShape(8.dp) + ) + .padding(16.dp), + style = PokitTheme.typography.body3Regular.copy(color = PokitTheme.colors.textPrimary), + maxLines = 4, + minLines = 4 + ) + } + + HorizontalDivider( + thickness = 1.dp, + color = PokitTheme.colors.borderTertiary + ) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 10.dp, start = 10.dp, end = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + modifier = Modifier + .size(36.dp) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = onClickBookmark + ) + .padding(6.dp), + painter = painterResource(id = R.drawable.icon_24_star), + contentDescription = "bookmark", + colorFilter = ColorFilter.tint( + color = if (link.bookmark) PokitTheme.colors.brand else PokitTheme.colors.iconTertiary + ) + ) + + Spacer(modifier = Modifier.weight(1f)) + + Image( + modifier = Modifier + .size(36.dp) + .padding(6.dp), + painter = painterResource(id = R.drawable.icon_24_share), + contentDescription = "share", + colorFilter = ColorFilter.tint( + color = PokitTheme.colors.iconSecondary + ) + ) + + Image( + modifier = Modifier + .size(36.dp) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = { + onClickModifyLink(link) + } + ) + .padding(6.dp), + painter = painterResource(id = R.drawable.icon_24_edit), + contentDescription = "edit", + colorFilter = ColorFilter.tint( + color = PokitTheme.colors.iconSecondary + ) + ) + + Image( + modifier = Modifier + .size(36.dp) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = { + onClickRemoveLink(link) + } + ) + .padding(6.dp), + painter = painterResource(id = R.drawable.icon_24_trash), + contentDescription = "remove", + colorFilter = ColorFilter.tint( + color = PokitTheme.colors.iconSecondary + ) + ) + } + } +} diff --git a/feature/search/src/main/java/pokitmons/pokit/search/components/searchitemlist/SearchItemList.kt b/feature/search/src/main/java/pokitmons/pokit/search/components/searchitemlist/SearchItemList.kt index c462b3f3..ec696e7f 100644 --- a/feature/search/src/main/java/pokitmons/pokit/search/components/searchitemlist/SearchItemList.kt +++ b/feature/search/src/main/java/pokitmons/pokit/search/components/searchitemlist/SearchItemList.kt @@ -9,8 +9,11 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -21,6 +24,7 @@ import androidx.compose.ui.unit.dp import pokitmons.pokit.core.ui.components.block.linkcard.LinkCard import pokitmons.pokit.core.ui.theme.PokitTheme import pokitmons.pokit.search.model.Link +import pokitmons.pokit.search.paging.SimplePagingState import pokitmons.pokit.core.ui.R.drawable as coreDrawable import pokitmons.pokit.search.R.string as SearchString @@ -30,9 +34,26 @@ internal fun SearchItemList( onToggleSort: () -> Unit = {}, useRecentOrder: Boolean = true, links: List = emptyList(), + linkPagingState: SimplePagingState = SimplePagingState.IDLE, onClickLinkKebab: (Link) -> Unit = {}, onClickLink: (Link) -> Unit = {}, + loadNextLinks: () -> Unit = {}, ) { + val linkLazyColumnListState = rememberLazyListState() + val startLinkPaging = remember { + derivedStateOf { + linkLazyColumnListState.layoutInfo.visibleItemsInfo.lastOrNull()?.let { last -> + last.index >= linkLazyColumnListState.layoutInfo.totalItemsCount - 3 + } ?: false + } + } + + LaunchedEffect(startLinkPaging.value) { + if (startLinkPaging.value && linkPagingState == SimplePagingState.IDLE) { + loadNextLinks() + } + } + Column( modifier = modifier ) { @@ -60,7 +81,9 @@ internal fun SearchItemList( ) } - LazyColumn { + LazyColumn( + state = linkLazyColumnListState + ) { items(links) { link -> LinkCard( item = link, diff --git a/feature/search/src/main/java/pokitmons/pokit/search/model/Date.kt b/feature/search/src/main/java/pokitmons/pokit/search/model/Date.kt index aa65a803..9e536132 100644 --- a/feature/search/src/main/java/pokitmons/pokit/search/model/Date.kt +++ b/feature/search/src/main/java/pokitmons/pokit/search/model/Date.kt @@ -12,4 +12,8 @@ data class Date( override fun toString(): String { return "${year % 1000}.$month.$day" } + + fun toDateString(): String { + return "%d-%02d-%02d".format(year, month, day) + } } diff --git a/feature/search/src/main/java/pokitmons/pokit/search/model/Link.kt b/feature/search/src/main/java/pokitmons/pokit/search/model/Link.kt index 15121d73..3379f8aa 100644 --- a/feature/search/src/main/java/pokitmons/pokit/search/model/Link.kt +++ b/feature/search/src/main/java/pokitmons/pokit/search/model/Link.kt @@ -1,83 +1,38 @@ package pokitmons.pokit.search.model import pokitmons.pokit.search.R +import pokitmons.pokit.domain.model.link.Link as DomainLink data class Link( - val id: String, - val title: String, - val dateString: String, - val domainUrl: String, - val isRead: Boolean, - val linkType: LinkType, - val url: String, - val memo: String, - val bookmark: Boolean, + val id: String = "", + val title: String = "", + val dateString: String = "", + val domainUrl: String = "", + val isRead: Boolean = false, + val linkType: LinkType = LinkType.TEXT, + val url: String = "", + val memo: String = "", + val bookmark: Boolean = false, val imageUrl: String? = null, -) +) { + companion object { + fun fromDomainLink(domainLink: DomainLink): Link { + return Link( + id = domainLink.id.toString(), + title = domainLink.title, + dateString = domainLink.createdAt, + domainUrl = domainLink.domain, + isRead = domainLink.isRead, + url = domainLink.data, + memo = domainLink.memo, + imageUrl = domainLink.thumbnail, + bookmark = domainLink.favorites, + linkType = LinkType.TEXT + ) + } + } +} enum class LinkType(val textResourceId: Int) { TEXT(R.string.text), } - -internal val sampleLinks = listOf( - Link( - id = "1", - title = "자연 친화적인 라이프스타일을 위한 환경 보호 방법", - imageUrl = null, - dateString = "2024.04.12", - domainUrl = "youtu.be", - isRead = true, - linkType = LinkType.TEXT, - url = "", - memo = "", - bookmark = true - ), - Link( - id = "2", - title = "자연 친화적인 라이프스타일을 위한 환경 보호 방법", - imageUrl = null, - dateString = "2024.05.12", - domainUrl = "youtu.be", - isRead = false, - linkType = LinkType.TEXT, - url = "", - memo = "", - bookmark = true - ), - Link( - id = "3", - title = "포킷포킷", - imageUrl = null, - dateString = "2024.04.12", - domainUrl = "pokitmons.pokit", - isRead = true, - linkType = LinkType.TEXT, - url = "", - memo = "", - bookmark = true - ), - Link( - id = "4", - title = "자연 친화적인 라이프스타일을 위한 환경 보호 방법", - imageUrl = null, - dateString = "2024.06.12", - domainUrl = "youtu.be", - isRead = true, - linkType = LinkType.TEXT, - url = "", - memo = "", - bookmark = true - ), - Link( - id = "5", - title = "마지막 링크입니다.", - imageUrl = null, - dateString = "2024.07.14", - domainUrl = "youtu.be", - isRead = false, - linkType = LinkType.TEXT, - url = "", - memo = "", - bookmark = true - ) -) diff --git a/feature/search/src/main/java/pokitmons/pokit/search/model/Pokit.kt b/feature/search/src/main/java/pokitmons/pokit/search/model/Pokit.kt index bcc29728..99072c5c 100644 --- a/feature/search/src/main/java/pokitmons/pokit/search/model/Pokit.kt +++ b/feature/search/src/main/java/pokitmons/pokit/search/model/Pokit.kt @@ -4,7 +4,17 @@ data class Pokit( val title: String = "", val id: String = "", val count: Int = 0, -) +) { + companion object { + fun fromDomainPokit(pokit: pokitmons.pokit.domain.model.pokit.Pokit): Pokit { + return Pokit( + title = pokit.name, + id = pokit.categoryId.toString(), + count = pokit.linkCount + ) + } + } +} internal val samplePokits = listOf( Pokit(title = "안드로이드", id = "1", count = 2), diff --git a/feature/search/src/main/java/pokitmons/pokit/search/model/SearchScreenState.kt b/feature/search/src/main/java/pokitmons/pokit/search/model/SearchScreenState.kt index 77f053d3..b7c33ebe 100644 --- a/feature/search/src/main/java/pokitmons/pokit/search/model/SearchScreenState.kt +++ b/feature/search/src/main/java/pokitmons/pokit/search/model/SearchScreenState.kt @@ -7,6 +7,8 @@ data class SearchScreenState( val useRecentSearchWord: Boolean = false, val showFilterBottomSheet: Boolean = false, val firstBottomSheetFilterType: FilterType = FilterType.Pokit, + val showLinkDetailBottomSheet: Boolean = false, + val linkBottomSheetType: BottomSheetType? = null, val sortRecent: Boolean = true, val currentLink: Link? = null, ) @@ -14,3 +16,7 @@ data class SearchScreenState( enum class SearchScreenStep { INPUT, RESULT } + +enum class BottomSheetType { + MODIFY, REMOVE +} diff --git a/feature/search/src/main/java/pokitmons/pokit/search/paging/LinkPaging.kt b/feature/search/src/main/java/pokitmons/pokit/search/paging/LinkPaging.kt new file mode 100644 index 00000000..7646fa85 --- /dev/null +++ b/feature/search/src/main/java/pokitmons/pokit/search/paging/LinkPaging.kt @@ -0,0 +1,160 @@ +package pokitmons.pokit.search.paging + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import pokitmons.pokit.domain.commom.PokitResult +import pokitmons.pokit.domain.usecase.link.SearchLinksUseCase +import pokitmons.pokit.search.model.Filter +import pokitmons.pokit.search.model.Link +import kotlin.coroutines.cancellation.CancellationException + +class LinkPaging( + private val searchLinksUseCase: SearchLinksUseCase, + private val perPage: Int = 10, + private val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.IO), + private val initPage: Int = 0, + private val firstRequestPage: Int = 3, + private var filter: Filter, + private var searchWord: String = "", + private var recentSort: Boolean = true, +) : SimplePaging { + + private val _pagingState: MutableStateFlow = MutableStateFlow(SimplePagingState.IDLE) + override val pagingState: StateFlow = _pagingState.asStateFlow() + + private val _pagingData: MutableStateFlow> = MutableStateFlow(emptyList()) + override val pagingData: StateFlow> = _pagingData.asStateFlow() + + private var currentPageIndex = initPage + private var requestJob: Job? = null + + fun changeFilter(filter: Filter) { + this.filter = filter + } + + fun changeSearchWord(searchWord: String) { + this.searchWord = searchWord + } + + fun changeRecentSort(recentSort: Boolean) { + this.recentSort = recentSort + } + + override suspend fun refresh() { + requestJob?.cancel() + + _pagingData.update { emptyList() } + _pagingState.update { SimplePagingState.LOADING_INIT } + requestJob = coroutineScope.launch { + try { + currentPageIndex = initPage + val response = searchLinksUseCase.searchLinks( + page = currentPageIndex, + size = perPage * firstRequestPage, + sort = listOf(), + isRead = !filter.notRead, + favorites = filter.bookmark, + startDate = filter.startDate?.toDateString(), + endDate = filter.endDate?.toDateString(), + categoryIds = filter.selectedPokits.mapNotNull { it.id.toIntOrNull() }, + searchWord = searchWord + ) + when (response) { + is PokitResult.Success -> { + val links = response.result.map { domainLink -> + Link.fromDomainLink(domainLink) + } + applyResponse(links, firstRequestPage) + } + is PokitResult.Error -> { + _pagingState.update { SimplePagingState.FAILURE_INIT } + } + } + } catch (exception: Exception) { + if (exception !is CancellationException) { + _pagingState.update { SimplePagingState.FAILURE_INIT } + } + } + } + } + + override suspend fun load() { + if (pagingState.value != SimplePagingState.IDLE) return + + requestJob?.cancel() + _pagingState.update { SimplePagingState.LOADING_NEXT } + + requestJob = coroutineScope.launch { + try { + val response = searchLinksUseCase.searchLinks( + page = currentPageIndex, + size = perPage, + sort = listOf(), + isRead = !filter.notRead, + favorites = filter.bookmark, + startDate = filter.startDate?.toDateString(), + endDate = filter.endDate?.toDateString(), + categoryIds = filter.selectedPokits.mapNotNull { it.id.toIntOrNull() }, + searchWord = searchWord + ) + when (response) { + is PokitResult.Success -> { + val links = response.result.map { domainLink -> + Link.fromDomainLink(domainLink) + } + applyResponse(links) + } + is PokitResult.Error -> { + _pagingState.update { SimplePagingState.FAILURE_INIT } + } + } + } catch (exception: Exception) { + if (exception !is CancellationException) { + _pagingState.update { SimplePagingState.FAILURE_NEXT } + } + } + } + } + + private fun applyResponse(dataInResponse: List, multiple: Int = 1) { + if (dataInResponse.size < perPage * multiple) { + _pagingState.update { SimplePagingState.LAST } + } else { + _pagingState.update { SimplePagingState.IDLE } + } + _pagingData.update { _pagingData.value + dataInResponse } + currentPageIndex += multiple + } + + override fun clear() { + requestJob?.cancel() + _pagingData.update { emptyList() } + _pagingState.update { SimplePagingState.IDLE } + } + + override suspend fun deleteItem(item: Link) { + val capturedDataList = _pagingData.value + _pagingData.update { capturedDataList.filter { it.id != item.id } } + } + + override suspend fun modifyItem(item: Link) { + val capturedDataList = _pagingData.value + val targetPokit = capturedDataList.find { it.id == item.id } ?: return + + _pagingData.update { + capturedDataList.map { pokit -> + if (targetPokit.id == pokit.id) { + item + } else { + pokit + } + } + } + } +} diff --git a/feature/search/src/main/java/pokitmons/pokit/search/paging/PokitPaging.kt b/feature/search/src/main/java/pokitmons/pokit/search/paging/PokitPaging.kt new file mode 100644 index 00000000..b3a6ae54 --- /dev/null +++ b/feature/search/src/main/java/pokitmons/pokit/search/paging/PokitPaging.kt @@ -0,0 +1,124 @@ +package pokitmons.pokit.search.paging + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import pokitmons.pokit.domain.commom.PokitResult +import pokitmons.pokit.domain.usecase.pokit.GetPokitsUseCase +import pokitmons.pokit.search.model.Pokit +import kotlin.coroutines.cancellation.CancellationException + +class PokitPaging( + private val getPokits: GetPokitsUseCase, + private val perPage: Int = 10, + private val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.IO), + private val initPage: Int = 0, + private val firstRequestPage: Int = 3, +) : SimplePaging { + private val _pagingState = MutableStateFlow(SimplePagingState.IDLE) + override val pagingState: StateFlow = _pagingState.asStateFlow() + + private val _pagingData: MutableStateFlow> = MutableStateFlow(emptyList()) + override val pagingData: StateFlow> = _pagingData.asStateFlow() + private var currentPageIndex = initPage + private var requestJob: Job? = null + + override suspend fun refresh() { + requestJob?.cancel() + + _pagingData.update { emptyList() } + _pagingState.update { SimplePagingState.LOADING_INIT } + requestJob = coroutineScope.launch { + try { + currentPageIndex = initPage + val response = getPokits.getPokits(size = perPage * firstRequestPage, page = currentPageIndex) + when (response) { + is PokitResult.Success -> { + val pokitList = response.result.map { domainPokit -> + Pokit.fromDomainPokit(domainPokit) + } + applyResponse(pokitList, firstRequestPage) + } + + is PokitResult.Error -> { + _pagingState.update { SimplePagingState.FAILURE_INIT } + } + } + } catch (exception: Exception) { + if (exception !is CancellationException) { + _pagingState.update { SimplePagingState.FAILURE_INIT } + } + } + } + } + + override suspend fun load() { + if (pagingState.value != SimplePagingState.IDLE) return + + requestJob?.cancel() + _pagingState.update { SimplePagingState.LOADING_NEXT } + + requestJob = coroutineScope.launch { + try { + val response = getPokits.getPokits(size = perPage, page = currentPageIndex) + when (response) { + is PokitResult.Success -> { + val pokitList = response.result.map { domainPokit -> + Pokit.fromDomainPokit(domainPokit) + } + applyResponse(pokitList) + } + + is PokitResult.Error -> { + _pagingState.update { SimplePagingState.FAILURE_NEXT } + } + } + } catch (exception: Exception) { + if (exception !is CancellationException) { + _pagingState.update { SimplePagingState.FAILURE_NEXT } + } + } + } + } + + private fun applyResponse(dataInResponse: List, multiple: Int = 1) { + if (dataInResponse.size < perPage * multiple) { + _pagingState.update { SimplePagingState.LAST } + } else { + _pagingState.update { SimplePagingState.IDLE } + } + _pagingData.update { _pagingData.value + dataInResponse } + currentPageIndex += multiple + } + + override fun clear() { + requestJob?.cancel() + _pagingData.update { emptyList() } + _pagingState.update { SimplePagingState.IDLE } + } + + override suspend fun deleteItem(item: Pokit) { + val capturedDataList = _pagingData.value + _pagingData.update { capturedDataList.filter { it.id != item.id } } + } + + override suspend fun modifyItem(item: Pokit) { + val capturedDataList = _pagingData.value + val targetPokit = capturedDataList.find { it.id == item.id } ?: return + + _pagingData.update { + capturedDataList.map { pokit -> + if (targetPokit.id == pokit.id) { + item + } else { + pokit + } + } + } + } +} diff --git a/feature/search/src/main/java/pokitmons/pokit/search/paging/SimplePaging.kt b/feature/search/src/main/java/pokitmons/pokit/search/paging/SimplePaging.kt new file mode 100644 index 00000000..b80d630c --- /dev/null +++ b/feature/search/src/main/java/pokitmons/pokit/search/paging/SimplePaging.kt @@ -0,0 +1,13 @@ +package pokitmons.pokit.search.paging + +import kotlinx.coroutines.flow.Flow + +interface SimplePaging { + val pagingData: Flow> + suspend fun refresh() + suspend fun load() + val pagingState: Flow + suspend fun modifyItem(item: T) + suspend fun deleteItem(item: T) + fun clear() +} diff --git a/feature/search/src/main/java/pokitmons/pokit/search/paging/SimplePagingState.kt b/feature/search/src/main/java/pokitmons/pokit/search/paging/SimplePagingState.kt new file mode 100644 index 00000000..50a931f1 --- /dev/null +++ b/feature/search/src/main/java/pokitmons/pokit/search/paging/SimplePagingState.kt @@ -0,0 +1,5 @@ +package pokitmons.pokit.search.paging + +enum class SimplePagingState { + IDLE, LOADING_NEXT, LOADING_INIT, FAILURE_NEXT, FAILURE_INIT, LAST +} diff --git a/feature/search/src/main/res/values/string.xml b/feature/search/src/main/res/values/string.xml index baceab83..90377eb6 100644 --- a/feature/search/src/main/res/values/string.xml +++ b/feature/search/src/main/res/values/string.xml @@ -32,4 +32,7 @@ %d년 %d월 + + 링크를 정말 삭제하시겠습니까? + 함께 저장한 모든 정보가 삭제되며,\n복구하실 수 없습니다. \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e9e52b39..48306cfc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -88,6 +88,8 @@ annotation = { group = "androidx.annotation", name = "annotation", version.ref = mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } mockk-android = { group = "io.mockk", name = "mockk-android", version.ref = "mockk" } +jsoup = { group = "org.jsoup", name = "jsoup", version = "1.18.1" } + [plugins] com-android-application = { id = "com.android.application", version.ref = "agp" } org-jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "org-jetbrains-kotlin-android" }