diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0429d757..8573ca43 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -71,6 +71,7 @@ dependencies { implementation(projects.core.designsystem) implementation(projects.feature.home) implementation(projects.feature.chat) + implementation(projects.feature.chatSearch) implementation(projects.feature.calendar) implementation(projects.feature.emotion) implementation(projects.feature.login) @@ -88,6 +89,7 @@ dependencies { implementation(libs.orbit.core) implementation(libs.orbit.viewmodel) implementation(libs.orbit.compose) + implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.coroutines.android) diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml index f740c8f9..60a3c396 100644 --- a/app/src/debug/AndroidManifest.xml +++ b/app/src/debug/AndroidManifest.xml @@ -6,7 +6,12 @@ + + diff --git a/app/src/debug/kotlin/com/gamss/android/app/debug/ChatSearchDebugActivity.kt b/app/src/debug/kotlin/com/gamss/android/app/debug/ChatSearchDebugActivity.kt new file mode 100644 index 00000000..06572d1b --- /dev/null +++ b/app/src/debug/kotlin/com/gamss/android/app/debug/ChatSearchDebugActivity.kt @@ -0,0 +1,23 @@ +package com.gamss.android.app.debug + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import com.gamss.android.core.designsystem.theme.GamssTheme +import com.gamss.android.feature.chatSearch.SearchChattingScreen +import dagger.hilt.android.AndroidEntryPoint + +/** 채팅방 검색 디버그/검증 전용 화면. 프로덕션 아님. */ +@AndroidEntryPoint +class ChatSearchDebugActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + GamssTheme { + SearchChattingScreen() + } + } + } +} diff --git a/data/build.gradle.kts b/data/build.gradle.kts index 3059515e..c58942b6 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -63,6 +63,7 @@ dependencies { implementation(libs.firebase.auth) implementation(libs.firebase.firestore) implementation(libs.kotlinx.coroutines.play.services) + implementation(libs.androidx.paging.common) testImplementation(libs.junit) testImplementation(libs.mockk) @@ -85,4 +86,5 @@ dependencies { androidTestImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation("androidx.test:runner:1.6.2") + } diff --git a/data/src/main/java/com/gamss/android/data/chattingsearch/ChattingRoomSearchPagingSource.kt b/data/src/main/java/com/gamss/android/data/chattingsearch/ChattingRoomSearchPagingSource.kt new file mode 100644 index 00000000..74b70c20 --- /dev/null +++ b/data/src/main/java/com/gamss/android/data/chattingsearch/ChattingRoomSearchPagingSource.kt @@ -0,0 +1,50 @@ +package com.gamss.android.data.chattingsearch + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.gamss.android.core.common.AppResult +import com.gamss.android.data.remote.chattingsearch.ChattingRoomSearchService +import com.gamss.android.data.remote.chattingsearch.model.response.toDomain +import com.gamss.android.data.repository.runCatchingApiCall +import com.gamss.android.domain.chattingsearch.ChattingRoomSummary + +internal class ChattingRoomSearchPagingSource( + private val chattingRoomSearchService: ChattingRoomSearchService, + private val keyword: String, +) : PagingSource() { + + override suspend fun load(params: LoadParams): LoadResult { + val page = params.key ?: DEFAULT_PAGE + val result = runCatchingApiCall { + checkNotNull( + chattingRoomSearchService.searchChattingRooms( + keyword = keyword, + page = page, + size = params.loadSize, + ).data, + ) { "No available chatting room search data" }.toDomain() + } + + return when (result) { + is AppResult.Success -> LoadResult.Page( + data = result.data.rooms, + prevKey = page.takeIf { it > DEFAULT_PAGE }?.minus(1), + nextKey = (page + 1).takeIf { result.data.hasNextPage }, + ) + + is AppResult.Failure -> LoadResult.Error(result.throwable) + } + } + + override fun getRefreshKey(state: PagingState): Int? = + state.anchorPosition?.let { anchorPosition -> + state.closestPageToPosition(anchorPosition)?.let { page -> + page.prevKey?.plus(1) ?: page.nextKey?.minus(1) + } + } + + companion object { + const val DEFAULT_PAGE = 0 + const val DEFAULT_SIZE = 20 + } +} diff --git a/data/src/main/java/com/gamss/android/data/chattingsearch/ChattingRoomSearchRepositoryImpl.kt b/data/src/main/java/com/gamss/android/data/chattingsearch/ChattingRoomSearchRepositoryImpl.kt new file mode 100644 index 00000000..87e4bb47 --- /dev/null +++ b/data/src/main/java/com/gamss/android/data/chattingsearch/ChattingRoomSearchRepositoryImpl.kt @@ -0,0 +1,32 @@ +package com.gamss.android.data.chattingsearch + +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import com.gamss.android.data.remote.chattingsearch.ChattingRoomSearchService +import com.gamss.android.domain.chattingsearch.ChattingRoomSummary +import com.gamss.android.domain.repository.ChattingRoomSearchRepository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class ChattingRoomSearchRepositoryImpl @Inject constructor( + private val chattingRoomSearchService: ChattingRoomSearchService, +) : ChattingRoomSearchRepository { + + override fun searchChattingRooms(keyword: String): Flow> = + Pager( + config = PagingConfig( + pageSize = ChattingRoomSearchPagingSource.DEFAULT_SIZE, + initialLoadSize = ChattingRoomSearchPagingSource.DEFAULT_SIZE, + enablePlaceholders = false, + ), + pagingSourceFactory = { + ChattingRoomSearchPagingSource( + chattingRoomSearchService = chattingRoomSearchService, + keyword = keyword, + ) + }, + ).flow +} diff --git a/data/src/main/java/com/gamss/android/data/di/ChattingRoomSearchModule.kt b/data/src/main/java/com/gamss/android/data/di/ChattingRoomSearchModule.kt new file mode 100644 index 00000000..bdd1acf1 --- /dev/null +++ b/data/src/main/java/com/gamss/android/data/di/ChattingRoomSearchModule.kt @@ -0,0 +1,18 @@ +package com.gamss.android.data.di + +import com.gamss.android.data.remote.chattingsearch.ChattingRoomSearchService +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import retrofit2.Retrofit +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal object ChattingRoomSearchModule { + @Provides + @Singleton + fun provideChattingRoomSearchService(retrofit: Retrofit): ChattingRoomSearchService = + retrofit.create(ChattingRoomSearchService::class.java) +} diff --git a/data/src/main/java/com/gamss/android/data/di/RepositoryModule.kt b/data/src/main/java/com/gamss/android/data/di/RepositoryModule.kt index 0f8cc084..4ec99e1f 100644 --- a/data/src/main/java/com/gamss/android/data/di/RepositoryModule.kt +++ b/data/src/main/java/com/gamss/android/data/di/RepositoryModule.kt @@ -1,5 +1,6 @@ package com.gamss.android.data.di +import com.gamss.android.data.chattingsearch.ChattingRoomSearchRepositoryImpl import com.gamss.android.data.local.auth.AuthTokenLocalDataSource import com.gamss.android.data.local.auth.EncryptedAuthTokenLocalDataSource import com.gamss.android.data.local.auth.TinkTokenCipher @@ -9,6 +10,7 @@ import com.gamss.android.data.local.auth.TokenProviderImpl import com.gamss.android.data.repository.AuthRepositoryImpl import com.gamss.android.data.repository.UserRepositoryImpl import com.gamss.android.domain.repository.AuthRepository +import com.gamss.android.domain.repository.ChattingRoomSearchRepository import com.gamss.android.domain.repository.UserRepository import dagger.Binds import dagger.Module @@ -58,6 +60,11 @@ internal abstract class RepositoryModule { userRepositoryImpl: UserRepositoryImpl, ): UserRepository + @Binds + abstract fun bindChattingRoomSearchRepository( + chattingRoomSearchRepositoryImpl: ChattingRoomSearchRepositoryImpl, + ): ChattingRoomSearchRepository + companion object { @Provides @Singleton diff --git a/data/src/main/java/com/gamss/android/data/remote/chattingsearch/ChattingRoomSearchService.kt b/data/src/main/java/com/gamss/android/data/remote/chattingsearch/ChattingRoomSearchService.kt new file mode 100644 index 00000000..299088ed --- /dev/null +++ b/data/src/main/java/com/gamss/android/data/remote/chattingsearch/ChattingRoomSearchService.kt @@ -0,0 +1,20 @@ +package com.gamss.android.data.remote.chattingsearch + +import com.gamss.android.data.remote.chattingsearch.model.response.ChattingRoomSearchResponse +import com.gamss.android.data.remote.model.response.ApiResponse +import retrofit2.http.GET +import retrofit2.http.Query + +/** + * 지정한 제목 또는 채팅 내용에 검색어가 포함된 본인 대화방을 최신순으로 조회한다. + * 현재 진행중인 대화를 대상으로 진행한다. + */ +internal interface ChattingRoomSearchService { + + @GET("/api/conversations/search") + suspend fun searchChattingRooms( + @Query("keyword") keyword: String, + @Query("page") page: Int, + @Query("size") size: Int, + ): ApiResponse +} diff --git a/data/src/main/java/com/gamss/android/data/remote/chattingsearch/model/response/ChattingRoomSearchResponse.kt b/data/src/main/java/com/gamss/android/data/remote/chattingsearch/model/response/ChattingRoomSearchResponse.kt new file mode 100644 index 00000000..cc934d25 --- /dev/null +++ b/data/src/main/java/com/gamss/android/data/remote/chattingsearch/model/response/ChattingRoomSearchResponse.kt @@ -0,0 +1,39 @@ +package com.gamss.android.data.remote.chattingsearch.model.response + +import com.gamss.android.domain.chattingsearch.ChattingRoomSearch +import com.gamss.android.domain.chattingsearch.ChattingRoomSummary +import kotlinx.serialization.Serializable + +@Serializable +internal data class ChattingRoomSearchResponse( + val content: List, + val page: Int, + val size: Int, + val totalElements: Long, + val totalPages: Int, +) + +@Serializable +internal data class ChattingRoomResponse( + val conversationId: Long, + val title: String, + val status: String, + val createdAt: String, +) + +internal fun ChattingRoomSearchResponse.toDomain(): ChattingRoomSearch = + ChattingRoomSearch( + rooms = content.map { it.toDomain() }, + page = page, + size = size, + totalElements = totalElements, + totalPages = totalPages, + ) + +internal fun ChattingRoomResponse.toDomain(): ChattingRoomSummary = + ChattingRoomSummary( + conversationId = conversationId, + title = title, + status = status, + createdAt = createdAt, + ) diff --git a/data/src/test/java/com/gamss/android/data/chattingsearch/ChattingRoomSearchPagingSourceTest.kt b/data/src/test/java/com/gamss/android/data/chattingsearch/ChattingRoomSearchPagingSourceTest.kt new file mode 100644 index 00000000..5b856157 --- /dev/null +++ b/data/src/test/java/com/gamss/android/data/chattingsearch/ChattingRoomSearchPagingSourceTest.kt @@ -0,0 +1,206 @@ +package com.gamss.android.data.chattingsearch + +import androidx.paging.PagingSource +import com.gamss.android.data.remote.chattingsearch.ChattingRoomSearchService +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import okhttp3.MediaType.Companion.toMediaType +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import retrofit2.Retrofit +import retrofit2.converter.kotlinx.serialization.asConverterFactory + +/** + * 실제 서버가 내려주는 응답 그대로의 JSON을 MockWebServer에 태워, PagingSource가 + * 페이지를 정상적으로 파싱·연결하고 무한 스크롤(추가 로딩)이 마지막 페이지에서 + * 올바르게 멈추는지 검증한다. + */ +class ChattingRoomSearchPagingSourceTest { + + private lateinit var server: MockWebServer + private lateinit var service: ChattingRoomSearchService + + @Before + fun setUp() { + server = MockWebServer().apply { start() } + + val json = Json { + ignoreUnknownKeys = true + coerceInputValues = true + } + val retrofit = Retrofit.Builder() + .baseUrl(server.url("/")) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + service = retrofit.create(ChattingRoomSearchService::class.java) + } + + @After + fun tearDown() { + server.close() + } + + @Test + fun `첫 페이지를 새로고침하면 서버 응답을 도메인 모델로 매핑하고 다음 페이지 키를 채운다`() = runTest { + server.enqueue(jsonResponse(FIRST_PAGE_JSON)) + val pagingSource = pagingSource() + + val result = pagingSource.load( + PagingSource.LoadParams.Refresh(key = null, loadSize = 20, placeholdersEnabled = false), + ) + + val page = result as PagingSource.LoadResult.Page + Assert.assertEquals(2, page.data.size) + Assert.assertEquals(1001L, page.data[0].conversationId) + Assert.assertEquals("출근길에 느낀 답답함", page.data[0].title) + Assert.assertEquals("ACTIVE", page.data[0].status) + Assert.assertEquals("2026-08-04T08:12:00Z", page.data[0].createdAt) + Assert.assertNull(page.prevKey) + Assert.assertEquals(1, page.nextKey) + + val request = server.takeRequest() + Assert.assertEquals("감정", request.url.queryParameter("keyword")) + Assert.assertEquals("0", request.url.queryParameter("page")) + Assert.assertEquals("20", request.url.queryParameter("size")) + } + + @Test + fun `다음 페이지를 추가 로딩하면 요청한 페이지 번호로 서버를 호출하고 이전-다음 키를 갱신한다`() = runTest { + server.enqueue(jsonResponse(SECOND_PAGE_JSON)) + val pagingSource = pagingSource() + + val result = pagingSource.load( + PagingSource.LoadParams.Append(key = 1, loadSize = 20, placeholdersEnabled = false), + ) + + val page = result as PagingSource.LoadResult.Page + Assert.assertEquals(2, page.data.size) + Assert.assertEquals(1003L, page.data[0].conversationId) + Assert.assertEquals(0, page.prevKey) + Assert.assertEquals(2, page.nextKey) + Assert.assertEquals("1", server.takeRequest().url.queryParameter("page")) + } + + @Test + fun `마지막 페이지를 추가 로딩하면 다음 페이지 키가 없어 무한 스크롤이 멈춘다`() = runTest { + server.enqueue(jsonResponse(LAST_PAGE_JSON)) + val pagingSource = pagingSource() + + val result = pagingSource.load( + PagingSource.LoadParams.Append(key = 2, loadSize = 20, placeholdersEnabled = false), + ) + + val page = result as PagingSource.LoadResult.Page + Assert.assertEquals(1, page.data.size) + Assert.assertEquals(1, page.prevKey) + Assert.assertNull(page.nextKey) + } + + @Test + fun `서버가 오류를 반환하면 로딩 실패로 반환된다`() = runTest { + server.enqueue( + MockResponse.Builder() + .code(500) + .body("""{"success":false,"error":{"code":"SEARCH_FAILED","message":"검색에 실패했습니다"}}""") + .addHeader("Content-Type", "application/json") + .build(), + ) + val pagingSource = pagingSource() + + val result = pagingSource.load( + PagingSource.LoadParams.Refresh(key = null, loadSize = 20, placeholdersEnabled = false), + ) + + Assert.assertTrue(result is PagingSource.LoadResult.Error) + } + + private fun pagingSource() = ChattingRoomSearchPagingSource( + chattingRoomSearchService = service, + keyword = "감정", + ) + + private fun jsonResponse(body: String) = MockResponse.Builder() + .code(200) + .body(body) + .addHeader("Content-Type", "application/json") + .build() + + private companion object { + // 실제 서버 응답 규격(ApiResponse)과 동일한 더미 JSON. + val FIRST_PAGE_JSON = """ + { + "success": true, + "data": { + "content": [ + { + "conversationId": 1001, + "title": "출근길에 느낀 답답함", + "status": "ACTIVE", + "createdAt": "2026-08-04T08:12:00Z" + }, + { + "conversationId": 1002, + "title": "점심시간 감정 기록", + "status": "ACTIVE", + "createdAt": "2026-08-04T12:31:00Z" + } + ], + "page": 0, + "size": 2, + "totalElements": 5, + "totalPages": 3 + } + } + """.trimIndent() + + val SECOND_PAGE_JSON = """ + { + "success": true, + "data": { + "content": [ + { + "conversationId": 1003, + "title": "회의 후 정리한 생각", + "status": "ACTIVE", + "createdAt": "2026-08-03T22:18:00Z" + }, + { + "conversationId": 1004, + "title": "친구와 나눈 고민", + "status": "ACTIVE", + "createdAt": "2026-08-03T23:40:00Z" + } + ], + "page": 1, + "size": 2, + "totalElements": 5, + "totalPages": 3 + } + } + """.trimIndent() + + val LAST_PAGE_JSON = """ + { + "success": true, + "data": { + "content": [ + { + "conversationId": 1005, + "title": "잠들기 전 감정 점검", + "status": "ACTIVE", + "createdAt": "2026-08-02T07:55:00Z" + } + ], + "page": 2, + "size": 2, + "totalElements": 5, + "totalPages": 3 + } + } + """.trimIndent() + } +} diff --git a/domain/build.gradle.kts b/domain/build.gradle.kts index 59646fe2..942efcd2 100644 --- a/domain/build.gradle.kts +++ b/domain/build.gradle.kts @@ -6,6 +6,7 @@ dependencies { api(projects.core.common) implementation(libs.javax.inject) implementation(libs.kotlinx.coroutines.core) + api(libs.androidx.paging.common) implementation(libs.javax.inject) diff --git a/domain/src/main/kotlin/com/gamss/android/domain/chattingsearch/ChattingRoomSearch.kt b/domain/src/main/kotlin/com/gamss/android/domain/chattingsearch/ChattingRoomSearch.kt new file mode 100644 index 00000000..35f82ad7 --- /dev/null +++ b/domain/src/main/kotlin/com/gamss/android/domain/chattingsearch/ChattingRoomSearch.kt @@ -0,0 +1,19 @@ +package com.gamss.android.domain.chattingsearch + +data class ChattingRoomSearch( + val rooms: List, + val page: Int, + val size: Int, + val totalElements: Long, + val totalPages: Int, +) { + val hasNextPage: Boolean + get() = page + 1 < totalPages +} + +data class ChattingRoomSummary( + val conversationId: Long, + val title: String, + val status: String, + val createdAt: String, +) diff --git a/domain/src/main/kotlin/com/gamss/android/domain/chattingsearch/ChattingRoomSearchException.kt b/domain/src/main/kotlin/com/gamss/android/domain/chattingsearch/ChattingRoomSearchException.kt new file mode 100644 index 00000000..ee5aac83 --- /dev/null +++ b/domain/src/main/kotlin/com/gamss/android/domain/chattingsearch/ChattingRoomSearchException.kt @@ -0,0 +1,5 @@ +package com.gamss.android.domain.chattingsearch + +sealed class ChattingRoomSearchException(cause: Throwable? = null) : RuntimeException(cause) { + class InvalidKeyword(cause: Throwable? = null) : ChattingRoomSearchException(cause) +} diff --git a/domain/src/main/kotlin/com/gamss/android/domain/chattingsearch/ChattingRoomSearchPolicy.kt b/domain/src/main/kotlin/com/gamss/android/domain/chattingsearch/ChattingRoomSearchPolicy.kt new file mode 100644 index 00000000..094da8e9 --- /dev/null +++ b/domain/src/main/kotlin/com/gamss/android/domain/chattingsearch/ChattingRoomSearchPolicy.kt @@ -0,0 +1,9 @@ +package com.gamss.android.domain.chattingsearch + +/** + * 채팅방 검색 키워드 검증 규칙. UseCase와 UI가 함께 참조할 수 있도록 + * SearchChattingRoomsUseCase가 아닌 별도 객체로 분리해 둔다. + */ +object ChattingRoomSearchPolicy { + const val MIN_KEYWORD_LENGTH = 2 +} diff --git a/domain/src/main/kotlin/com/gamss/android/domain/chattingsearch/SearchChattingRoomsUseCase.kt b/domain/src/main/kotlin/com/gamss/android/domain/chattingsearch/SearchChattingRoomsUseCase.kt new file mode 100644 index 00000000..dfd45584 --- /dev/null +++ b/domain/src/main/kotlin/com/gamss/android/domain/chattingsearch/SearchChattingRoomsUseCase.kt @@ -0,0 +1,26 @@ +package com.gamss.android.domain.chattingsearch + +import androidx.paging.PagingData +import com.gamss.android.core.common.AppResult +import com.gamss.android.domain.repository.ChattingRoomSearchRepository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class SearchChattingRoomsUseCase @Inject constructor( + private val chattingRoomSearchRepository: ChattingRoomSearchRepository, +) { + + operator fun invoke(keyword: String): AppResult>> { + val trimmedKeyword = keyword.trim() + + validate(trimmedKeyword)?.let { return AppResult.Failure(it) } + + return AppResult.Success(chattingRoomSearchRepository.searchChattingRooms(trimmedKeyword)) + } + + private fun validate(keyword: String): ChattingRoomSearchException? = + when { + keyword.length < ChattingRoomSearchPolicy.MIN_KEYWORD_LENGTH -> ChattingRoomSearchException.InvalidKeyword() + else -> null + } +} diff --git a/domain/src/main/kotlin/com/gamss/android/domain/repository/ChattingRoomSearchRepository.kt b/domain/src/main/kotlin/com/gamss/android/domain/repository/ChattingRoomSearchRepository.kt new file mode 100644 index 00000000..b06747a5 --- /dev/null +++ b/domain/src/main/kotlin/com/gamss/android/domain/repository/ChattingRoomSearchRepository.kt @@ -0,0 +1,10 @@ +package com.gamss.android.domain.repository + +import androidx.paging.PagingData +import com.gamss.android.domain.chattingsearch.ChattingRoomSummary +import kotlinx.coroutines.flow.Flow + +interface ChattingRoomSearchRepository { + + fun searchChattingRooms(keyword: String): Flow> +} diff --git a/domain/src/test/kotlin/com/gamss/android/domain/chattingsearch/SearchChattingRoomsUseCaseTest.kt b/domain/src/test/kotlin/com/gamss/android/domain/chattingsearch/SearchChattingRoomsUseCaseTest.kt new file mode 100644 index 00000000..91183840 --- /dev/null +++ b/domain/src/test/kotlin/com/gamss/android/domain/chattingsearch/SearchChattingRoomsUseCaseTest.kt @@ -0,0 +1,62 @@ +package com.gamss.android.domain.chattingsearch + +import androidx.paging.PagingData +import com.gamss.android.core.common.AppResult +import com.gamss.android.domain.repository.ChattingRoomSearchRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class SearchChattingRoomsUseCaseTest { + + @Test + fun `최소 길이보다 짧은 검색어는 유효하지 않은 키워드 실패를 반환하고 저장소를 호출하지 않는다`() { + val repository = FakeChattingRoomSearchRepository() + + val result = SearchChattingRoomsUseCase(repository)("가") + + assertTrue((result as AppResult.Failure).throwable is ChattingRoomSearchException.InvalidKeyword) + assertEquals(0, repository.searchCallCount) + } + + @Test + fun `공백을 제외하면 최소 길이보다 짧은 검색어도 유효하지 않은 키워드 실패를 반환한다`() { + val repository = FakeChattingRoomSearchRepository() + + val result = SearchChattingRoomsUseCase(repository)(" 가 ") + + assertTrue((result as AppResult.Failure).throwable is ChattingRoomSearchException.InvalidKeyword) + assertEquals(0, repository.searchCallCount) + } + + @Test + fun `유효한 검색어는 앞뒤 공백을 제거해 저장소에 전달한다`() { + val flow = flowOf(PagingData.empty()) + val repository = FakeChattingRoomSearchRepository(searchResult = flow) + + val result = SearchChattingRoomsUseCase(repository)(" 감정 ") + + assertSame(flow, (result as AppResult.Success).data) + assertEquals("감정", repository.lastKeyword) + assertEquals(1, repository.searchCallCount) + } + + private class FakeChattingRoomSearchRepository( + private val searchResult: Flow> = flowOf(PagingData.empty()), + ) : ChattingRoomSearchRepository { + var searchCallCount: Int = 0 + private set + + var lastKeyword: String? = null + private set + + override fun searchChattingRooms(keyword: String): Flow> { + searchCallCount++ + lastKeyword = keyword + return searchResult + } + } +} diff --git a/feature/chat-search/build.gradle.kts b/feature/chat-search/build.gradle.kts new file mode 100644 index 00000000..98b1edd0 --- /dev/null +++ b/feature/chat-search/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + alias(libs.plugins.gamss.android.feature) +} + +android { + namespace = "com.gamss.android.feature.chatSearch" +} + +dependencies { + implementation(projects.domain) + implementation(libs.androidx.paging.compose) +} diff --git a/feature/chat-search/src/main/java/com/gamss/android/feature/chatSearch/SearchChattingScreen.kt b/feature/chat-search/src/main/java/com/gamss/android/feature/chatSearch/SearchChattingScreen.kt new file mode 100644 index 00000000..3acfc161 --- /dev/null +++ b/feature/chat-search/src/main/java/com/gamss/android/feature/chatSearch/SearchChattingScreen.kt @@ -0,0 +1,298 @@ +package com.gamss.android.feature.chatSearch + +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.paging.LoadState +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import androidx.paging.compose.itemKey +import com.gamss.android.domain.chattingsearch.ChattingRoomSummary +import org.orbitmvi.orbit.compose.collectAsState +import org.orbitmvi.orbit.compose.collectSideEffect + +@Composable +fun SearchChattingScreen( + viewModel: SearchChattingViewModel = hiltViewModel(), +) { + val state by viewModel.collectAsState() + val chattingRooms = viewModel.chattingRooms.collectAsLazyPagingItems() + val context = LocalContext.current + + viewModel.collectSideEffect { sideEffect -> + when (sideEffect) { + is SearchChattingSideEffect.SearchFailure -> + Toast.makeText(context, sideEffect.reason.toMessage(), Toast.LENGTH_SHORT).show() + } + } + + SearchChattingContent( + state = state, + chattingRooms = chattingRooms, + onKeywordChanged = viewModel::onKeywordChanged, + onSearch = viewModel::search, + ) +} + +@Composable +fun SearchChattingContent( + state: SearchChattingState, + chattingRooms: LazyPagingItems, + onKeywordChanged: (TextFieldValue) -> Unit, + onSearch: () -> Unit, +) { + val listState = rememberLazyListState() + + // 화면 회전 등 구성 변경으로 Composition이 다시 만들어져도 이미 처리한 검색에 대해서는 + // 스크롤을 맨 위로 되돌리지 않도록, 마지막으로 스크롤을 적용한 generation을 별도로 기억한다. + var scrolledGeneration by rememberSaveable { mutableLongStateOf(0L) } + + LaunchedEffect(state.searchGeneration) { + if (state.hasSearched && state.searchGeneration != scrolledGeneration) { + listState.scrollToItem(0) + scrolledGeneration = state.searchGeneration + } + } + + Scaffold( + topBar = { + SearchTopBar( + state = state, + onKeywordChanged = onKeywordChanged, + onSearch = onSearch, + ) + }, + ) { innerPadding -> + SearchResultContent( + state = state, + chattingRooms = chattingRooms, + listState = listState, + contentPadding = innerPadding, + ) + } +} + +@Composable +private fun SearchTopBar( + state: SearchChattingState, + onKeywordChanged: (TextFieldValue) -> Unit, + onSearch: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = "채팅방 검색", + style = MaterialTheme.typography.titleLarge, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = state.keyword, + onValueChange = onKeywordChanged, + modifier = Modifier.weight(1f), + singleLine = true, + label = { Text("검색어") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { onSearch() }), + ) + Button(onClick = onSearch) { + Text("검색") + } + } + } +} + +@Composable +private fun SearchResultContent( + state: SearchChattingState, + chattingRooms: LazyPagingItems, + listState: LazyListState, + contentPadding: PaddingValues, +) { + if (!state.hasSearched) { + MessageContent(message = "검색어를 입력해 채팅방을 찾아보세요", contentPadding = contentPadding) + return + } + + when (val refresh = chattingRooms.loadState.refresh) { + is LoadState.Loading -> LoadingContent(contentPadding) + is LoadState.Error -> ErrorContent( + message = refresh.error.toSearchFailureReason().toMessage(), + contentPadding = contentPadding, + onRetry = chattingRooms::retry, + ) + + is LoadState.NotLoading -> if (chattingRooms.itemCount == 0) { + MessageContent(message = "검색 결과가 없어요", contentPadding = contentPadding) + } else { + SearchResultList( + chattingRooms = chattingRooms, + listState = listState, + contentPadding = contentPadding, + ) + } + } +} + +@Composable +private fun SearchResultList( + chattingRooms: LazyPagingItems, + listState: LazyListState, + contentPadding: PaddingValues, +) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = contentPadding, + ) { + items( + count = chattingRooms.itemCount, + key = chattingRooms.itemKey { it.conversationId }, + ) { index -> + chattingRooms[index]?.let { room -> + ListItem( + headlineContent = { Text(room.title) }, + supportingContent = { Text("${room.status} · ${room.createdAt}") }, + ) + HorizontalDivider() + } + } + + when (val append = chattingRooms.loadState.append) { + is LoadState.Loading -> item { AppendLoadingItem() } + is LoadState.Error -> item { + AppendErrorItem( + message = append.error.toSearchFailureReason().toMessage(), + onRetry = chattingRooms::retry, + ) + } + + is LoadState.NotLoading -> Unit + } + } +} + +@Composable +private fun AppendLoadingItem() { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } +} + +@Composable +private fun AppendErrorItem( + message: String, + onRetry: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(message) + Button(onClick = onRetry) { + Text("재시도") + } + } +} + +@Composable +private fun LoadingContent(contentPadding: PaddingValues) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(contentPadding), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } +} + +@Composable +private fun MessageContent(message: String, contentPadding: PaddingValues) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(contentPadding) + .padding(24.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = message, + style = MaterialTheme.typography.bodyLarge, + ) + } +} + +@Composable +private fun ErrorContent( + message: String, + contentPadding: PaddingValues, + onRetry: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(contentPadding) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(message) + Button(onClick = onRetry) { + Text("재시도") + } + } +} + +private fun SearchFailureReason.toMessage(): String = + when (this) { + SearchFailureReason.NETWORK -> "네트워크 연결을 확인해 주세요" + SearchFailureReason.INVALID_INPUT -> "최소 2글자 이상 입력해주세요" + SearchFailureReason.UNKNOWN -> "채팅방 검색에 실패했어요" + } diff --git a/feature/chat-search/src/main/java/com/gamss/android/feature/chatSearch/SearchChattingSideEffect.kt b/feature/chat-search/src/main/java/com/gamss/android/feature/chatSearch/SearchChattingSideEffect.kt new file mode 100644 index 00000000..99fbe4af --- /dev/null +++ b/feature/chat-search/src/main/java/com/gamss/android/feature/chatSearch/SearchChattingSideEffect.kt @@ -0,0 +1,11 @@ +package com.gamss.android.feature.chatSearch + +sealed interface SearchChattingSideEffect { + data class SearchFailure(val reason: SearchFailureReason) : SearchChattingSideEffect +} + +enum class SearchFailureReason { + INVALID_INPUT, + NETWORK, + UNKNOWN, +} diff --git a/feature/chat-search/src/main/java/com/gamss/android/feature/chatSearch/SearchChattingState.kt b/feature/chat-search/src/main/java/com/gamss/android/feature/chatSearch/SearchChattingState.kt new file mode 100644 index 00000000..d4bc41a3 --- /dev/null +++ b/feature/chat-search/src/main/java/com/gamss/android/feature/chatSearch/SearchChattingState.kt @@ -0,0 +1,9 @@ +package com.gamss.android.feature.chatSearch + +import androidx.compose.ui.text.input.TextFieldValue + +data class SearchChattingState( + val keyword: TextFieldValue = TextFieldValue(), + val hasSearched: Boolean = false, + val searchGeneration: Long = 0L, +) diff --git a/feature/chat-search/src/main/java/com/gamss/android/feature/chatSearch/SearchChattingViewModel.kt b/feature/chat-search/src/main/java/com/gamss/android/feature/chatSearch/SearchChattingViewModel.kt new file mode 100644 index 00000000..cee0d525 --- /dev/null +++ b/feature/chat-search/src/main/java/com/gamss/android/feature/chatSearch/SearchChattingViewModel.kt @@ -0,0 +1,75 @@ +package com.gamss.android.feature.chatSearch + +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn +import com.gamss.android.core.common.AppResult +import com.gamss.android.core.common.network.ApiException +import com.gamss.android.domain.chattingsearch.ChattingRoomSearchException +import com.gamss.android.domain.chattingsearch.ChattingRoomSummary +import com.gamss.android.domain.chattingsearch.SearchChattingRoomsUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import org.orbitmvi.orbit.ContainerHost +import org.orbitmvi.orbit.viewmodel.container +import javax.inject.Inject + +@HiltViewModel +@OptIn(ExperimentalCoroutinesApi::class) +class SearchChattingViewModel @Inject constructor( + private val searchChattingRoomsUseCase: SearchChattingRoomsUseCase, +) : ViewModel(), ContainerHost { + + override val container = container( + SearchChattingState(), + ) + + private val searchResults = MutableStateFlow>?>(null) + + val chattingRooms: Flow> = searchResults + .filterNotNull() + .flatMapLatest { it } + .cachedIn(viewModelScope) + + fun onKeywordChanged(keyword: TextFieldValue) = intent { + reduce { state.copy(keyword = keyword) } + } + + fun search() = intent { + val keyword = state.keyword.text.trim() + val nextGeneration = state.searchGeneration + 1 + + when (val result = searchChattingRoomsUseCase(keyword)) { + is AppResult.Success -> { + reduce { + state.copy( + keyword = TextFieldValue( + text = keyword, + selection = TextRange(keyword.length), + ), + hasSearched = true, + searchGeneration = nextGeneration, + ) + } + searchResults.value = result.data + } + + is AppResult.Failure -> postSideEffect( + SearchChattingSideEffect.SearchFailure(result.throwable.toSearchFailureReason()), + ) + } + } +} + +internal fun Throwable.toSearchFailureReason(): SearchFailureReason = when (this) { + is ChattingRoomSearchException.InvalidKeyword -> SearchFailureReason.INVALID_INPUT + is ApiException.Network -> SearchFailureReason.NETWORK + else -> SearchFailureReason.UNKNOWN +} diff --git a/feature/chat-search/src/main/java/com/gamss/android/feature/chatSearch/navigation/SearchChattingKey.kt b/feature/chat-search/src/main/java/com/gamss/android/feature/chatSearch/navigation/SearchChattingKey.kt new file mode 100644 index 00000000..84fd0509 --- /dev/null +++ b/feature/chat-search/src/main/java/com/gamss/android/feature/chatSearch/navigation/SearchChattingKey.kt @@ -0,0 +1,7 @@ +package com.gamss.android.feature.chatSearch.navigation + +import androidx.navigation3.runtime.NavKey +import kotlinx.serialization.Serializable + +@Serializable +data object SearchChattingKey : NavKey diff --git a/feature/chat-search/src/test/java/com/gamss/android/feature/chatSearch/SearchChattingViewModelTest.kt b/feature/chat-search/src/test/java/com/gamss/android/feature/chatSearch/SearchChattingViewModelTest.kt new file mode 100644 index 00000000..ac0f87b6 --- /dev/null +++ b/feature/chat-search/src/test/java/com/gamss/android/feature/chatSearch/SearchChattingViewModelTest.kt @@ -0,0 +1,89 @@ +package com.gamss.android.feature.chatSearch + +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.paging.PagingData +import com.gamss.android.domain.chattingsearch.ChattingRoomSummary +import com.gamss.android.domain.chattingsearch.SearchChattingRoomsUseCase +import com.gamss.android.domain.repository.ChattingRoomSearchRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.orbitmvi.orbit.test.test + +/** + * 검색어가 최소 길이(ChattingRoomSearchPolicy.MIN_KEYWORD_LENGTH) 미만이면 + * INVALID_INPUT 사이드 이펙트를 발행하고 검색 API를 호출하지 않는지 검증한다. + */ +class SearchChattingViewModelTest { + + @Test + fun `검색어가 2글자 미만이면 검색을 실행하지 않는다`() = runTest { + val repository = FakeChattingRoomSearchRepository() + val viewModel = SearchChattingViewModel(SearchChattingRoomsUseCase(repository)) + backgroundScope.launch { viewModel.chattingRooms.collect {} } + + viewModel.test(this) { + viewModel.onKeywordChanged(TextFieldValue("a")) + expectState { copy(keyword = TextFieldValue("a")) } + + viewModel.search() + expectSideEffect(SearchChattingSideEffect.SearchFailure(SearchFailureReason.INVALID_INPUT)) + } + + assertTrue(repository.requestedKeywords.isEmpty()) + } + + @Test + fun `공백을 제외하면 2글자 미만인 검색어도 검색을 실행하지 않는다`() = runTest { + val repository = FakeChattingRoomSearchRepository() + val viewModel = SearchChattingViewModel(SearchChattingRoomsUseCase(repository)) + backgroundScope.launch { viewModel.chattingRooms.collect {} } + + viewModel.test(this) { + viewModel.onKeywordChanged(TextFieldValue(" a ")) + expectState { copy(keyword = TextFieldValue(" a ")) } + + viewModel.search() + expectSideEffect(SearchChattingSideEffect.SearchFailure(SearchFailureReason.INVALID_INPUT)) + } + + assertTrue(repository.requestedKeywords.isEmpty()) + } + + @Test + fun `검색어가 2글자 이상이면 공백을 제거하고 검색을 실행한다`() = runTest { + val repository = FakeChattingRoomSearchRepository() + val viewModel = SearchChattingViewModel(SearchChattingRoomsUseCase(repository)) + backgroundScope.launch { viewModel.chattingRooms.collect {} } + + viewModel.test(this) { + viewModel.onKeywordChanged(TextFieldValue(" ab ")) + expectState { copy(keyword = TextFieldValue(" ab ")) } + + viewModel.search() + expectState { + copy( + keyword = TextFieldValue(text = "ab", selection = TextRange(2)), + hasSearched = true, + searchGeneration = 1L, + ) + } + } + + assertEquals(listOf("ab"), repository.requestedKeywords) + } + + private class FakeChattingRoomSearchRepository : ChattingRoomSearchRepository { + val requestedKeywords = mutableListOf() + + override fun searchChattingRooms(keyword: String): Flow> { + requestedKeywords += keyword + return flowOf(PagingData.empty()) + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a29dbcc8..e405cd91 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -40,6 +40,7 @@ googleidVersion = "1.2.0" datastore = "1.2.1" tink = "1.23.0" mockk = "1.13.14" +paging = "3.4.2" [libraries] androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" } @@ -126,6 +127,8 @@ kotlin-gradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", ve compose-gradlePlugin = { module = "org.jetbrains.kotlin:compose-compiler-gradle-plugin", version.ref = "kotlin" } ksp-gradlePlugin = { module = "com.google.devtools.ksp:symbol-processing-gradle-plugin", version.ref = "ksp" } hilt-gradlePlugin = { module = "com.google.dagger:hilt-android-gradle-plugin", version.ref = "hilt" } +androidx-paging-common = { group = "androidx.paging", name = "paging-common", version.ref = "paging" } +androidx-paging-compose = { group = "androidx.paging", name = "paging-compose", version.ref = "paging" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } diff --git a/settings.gradle.kts b/settings.gradle.kts index a4192966..e3a69ccd 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -31,4 +31,5 @@ include(":feature:chat") include(":feature:calendar") include(":feature:login") include(":feature:emotion") +include(":feature:chat-search") include(":core:designsystem")