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")