From 474ade56fd2766fb3cd33d12b46b96187c6cb630 Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Fri, 30 Jan 2026 01:28:37 +0900 Subject: [PATCH 01/22] =?UTF-8?q?[feat]=20#70:=20Pose=20API=20=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=96=B4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/dataapi/repository/PoseRepository.kt | 18 +++++++ .../core/data/remote/api/PoseService.kt | 39 +++++++++++++++ .../remote/model/response/PoseResponse.kt | 47 +++++++++++++++++++ .../data/repository/di/RepositoryModule.kt | 8 ++++ .../repository/impl/PoseRepositoryImpl.kt | 35 ++++++++++++++ 5 files changed, 147 insertions(+) create mode 100644 core/data-api/src/main/java/com/neki/android/core/dataapi/repository/PoseRepository.kt create mode 100644 core/data/src/main/java/com/neki/android/core/data/remote/api/PoseService.kt create mode 100644 core/data/src/main/java/com/neki/android/core/data/remote/model/response/PoseResponse.kt create mode 100644 core/data/src/main/java/com/neki/android/core/data/repository/impl/PoseRepositoryImpl.kt diff --git a/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/PoseRepository.kt b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/PoseRepository.kt new file mode 100644 index 000000000..564878b1d --- /dev/null +++ b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/PoseRepository.kt @@ -0,0 +1,18 @@ +package com.neki.android.core.dataapi.repository + +import com.neki.android.core.model.PeopleCount +import com.neki.android.core.model.Pose +import com.neki.android.core.model.SortOrder + +interface PoseRepository { + suspend fun getPoses( + page: Int = 0, + size: Int = 20, + headCount: PeopleCount? = null, + sortOrder: SortOrder = SortOrder.DESC, + ): Result> + + suspend fun getPose(poseId: Long): Result + + suspend fun getRandomPose(): Result +} diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/api/PoseService.kt b/core/data/src/main/java/com/neki/android/core/data/remote/api/PoseService.kt new file mode 100644 index 000000000..3474fab82 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/api/PoseService.kt @@ -0,0 +1,39 @@ +package com.neki.android.core.data.remote.api + +import com.neki.android.core.data.remote.model.response.BasicResponse +import com.neki.android.core.data.remote.model.response.PoseDetailResponse +import com.neki.android.core.data.remote.model.response.PoseResponse +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.client.request.parameter +import javax.inject.Inject + +class PoseService @Inject constructor( + private val client: HttpClient, +) { + // 포즈 목록 조회 + suspend fun getPoses( + page: Int = 0, + size: Int = 20, + headCount: String? = null, + sortOrder: String = "DESC", + ): BasicResponse { + return client.get("/api/poses") { + parameter("page", page) + parameter("size", size) + parameter("headCount", headCount) + parameter("sortOrder", sortOrder) + }.body() + } + + // 포즈 상세 조회 + suspend fun getPose(poseId: Long): BasicResponse { + return client.get("/api/poses/$poseId").body() + } + + // 랜덤 포즈 조회 + suspend fun getRandomPose(): BasicResponse { + return client.get("/api/poses/random").body() + } +} diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/response/PoseResponse.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/PoseResponse.kt new file mode 100644 index 000000000..1d014c44b --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/PoseResponse.kt @@ -0,0 +1,47 @@ +package com.neki.android.core.data.remote.model.response + +import com.neki.android.core.model.PeopleCount +import com.neki.android.core.model.Pose +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class PoseResponse( + @SerialName("hasNext") val hasNext: Boolean, + @SerialName("items") val items: List, +) { + @Serializable + data class Item( + @SerialName("poseId") val poseId: Long, + @SerialName("headCount") val headCount: String, + @SerialName("imageUrl") val imageUrl: String, + @SerialName("contentType") val contentType: String, + @SerialName("createdAt") val createdAt: String, + ) { + internal fun toModel() = Pose( + id = poseId, + poseImageUrl = imageUrl, + isScrapped = false, + peopleCount = PeopleCount.entries.find { it.name == headCount }?.value ?: 1, + ) + } + + fun toModels() = items.map { it.toModel() } +} + +@Serializable +data class PoseDetailResponse( + @SerialName("poseId") val poseId: Long, + @SerialName("headCount") val headCount: String, + @SerialName("imageUrl") val imageUrl: String, + @SerialName("scrap") val scrap: Boolean, + @SerialName("contentType") val contentType: String, + @SerialName("createdAt") val createdAt: String, +) { + internal fun toModel() = Pose( + id = poseId, + poseImageUrl = imageUrl, + isScrapped = scrap, + peopleCount = PeopleCount.entries.find { it.name == headCount }?.value ?: 1, + ) +} diff --git a/core/data/src/main/java/com/neki/android/core/data/repository/di/RepositoryModule.kt b/core/data/src/main/java/com/neki/android/core/data/repository/di/RepositoryModule.kt index 33c84eb8f..e6fbb4c38 100644 --- a/core/data/src/main/java/com/neki/android/core/data/repository/di/RepositoryModule.kt +++ b/core/data/src/main/java/com/neki/android/core/data/repository/di/RepositoryModule.kt @@ -6,6 +6,7 @@ import com.neki.android.core.data.repository.impl.DataStoreRepositoryImpl import com.neki.android.core.data.repository.impl.MediaUploadRepositoryImpl import com.neki.android.core.data.repository.impl.MapRepositoryImpl import com.neki.android.core.data.repository.impl.PhotoRepositoryImpl +import com.neki.android.core.data.repository.impl.PoseRepositoryImpl import com.neki.android.core.data.repository.impl.TokenRepositoryImpl import com.neki.android.core.dataapi.auth.AuthEventManager import com.neki.android.core.dataapi.repository.AuthRepository @@ -13,6 +14,7 @@ import com.neki.android.core.dataapi.repository.DataStoreRepository import com.neki.android.core.dataapi.repository.MediaUploadRepository import com.neki.android.core.dataapi.repository.MapRepository import com.neki.android.core.dataapi.repository.PhotoRepository +import com.neki.android.core.dataapi.repository.PoseRepository import com.neki.android.core.dataapi.repository.TokenRepository import dagger.Binds import dagger.Module @@ -65,4 +67,10 @@ internal interface RepositoryModule { fun bindMapRepositoryImpl( mapRepositoryImpl: MapRepositoryImpl, ): MapRepository + + @Binds + @Singleton + fun bindPoseRepositoryImpl( + poseRepositoryImpl: PoseRepositoryImpl, + ): PoseRepository } diff --git a/core/data/src/main/java/com/neki/android/core/data/repository/impl/PoseRepositoryImpl.kt b/core/data/src/main/java/com/neki/android/core/data/repository/impl/PoseRepositoryImpl.kt new file mode 100644 index 000000000..58b638a3b --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/repository/impl/PoseRepositoryImpl.kt @@ -0,0 +1,35 @@ +package com.neki.android.core.data.repository.impl + +import com.neki.android.core.data.remote.api.PoseService +import com.neki.android.core.data.util.runSuspendCatching +import com.neki.android.core.dataapi.repository.PoseRepository +import com.neki.android.core.model.PeopleCount +import com.neki.android.core.model.Pose +import com.neki.android.core.model.SortOrder +import javax.inject.Inject + +class PoseRepositoryImpl @Inject constructor( + private val poseService: PoseService, +) : PoseRepository { + override suspend fun getPoses( + page: Int, + size: Int, + headCount: PeopleCount?, + sortOrder: SortOrder, + ): Result> = runSuspendCatching { + poseService.getPoses( + page = page, + size = size, + headCount = headCount?.name, + sortOrder = sortOrder.name, + ).data.toModels() + } + + override suspend fun getPose(poseId: Long): Result = runSuspendCatching { + poseService.getPose(poseId).data.toModel() + } + + override suspend fun getRandomPose(): Result = runSuspendCatching { + poseService.getRandomPose().data.toModel() + } +} From d42572c45f049957acce7dc6f11ac3443359cb14 Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Fri, 30 Jan 2026 01:28:43 +0900 Subject: [PATCH 02/22] =?UTF-8?q?[feat]=20#70:=20=ED=8F=AC=EC=A6=88=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=ED=99=94=EB=A9=B4=20API=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../neki/android/core/model/PoseContract.kt | 74 +------------------ .../feature/pose/impl/main/PoseScreen.kt | 2 +- .../feature/pose/impl/main/PoseViewModel.kt | 52 ++++++++++--- 3 files changed, 45 insertions(+), 83 deletions(-) diff --git a/core/model/src/main/java/com/neki/android/core/model/PoseContract.kt b/core/model/src/main/java/com/neki/android/core/model/PoseContract.kt index 8ca71fc95..7edab8485 100644 --- a/core/model/src/main/java/com/neki/android/core/model/PoseContract.kt +++ b/core/model/src/main/java/com/neki/android/core/model/PoseContract.kt @@ -4,83 +4,13 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.serialization.Serializable -private val dummyPoseList = persistentListOf( - Pose(id = 1, poseImageUrl = "https://picsum.photos/seed/poseA/400/520", peopleCount = 1), - Pose(id = 2, poseImageUrl = "https://picsum.photos/seed/poseB/400/680", peopleCount = 2), - Pose(id = 3, poseImageUrl = "https://picsum.photos/seed/poseC/400/450", peopleCount = 1), - Pose(id = 4, poseImageUrl = "https://picsum.photos/seed/poseD/400/600", peopleCount = 3), - Pose(id = 5, poseImageUrl = "https://picsum.photos/seed/poseE/400/550", peopleCount = 2), - Pose(id = 6, poseImageUrl = "https://picsum.photos/seed/poseF/400/720", peopleCount = 4), - Pose(id = 7, poseImageUrl = "https://picsum.photos/seed/poseG/400/480", peopleCount = 1), - Pose(id = 8, poseImageUrl = "https://picsum.photos/seed/poseH/400/650", peopleCount = 2), - Pose(id = 9, poseImageUrl = "https://picsum.photos/seed/poseI/400/500", peopleCount = 3), - Pose(id = 10, poseImageUrl = "https://picsum.photos/seed/poseJ/400/580", peopleCount = 1), - Pose(id = 11, poseImageUrl = "https://picsum.photos/seed/poseK/400/700", peopleCount = 5), - Pose(id = 12, poseImageUrl = "https://picsum.photos/seed/poseL/400/460", peopleCount = 2), - Pose(id = 13, poseImageUrl = "https://picsum.photos/seed/poseM/400/620", peopleCount = 1), - Pose(id = 14, poseImageUrl = "https://picsum.photos/seed/poseN/400/540", peopleCount = 4), - Pose(id = 15, poseImageUrl = "https://picsum.photos/seed/poseO/400/690", peopleCount = 2), - Pose(id = 16, poseImageUrl = "https://picsum.photos/seed/poseP/400/470", peopleCount = 3), - Pose(id = 17, poseImageUrl = "https://picsum.photos/seed/poseQ/400/610", peopleCount = 1), - Pose(id = 18, poseImageUrl = "https://picsum.photos/seed/poseR/400/530", peopleCount = 2), - Pose(id = 19, poseImageUrl = "https://picsum.photos/seed/poseS/400/670", peopleCount = 5), - Pose(id = 20, poseImageUrl = "https://picsum.photos/seed/poseT/400/490", peopleCount = 1), - Pose(id = 21, poseImageUrl = "https://picsum.photos/seed/poseU/400/640", peopleCount = 2), - Pose(id = 22, poseImageUrl = "https://picsum.photos/seed/poseV/400/560", peopleCount = 3), -) - -private val scrappedDummyList = persistentListOf( - Pose( - id = 101, - poseImageUrl = "https://picsum.photos/seed/scrapA/400/520", - isScrapped = true, - peopleCount = 1, - ), - Pose( - id = 102, - poseImageUrl = "https://picsum.photos/seed/scrapB/400/680", - isScrapped = true, - peopleCount = 2, - ), - Pose( - id = 103, - poseImageUrl = "https://picsum.photos/seed/scrapC/400/450", - isScrapped = true, - peopleCount = 1, - ), - Pose( - id = 104, - poseImageUrl = "https://picsum.photos/seed/scrapD/400/600", - isScrapped = true, - peopleCount = 3, - ), - Pose( - id = 105, - poseImageUrl = "https://picsum.photos/seed/scrapE/400/550", - isScrapped = true, - peopleCount = 2, - ), - Pose( - id = 106, - poseImageUrl = "https://picsum.photos/seed/scrapF/400/720", - isScrapped = true, - peopleCount = 4, - ), - Pose( - id = 107, - poseImageUrl = "https://picsum.photos/seed/scrapG/400/480", - isScrapped = true, - peopleCount = 1, - ), -) - data class PoseState( val isLoading: Boolean = false, val selectedPeopleCount: PeopleCount? = null, val selectedRandomPosePeopleCount: PeopleCount? = null, val isShowScrappedPose: Boolean = false, - val randomPoseList: ImmutableList = dummyPoseList, - val scrappedPoseList: ImmutableList = scrappedDummyList, + val poseList: ImmutableList = persistentListOf(), + val scrappedPoseList: ImmutableList = persistentListOf(), val isShowPeopleCountBottomSheet: Boolean = false, val isShowRandomPosePeopleCountBottomSheet: Boolean = false, ) diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/PoseScreen.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/PoseScreen.kt index 25590c37d..6366a41b2 100644 --- a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/PoseScreen.kt +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/PoseScreen.kt @@ -74,7 +74,7 @@ fun PoseScreen( PoseContent( selectedPeopleCount = uiState.selectedPeopleCount, isScrapSelected = uiState.isShowScrappedPose, - poseList = if (uiState.isShowScrappedPose) uiState.scrappedPoseList else uiState.randomPoseList, + poseList = if (uiState.isShowScrappedPose) uiState.scrappedPoseList else uiState.poseList, onClickAlarmIcon = { onIntent(PoseIntent.ClickAlarmIcon) }, onClickPeopleCount = { onIntent(PoseIntent.ClickPeopleCountChip) }, onClickScrap = { onIntent(PoseIntent.ClickScrapChip) }, diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/PoseViewModel.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/PoseViewModel.kt index c520b122c..856ab3da0 100644 --- a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/PoseViewModel.kt +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/PoseViewModel.kt @@ -1,21 +1,30 @@ package com.neki.android.feature.pose.impl.main import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.neki.android.core.dataapi.repository.PoseRepository +import com.neki.android.core.model.PeopleCount import com.neki.android.core.model.PoseEffect import com.neki.android.core.model.PoseIntent import com.neki.android.core.model.PoseState import com.neki.android.core.ui.MviIntentStore import com.neki.android.core.ui.mviIntentStore import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject @HiltViewModel -internal class PoseViewModel @Inject constructor() : ViewModel() { +internal class PoseViewModel @Inject constructor( + private val poseRepository: PoseRepository, +) : ViewModel() { val store: MviIntentStore = mviIntentStore( initialState = PoseState(), onIntent = ::onIntent, + initialFetchData = { store.onIntent(PoseIntent.EnterPoseScreen) }, ) private fun onIntent( @@ -26,15 +35,18 @@ internal class PoseViewModel @Inject constructor() : ViewModel() { ) { when (intent) { // Pose Main - PoseIntent.EnterPoseScreen -> fetchInitialData(reduce) + PoseIntent.EnterPoseScreen -> fetchPoses(reduce, postSideEffect) PoseIntent.ClickAlarmIcon -> postSideEffect(PoseEffect.NavigateToNotification) PoseIntent.ClickPeopleCountChip -> reduce { copy(isShowPeopleCountBottomSheet = true) } - is PoseIntent.ClickPeopleCountSheetItem -> reduce { - copy( - isShowScrappedPose = false, - selectedPeopleCount = intent.peopleCount, - isShowPeopleCountBottomSheet = false, - ) + is PoseIntent.ClickPeopleCountSheetItem -> { + reduce { + copy( + isShowScrappedPose = false, + selectedPeopleCount = intent.peopleCount, + isShowPeopleCountBottomSheet = false, + ) + } + fetchPoses(reduce, postSideEffect, intent.peopleCount) } PoseIntent.DismissPeopleCountBottomSheet -> reduce { copy(isShowPeopleCountBottomSheet = false) } @@ -60,7 +72,27 @@ internal class PoseViewModel @Inject constructor() : ViewModel() { } } - private fun fetchInitialData(reduce: (PoseState.() -> PoseState) -> Unit) { - reduce { copy() } + private fun fetchPoses( + reduce: (PoseState.() -> PoseState) -> Unit, + postSideEffect: (PoseEffect) -> Unit, + headCount: PeopleCount? = null, + ) { + viewModelScope.launch { + reduce { copy(isLoading = true) } + poseRepository.getPoses(headCount = headCount) + .onSuccess { poses -> + reduce { + copy( + isLoading = false, + poseList = poses.toImmutableList(), + ) + } + } + .onFailure { error -> + Timber.e(error) + reduce { copy(isLoading = false) } + postSideEffect(PoseEffect.ShowToast("포즈를 불러오는데 실패했어요")) + } + } } } From 92d595bb14e9fdb7f03fb0e7ee64fcdb90efebc2 Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Fri, 30 Jan 2026 01:28:47 +0900 Subject: [PATCH 03/22] =?UTF-8?q?[feat]=20#70:=20=EB=9E=9C=EB=8D=A4=20?= =?UTF-8?q?=ED=8F=AC=EC=A6=88=20=ED=99=94=EB=A9=B4=20API=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pose/impl/random/RandomPoseContract.kt | 18 +++- .../pose/impl/random/RandomPoseScreen.kt | 33 +++--- .../pose/impl/random/RandomPoseViewModel.kt | 102 ++++++++++++++---- 3 files changed, 117 insertions(+), 36 deletions(-) diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseContract.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseContract.kt index 3a88ad4ca..870feb885 100644 --- a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseContract.kt +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseContract.kt @@ -7,9 +7,18 @@ import kotlinx.collections.immutable.persistentListOf data class RandomPoseUiState( val isLoading: Boolean = false, val isShowTutorial: Boolean = true, - val currentPose: Pose = Pose(), - val randomPoseList: ImmutableList = persistentListOf(), -) + val currentIndex: Int = 0, + val poseList: ImmutableList = persistentListOf(), +) { + val currentPose: Pose? + get() = poseList.getOrNull(currentIndex) + + val hasPrevious: Boolean + get() = currentIndex > 0 + + val hasNext: Boolean + get() = currentIndex < poseList.lastIndex +} sealed interface RandomPoseIntent { data object EnterRandomPoseScreen : RandomPoseIntent @@ -23,9 +32,12 @@ sealed interface RandomPoseIntent { data object ClickCloseIcon : RandomPoseIntent data object ClickGoToDetailIcon : RandomPoseIntent data object ClickScrapIcon : RandomPoseIntent + data object SwipeLeft : RandomPoseIntent + data object SwipeRight : RandomPoseIntent } sealed interface RandomPoseEffect { data object NavigateBack : RandomPoseEffect data class NavigateToDetail(val pose: Pose) : RandomPoseEffect + data class ShowToast(val message: String) : RandomPoseEffect } diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseScreen.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseScreen.kt index c66f2887d..36a19c24b 100644 --- a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseScreen.kt +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseScreen.kt @@ -39,6 +39,7 @@ internal fun RandomPoseRoute( when (sideEffect) { RandomPoseEffect.NavigateBack -> navigateBack() is RandomPoseEffect.NavigateToDetail -> navigateToPoseDetail(sideEffect.pose) + is RandomPoseEffect.ShowToast -> Unit // TODO: Toast 처리 } } @@ -70,21 +71,23 @@ internal fun RandomPoseScreen( onClose = { onIntent(RandomPoseIntent.ClickCloseIcon) }, ) VerticalSpacer(42.dp) - RandomPoseImage( - modifier = Modifier - .fillMaxWidth() - .weight(1f), - pose = uiState.currentPose, - ) - RandomPoseFloatingBarContent( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp), - isScrapped = uiState.currentPose.isScrapped, - onClickClose = { onIntent(RandomPoseIntent.ClickCloseIcon) }, - onClickGoToDetail = { onIntent(RandomPoseIntent.ClickGoToDetailIcon) }, - onClickScrap = { onIntent(RandomPoseIntent.ClickScrapIcon) }, - ) + uiState.currentPose?.let { pose -> + RandomPoseImage( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + pose = pose, + ) + RandomPoseFloatingBarContent( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + isScrapped = pose.isScrapped, + onClickClose = { onIntent(RandomPoseIntent.ClickCloseIcon) }, + onClickGoToDetail = { onIntent(RandomPoseIntent.ClickGoToDetailIcon) }, + onClickScrap = { onIntent(RandomPoseIntent.ClickScrapIcon) }, + ) + } } if (uiState.isShowTutorial) { diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseViewModel.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseViewModel.kt index 43aa4cf90..4a03fc672 100644 --- a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseViewModel.kt +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseViewModel.kt @@ -1,6 +1,8 @@ package com.neki.android.feature.pose.impl.random import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.neki.android.core.dataapi.repository.PoseRepository import com.neki.android.core.model.PeopleCount import com.neki.android.core.model.Pose import com.neki.android.core.ui.MviIntentStore @@ -9,11 +11,14 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.launch +import timber.log.Timber @HiltViewModel(assistedFactory = RandomPoseViewModel.Factory::class) internal class RandomPoseViewModel @AssistedInject constructor( @Assisted private val peopleCount: PeopleCount, + private val poseRepository: PoseRepository, ) : ViewModel() { @AssistedFactory @@ -21,12 +26,6 @@ internal class RandomPoseViewModel @AssistedInject constructor( fun create(peopleCount: PeopleCount): RandomPoseViewModel } - private val dummyPoseList = persistentListOf( - Pose(id = 1, poseImageUrl = "https://picsum.photos/seed/random1/400/520", peopleCount = peopleCount.value), - Pose(id = 2, poseImageUrl = "https://picsum.photos/seed/random2/400/520", peopleCount = peopleCount.value), - Pose(id = 3, poseImageUrl = "https://picsum.photos/seed/random3/400/520", peopleCount = peopleCount.value), - ) - val store: MviIntentStore = mviIntentStore( initialState = RandomPoseUiState(), @@ -41,7 +40,7 @@ internal class RandomPoseViewModel @AssistedInject constructor( postSideEffect: (RandomPoseEffect) -> Unit, ) { when (intent) { - RandomPoseIntent.EnterRandomPoseScreen -> fetchInitialData(reduce) + RandomPoseIntent.EnterRandomPoseScreen -> fetchInitialPoses(reduce, postSideEffect) // 튜토리얼 RandomPoseIntent.ClickLeftSwipe -> Unit @@ -51,23 +50,90 @@ internal class RandomPoseViewModel @AssistedInject constructor( // 기본화면 RandomPoseIntent.ClickCloseIcon -> postSideEffect(RandomPoseEffect.NavigateBack) RandomPoseIntent.ClickGoToDetailIcon -> { - if (state.currentPose.id != 0L) { - postSideEffect(RandomPoseEffect.NavigateToDetail(state.currentPose)) + state.currentPose?.let { pose -> + postSideEffect(RandomPoseEffect.NavigateToDetail(pose)) } } - RandomPoseIntent.ClickScrapIcon -> reduce { - copy(currentPose = currentPose.copy(isScrapped = !currentPose.isScrapped)) + RandomPoseIntent.ClickScrapIcon -> { + state.currentPose?.let { currentPose -> + reduce { + copy( + poseList = poseList.map { pose -> + if (pose.id == currentPose.id) { + pose.copy(isScrapped = !pose.isScrapped) + } else { + pose + } + }.toImmutableList() + ) + } + } + } + + RandomPoseIntent.SwipeLeft -> { + if (state.hasPrevious) { + reduce { copy(currentIndex = currentIndex - 1) } + } + } + + RandomPoseIntent.SwipeRight -> { + if (state.hasNext) { + reduce { copy(currentIndex = currentIndex + 1) } + // 마지막 포즈에 도달하면 다음 포즈 미리 로드 + if (state.currentIndex + 1 >= state.poseList.lastIndex) { + fetchNextPose(reduce, postSideEffect) + } + } } } } - private fun fetchInitialData(reduce: (RandomPoseUiState.() -> RandomPoseUiState) -> Unit) { - reduce { - copy( - randomPoseList = dummyPoseList, - currentPose = dummyPoseList.firstOrNull() ?: Pose(), - ) + private fun fetchInitialPoses( + reduce: (RandomPoseUiState.() -> RandomPoseUiState) -> Unit, + postSideEffect: (RandomPoseEffect) -> Unit, + ) { + viewModelScope.launch { + reduce { copy(isLoading = true) } + + val poses = mutableListOf() + + // 초기에 2개 로드 + repeat(2) { + poseRepository.getRandomPose() + .onSuccess { pose -> poses.add(pose) } + .onFailure { error -> Timber.e(error) } + } + + if (poses.isNotEmpty()) { + reduce { + copy( + isLoading = false, + poseList = poses.toImmutableList(), + currentIndex = 0, + ) + } + } else { + reduce { copy(isLoading = false) } + postSideEffect(RandomPoseEffect.ShowToast("포즈를 불러오는데 실패했어요")) + } + } + } + + private fun fetchNextPose( + reduce: (RandomPoseUiState.() -> RandomPoseUiState) -> Unit, + postSideEffect: (RandomPoseEffect) -> Unit, + ) { + viewModelScope.launch { + poseRepository.getRandomPose() + .onSuccess { pose -> + reduce { + copy(poseList = (poseList + pose).toImmutableList()) + } + } + .onFailure { error -> + Timber.e(error) + } } } } From f4f5ff3f7d2a20e1e928bf1a8fae689b70a4dfd3 Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Fri, 30 Jan 2026 01:29:31 +0900 Subject: [PATCH 04/22] =?UTF-8?q?[refactor]=20#70:=20BottomSheet=EC=9D=B4?= =?UTF-8?q?=20=EC=A4=91=EA=B0=84=EC=97=90=20=EA=B1=B8=EC=B9=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `rememberModalBottomSheetState`에 `skipPartiallyExpanded = true` 속성을 적용하여 `PeopleCountBottomSheet`과 `DoubleButtonOptionBottomSheet`이 완전히 확장된 상태로만 표시되도록 수정합니다. --- .../android/core/ui/component/DoubleButtonOptionBottomSheet.kt | 2 +- .../feature/pose/impl/main/component/PeopleCountBottomSheet.kt | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/core/ui/src/main/java/com/neki/android/core/ui/component/DoubleButtonOptionBottomSheet.kt b/core/ui/src/main/java/com/neki/android/core/ui/component/DoubleButtonOptionBottomSheet.kt index 4873c7047..5be1d7652 100644 --- a/core/ui/src/main/java/com/neki/android/core/ui/component/DoubleButtonOptionBottomSheet.kt +++ b/core/ui/src/main/java/com/neki/android/core/ui/component/DoubleButtonOptionBottomSheet.kt @@ -43,7 +43,7 @@ fun DoubleButtonOptionBottomSheet( onOptionSelect: (T) -> Unit, modifier: Modifier = Modifier, buttonEnabled: Boolean = true, - sheetState: SheetState = rememberModalBottomSheetState(), + sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), ) { ModalBottomSheet( modifier = modifier, diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/component/PeopleCountBottomSheet.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/component/PeopleCountBottomSheet.kt index 22fc6a5a9..2fe3bc031 100644 --- a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/component/PeopleCountBottomSheet.kt +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/component/PeopleCountBottomSheet.kt @@ -9,6 +9,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -36,6 +37,7 @@ internal fun PeopleCountBottomSheet( onDismissRequest = onDismissRequest, containerColor = NekiTheme.colorScheme.white, dragHandle = { BottomSheetDragHandle() }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), ) { PeopleCountBottomSheetContent( selectedItem = selectedItem, From 12b564f800f782c2f000f02709718245f683187f Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Fri, 30 Jan 2026 01:53:15 +0900 Subject: [PATCH 05/22] =?UTF-8?q?[feat]=20#70:=20=ED=8F=AC=EC=A6=88=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20Paging=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PosePagingSource 생성 - PoseRepository에 getPosesFlow 추가 - PoseViewModel에 flatMapLatest 패턴으로 headCount 필터 적용 - PoseScreen/PoseListContent에 LazyPagingItems 적용 - paging 라이브러리 의존성 추가 --- core/data-api/build.gradle.kts | 1 + .../core/dataapi/repository/PoseRepository.kt | 7 ++ .../core/data/paging/PosePagingSource.kt | 43 ++++++++++++ .../repository/impl/PoseRepositoryImpl.kt | 29 +++++++++ .../neki/android/core/model/PoseContract.kt | 1 - feature/pose/impl/build.gradle.kts | 1 + .../feature/pose/impl/main/PoseScreen.kt | 22 +++---- .../feature/pose/impl/main/PoseViewModel.kt | 65 +++++++++---------- .../impl/main/component/PoseListContent.kt | 35 ++++------ gradle/libs.versions.toml | 4 ++ 10 files changed, 138 insertions(+), 70 deletions(-) create mode 100644 core/data/src/main/java/com/neki/android/core/data/paging/PosePagingSource.kt diff --git a/core/data-api/build.gradle.kts b/core/data-api/build.gradle.kts index 2cbcec861..54e4a299e 100644 --- a/core/data-api/build.gradle.kts +++ b/core/data-api/build.gradle.kts @@ -6,4 +6,5 @@ dependencies { implementation(projects.core.model) implementation(libs.kotlinx.coroutines.core) api(libs.androidx.datastore.preferences) + api(libs.androidx.paging.common) } \ No newline at end of file diff --git a/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/PoseRepository.kt b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/PoseRepository.kt index 564878b1d..3e9d03e0e 100644 --- a/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/PoseRepository.kt +++ b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/PoseRepository.kt @@ -1,8 +1,10 @@ package com.neki.android.core.dataapi.repository +import androidx.paging.PagingData import com.neki.android.core.model.PeopleCount import com.neki.android.core.model.Pose import com.neki.android.core.model.SortOrder +import kotlinx.coroutines.flow.Flow interface PoseRepository { suspend fun getPoses( @@ -12,6 +14,11 @@ interface PoseRepository { sortOrder: SortOrder = SortOrder.DESC, ): Result> + fun getPosesFlow( + headCount: PeopleCount? = null, + sortOrder: SortOrder = SortOrder.DESC, + ): Flow> + suspend fun getPose(poseId: Long): Result suspend fun getRandomPose(): Result diff --git a/core/data/src/main/java/com/neki/android/core/data/paging/PosePagingSource.kt b/core/data/src/main/java/com/neki/android/core/data/paging/PosePagingSource.kt new file mode 100644 index 000000000..8c3ff2aa4 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/paging/PosePagingSource.kt @@ -0,0 +1,43 @@ +package com.neki.android.core.data.paging + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.neki.android.core.data.remote.api.PoseService +import com.neki.android.core.model.PeopleCount +import com.neki.android.core.model.Pose +import com.neki.android.core.model.SortOrder + +class PosePagingSource( + private val poseService: PoseService, + private val headCount: PeopleCount?, + private val sortOrder: SortOrder, +) : PagingSource() { + + override suspend fun load(params: LoadParams): LoadResult { + return try { + val page = params.key ?: 0 + val response = poseService.getPoses( + page = page, + size = params.loadSize, + headCount = headCount?.name, + sortOrder = sortOrder.name, + ) + val poses = response.data.toModels() + + LoadResult.Page( + data = poses, + prevKey = if (page == 0) null else page - 1, + nextKey = if (poses.isEmpty()) null else page + 1, + ) + } catch (e: Exception) { + LoadResult.Error(e) + } + } + + override fun getRefreshKey(state: PagingState): Int? { + return state.anchorPosition?.let { anchorPosition -> + state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1) + ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1) + } + } +} diff --git a/core/data/src/main/java/com/neki/android/core/data/repository/impl/PoseRepositoryImpl.kt b/core/data/src/main/java/com/neki/android/core/data/repository/impl/PoseRepositoryImpl.kt index 58b638a3b..b9c3583d5 100644 --- a/core/data/src/main/java/com/neki/android/core/data/repository/impl/PoseRepositoryImpl.kt +++ b/core/data/src/main/java/com/neki/android/core/data/repository/impl/PoseRepositoryImpl.kt @@ -1,16 +1,22 @@ package com.neki.android.core.data.repository.impl +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import com.neki.android.core.data.paging.PosePagingSource import com.neki.android.core.data.remote.api.PoseService import com.neki.android.core.data.util.runSuspendCatching import com.neki.android.core.dataapi.repository.PoseRepository import com.neki.android.core.model.PeopleCount import com.neki.android.core.model.Pose import com.neki.android.core.model.SortOrder +import kotlinx.coroutines.flow.Flow import javax.inject.Inject class PoseRepositoryImpl @Inject constructor( private val poseService: PoseService, ) : PoseRepository { + override suspend fun getPoses( page: Int, size: Int, @@ -25,6 +31,25 @@ class PoseRepositoryImpl @Inject constructor( ).data.toModels() } + override fun getPosesFlow( + headCount: PeopleCount?, + sortOrder: SortOrder, + ): Flow> { + return Pager( + config = PagingConfig( + pageSize = PAGE_SIZE, + enablePlaceholders = false, + ), + pagingSourceFactory = { + PosePagingSource( + poseService = poseService, + headCount = headCount, + sortOrder = sortOrder, + ) + }, + ).flow + } + override suspend fun getPose(poseId: Long): Result = runSuspendCatching { poseService.getPose(poseId).data.toModel() } @@ -32,4 +57,8 @@ class PoseRepositoryImpl @Inject constructor( override suspend fun getRandomPose(): Result = runSuspendCatching { poseService.getRandomPose().data.toModel() } + + companion object { + private const val PAGE_SIZE = 20 + } } diff --git a/core/model/src/main/java/com/neki/android/core/model/PoseContract.kt b/core/model/src/main/java/com/neki/android/core/model/PoseContract.kt index 7edab8485..100ab982c 100644 --- a/core/model/src/main/java/com/neki/android/core/model/PoseContract.kt +++ b/core/model/src/main/java/com/neki/android/core/model/PoseContract.kt @@ -9,7 +9,6 @@ data class PoseState( val selectedPeopleCount: PeopleCount? = null, val selectedRandomPosePeopleCount: PeopleCount? = null, val isShowScrappedPose: Boolean = false, - val poseList: ImmutableList = persistentListOf(), val scrappedPoseList: ImmutableList = persistentListOf(), val isShowPeopleCountBottomSheet: Boolean = false, val isShowRandomPosePeopleCountBottomSheet: Boolean = false, diff --git a/feature/pose/impl/build.gradle.kts b/feature/pose/impl/build.gradle.kts index 4c4ac86d4..9e2beb39e 100644 --- a/feature/pose/impl/build.gradle.kts +++ b/feature/pose/impl/build.gradle.kts @@ -10,4 +10,5 @@ dependencies { implementation(projects.feature.pose.api) implementation(libs.kotlinx.collections.immutable) + implementation(libs.androidx.paging.compose) } diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/PoseScreen.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/PoseScreen.kt index 6366a41b2..acb2a20c4 100644 --- a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/PoseScreen.kt +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/PoseScreen.kt @@ -21,8 +21,8 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.neki.android.core.designsystem.DevicePreview -import com.neki.android.core.designsystem.ui.theme.NekiTheme +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems import com.neki.android.core.model.PeopleCount import com.neki.android.core.model.Pose import com.neki.android.core.model.PoseEffect @@ -36,7 +36,6 @@ import com.neki.android.feature.pose.impl.main.component.PoseListContent import com.neki.android.feature.pose.impl.main.component.PoseTopBar import com.neki.android.feature.pose.impl.main.component.RandomPosePeopleCountBottomSheet import com.neki.android.feature.pose.impl.main.component.RecommendationChip -import kotlinx.collections.immutable.ImmutableList @Composable internal fun PoseRoute( @@ -46,6 +45,7 @@ internal fun PoseRoute( navigateToNotification: () -> Unit, ) { val uiState by viewModel.store.uiState.collectAsStateWithLifecycle() + val posePagingItems = viewModel.posePagingData.collectAsLazyPagingItems() val context = LocalContext.current viewModel.store.sideEffects.collectWithLifecycle { sideEffect -> @@ -59,6 +59,7 @@ internal fun PoseRoute( PoseScreen( uiState = uiState, + posePagingItems = posePagingItems, onIntent = viewModel.store::onIntent, ) } @@ -66,6 +67,7 @@ internal fun PoseRoute( @Composable fun PoseScreen( uiState: PoseState = PoseState(), + posePagingItems: LazyPagingItems, onIntent: (PoseIntent) -> Unit = {}, ) { Box( @@ -74,7 +76,7 @@ fun PoseScreen( PoseContent( selectedPeopleCount = uiState.selectedPeopleCount, isScrapSelected = uiState.isShowScrappedPose, - poseList = if (uiState.isShowScrappedPose) uiState.scrappedPoseList else uiState.poseList, + posePagingItems = posePagingItems, onClickAlarmIcon = { onIntent(PoseIntent.ClickAlarmIcon) }, onClickPeopleCount = { onIntent(PoseIntent.ClickPeopleCountChip) }, onClickScrap = { onIntent(PoseIntent.ClickScrapChip) }, @@ -112,7 +114,7 @@ fun PoseContent( modifier: Modifier = Modifier, selectedPeopleCount: PeopleCount?, isScrapSelected: Boolean, - poseList: ImmutableList, + posePagingItems: LazyPagingItems, onClickAlarmIcon: () -> Unit = {}, onClickPeopleCount: () -> Unit = {}, onClickScrap: () -> Unit = {}, @@ -142,7 +144,7 @@ fun PoseContent( ) { PoseListContent( topPadding = topPadding + POSE_LAYOUT_DEFAULT_TOP_PADDING.dp, - poseList = poseList, + posePagingItems = posePagingItems, state = lazyState, onClickItem = onClickPoseItem, ) @@ -162,10 +164,4 @@ fun PoseContent( } } -@DevicePreview -@Composable -private fun PoseScreenPreview() { - NekiTheme { - PoseScreen() - } -} +// Preview is not available for PagingItems component diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/PoseViewModel.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/PoseViewModel.kt index 856ab3da0..5d8380390 100644 --- a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/PoseViewModel.kt +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/PoseViewModel.kt @@ -2,17 +2,22 @@ package com.neki.android.feature.pose.impl.main import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn import com.neki.android.core.dataapi.repository.PoseRepository import com.neki.android.core.model.PeopleCount +import com.neki.android.core.model.Pose import com.neki.android.core.model.PoseEffect import com.neki.android.core.model.PoseIntent import com.neki.android.core.model.PoseState +import com.neki.android.core.model.SortOrder import com.neki.android.core.ui.MviIntentStore import com.neki.android.core.ui.mviIntentStore import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.launch -import timber.log.Timber +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flatMapLatest import javax.inject.Inject @HiltViewModel @@ -20,11 +25,22 @@ internal class PoseViewModel @Inject constructor( private val poseRepository: PoseRepository, ) : ViewModel() { + private val _headCountFilter = MutableStateFlow(null) + + @OptIn(ExperimentalCoroutinesApi::class) + val posePagingData: Flow> = _headCountFilter + .flatMapLatest { headCount -> + poseRepository.getPosesFlow( + headCount = headCount, + sortOrder = SortOrder.DESC, + ) + } + .cachedIn(viewModelScope) + val store: MviIntentStore = mviIntentStore( initialState = PoseState(), onIntent = ::onIntent, - initialFetchData = { store.onIntent(PoseIntent.EnterPoseScreen) }, ) private fun onIntent( @@ -35,10 +51,11 @@ internal class PoseViewModel @Inject constructor( ) { when (intent) { // Pose Main - PoseIntent.EnterPoseScreen -> fetchPoses(reduce, postSideEffect) + PoseIntent.EnterPoseScreen -> Unit PoseIntent.ClickAlarmIcon -> postSideEffect(PoseEffect.NavigateToNotification) PoseIntent.ClickPeopleCountChip -> reduce { copy(isShowPeopleCountBottomSheet = true) } is PoseIntent.ClickPeopleCountSheetItem -> { + _headCountFilter.value = intent.peopleCount reduce { copy( isShowScrappedPose = false, @@ -46,16 +63,18 @@ internal class PoseViewModel @Inject constructor( isShowPeopleCountBottomSheet = false, ) } - fetchPoses(reduce, postSideEffect, intent.peopleCount) } PoseIntent.DismissPeopleCountBottomSheet -> reduce { copy(isShowPeopleCountBottomSheet = false) } PoseIntent.DismissRandomPosePeopleCountBottomSheet -> reduce { copy(isShowRandomPosePeopleCountBottomSheet = false) } - PoseIntent.ClickScrapChip -> reduce { - copy( - isShowScrappedPose = !isShowScrappedPose, - selectedPeopleCount = null, - ) + PoseIntent.ClickScrapChip -> { + _headCountFilter.value = null + reduce { + copy( + isShowScrappedPose = !isShowScrappedPose, + selectedPeopleCount = null, + ) + } } is PoseIntent.ClickPoseItem -> { @@ -71,28 +90,4 @@ internal class PoseViewModel @Inject constructor( } } } - - private fun fetchPoses( - reduce: (PoseState.() -> PoseState) -> Unit, - postSideEffect: (PoseEffect) -> Unit, - headCount: PeopleCount? = null, - ) { - viewModelScope.launch { - reduce { copy(isLoading = true) } - poseRepository.getPoses(headCount = headCount) - .onSuccess { poses -> - reduce { - copy( - isLoading = false, - poseList = poses.toImmutableList(), - ) - } - } - .onFailure { error -> - Timber.e(error) - reduce { copy(isLoading = false) } - postSideEffect(PoseEffect.ShowToast("포즈를 불러오는데 실패했어요")) - } - } - } } diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/component/PoseListContent.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/component/PoseListContent.kt index 7a18b844c..26e1d0ad1 100644 --- a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/component/PoseListContent.kt +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/component/PoseListContent.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells -import androidx.compose.foundation.lazy.staggeredgrid.items import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable @@ -16,21 +15,19 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.itemKey import coil3.compose.AsyncImage -import com.neki.android.core.designsystem.ComponentPreview import com.neki.android.core.designsystem.modifier.noRippleClickable -import com.neki.android.core.designsystem.ui.theme.NekiTheme import com.neki.android.core.model.Pose import com.neki.android.feature.pose.impl.const.PoseConst.POSE_LAYOUT_BOTTOM_PADDING import com.neki.android.feature.pose.impl.const.PoseConst.POSE_LAYOUT_VERTICAL_SPACING -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf @Composable internal fun PoseListContent( topPadding: Dp, modifier: Modifier = Modifier, - poseList: ImmutableList = persistentListOf(), + posePagingItems: LazyPagingItems, state: LazyStaggeredGridState = rememberLazyStaggeredGridState(), onClickItem: (Pose) -> Unit = {}, ) { @@ -44,11 +41,16 @@ internal fun PoseListContent( verticalItemSpacing = POSE_LAYOUT_VERTICAL_SPACING.dp, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - items(poseList) { pose -> - PoseItem( - pose = pose, - onClickItem = onClickItem, - ) + items( + count = posePagingItems.itemCount, + key = posePagingItems.itemKey { it.id }, + ) { index -> + posePagingItems[index]?.let { pose -> + PoseItem( + pose = pose, + onClickItem = onClickItem, + ) + } } } } @@ -69,13 +71,4 @@ private fun PoseItem( ) } -@ComponentPreview -@Composable -private fun PoseListContentPreview() { - NekiTheme { - PoseListContent( - topPadding = 0.dp, - poseList = persistentListOf(), - ) - } -} +// Preview is not available for PagingItems component diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 91a5e5587..b675ba997 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,6 +19,7 @@ hilt = "2.54" ktor = "2.3.12" androidxDatastore = "1.1.2" kotlinxCoroutines = "1.8.1" +paging = "3.3.6" kotlinxCollectionsImmutable = "0.4.0" kotlinxDatetime = "0.7.1" mapSdk = "3.23.0" @@ -63,6 +64,9 @@ androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifec androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "androidxLifecycleViewModel" } kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } + +androidx-paging-common = { group = "androidx.paging", name = "paging-common", version.ref = "paging" } +androidx-paging-compose = { group = "androidx.paging", name = "paging-compose", version.ref = "paging" } kotlinx-collections-immutable = { group = "org.jetbrains.kotlinx", name = "kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version.ref = "kotlinxDatetime" } From 63f00d4223838c35de4bf2e14061d392a083ce55 Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Fri, 30 Jan 2026 01:56:27 +0900 Subject: [PATCH 06/22] =?UTF-8?q?[build]=20#70:=20detekt=20UnusedPrivatePr?= =?UTF-8?q?operty=20suppress=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android/feature/pose/impl/random/RandomPoseViewModel.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseViewModel.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseViewModel.kt index 4a03fc672..6d0db24c5 100644 --- a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseViewModel.kt +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseViewModel.kt @@ -17,7 +17,7 @@ import timber.log.Timber @HiltViewModel(assistedFactory = RandomPoseViewModel.Factory::class) internal class RandomPoseViewModel @AssistedInject constructor( - @Assisted private val peopleCount: PeopleCount, + @Suppress("UnusedPrivateProperty") @Assisted private val peopleCount: PeopleCount, private val poseRepository: PoseRepository, ) : ViewModel() { @@ -65,7 +65,7 @@ internal class RandomPoseViewModel @AssistedInject constructor( } else { pose } - }.toImmutableList() + }.toImmutableList(), ) } } From 3fe975d9b818db133932ffa401b0ec75051d60c6 Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Fri, 30 Jan 2026 01:56:31 +0900 Subject: [PATCH 07/22] =?UTF-8?q?[refactor]=20#70:=20PoseDetailResponse?= =?UTF-8?q?=EB=A5=BC=20PoseItemResponse=EB=A1=9C=20=EC=9D=B4=EB=A6=84=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 포즈 목록 아이템과 포즈 상세 조회 시 사용되는 데이터 모델이 동일하므로, `PoseDetailResponse`를 `PoseItemResponse`로 이름을 변경하여 재사용합니다. 이에 따라 `PoseResponse` 내부에 있던 `Item` 클래스를 제거하고, `PoseService`의 관련 API 반환 타입을 수정합니다. --- .../core/data/remote/api/PoseService.kt | 6 +++--- .../remote/model/response/PoseResponse.kt | 20 ++----------------- 2 files changed, 5 insertions(+), 21 deletions(-) diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/api/PoseService.kt b/core/data/src/main/java/com/neki/android/core/data/remote/api/PoseService.kt index 3474fab82..f77c6b68f 100644 --- a/core/data/src/main/java/com/neki/android/core/data/remote/api/PoseService.kt +++ b/core/data/src/main/java/com/neki/android/core/data/remote/api/PoseService.kt @@ -1,7 +1,7 @@ package com.neki.android.core.data.remote.api import com.neki.android.core.data.remote.model.response.BasicResponse -import com.neki.android.core.data.remote.model.response.PoseDetailResponse +import com.neki.android.core.data.remote.model.response.PoseItemResponse import com.neki.android.core.data.remote.model.response.PoseResponse import io.ktor.client.HttpClient import io.ktor.client.call.body @@ -28,12 +28,12 @@ class PoseService @Inject constructor( } // 포즈 상세 조회 - suspend fun getPose(poseId: Long): BasicResponse { + suspend fun getPose(poseId: Long): BasicResponse { return client.get("/api/poses/$poseId").body() } // 랜덤 포즈 조회 - suspend fun getRandomPose(): BasicResponse { + suspend fun getRandomPose(): BasicResponse { return client.get("/api/poses/random").body() } } diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/response/PoseResponse.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/PoseResponse.kt index 1d014c44b..ee5cdd081 100644 --- a/core/data/src/main/java/com/neki/android/core/data/remote/model/response/PoseResponse.kt +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/PoseResponse.kt @@ -8,29 +8,13 @@ import kotlinx.serialization.Serializable @Serializable data class PoseResponse( @SerialName("hasNext") val hasNext: Boolean, - @SerialName("items") val items: List, + @SerialName("items") val items: List, ) { - @Serializable - data class Item( - @SerialName("poseId") val poseId: Long, - @SerialName("headCount") val headCount: String, - @SerialName("imageUrl") val imageUrl: String, - @SerialName("contentType") val contentType: String, - @SerialName("createdAt") val createdAt: String, - ) { - internal fun toModel() = Pose( - id = poseId, - poseImageUrl = imageUrl, - isScrapped = false, - peopleCount = PeopleCount.entries.find { it.name == headCount }?.value ?: 1, - ) - } - fun toModels() = items.map { it.toModel() } } @Serializable -data class PoseDetailResponse( +data class PoseItemResponse( @SerialName("poseId") val poseId: Long, @SerialName("headCount") val headCount: String, @SerialName("imageUrl") val imageUrl: String, From a956c890dd0748a85d7d1f50066af3ff97fcd3f8 Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Fri, 30 Jan 2026 02:10:06 +0900 Subject: [PATCH 08/22] =?UTF-8?q?[refactor]=20#70:=20PagingConfig=20?= =?UTF-8?q?=EC=83=81=EC=88=98=20=EC=9C=84=EC=B9=98=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EB=B0=8F=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PagingConfig`에서 사용되는 상수들의 위치를 `companion object`에서 파일 최상단으로 이동시킵니다. 또한, `PagingConfig`에 `initialLoadSize`와 `prefetchDistance` 설정을 추가하여 페이징 동작을 명확히 정의합니다. --- .../core/data/repository/impl/PoseRepositoryImpl.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/core/data/src/main/java/com/neki/android/core/data/repository/impl/PoseRepositoryImpl.kt b/core/data/src/main/java/com/neki/android/core/data/repository/impl/PoseRepositoryImpl.kt index b9c3583d5..1f5b2e4ac 100644 --- a/core/data/src/main/java/com/neki/android/core/data/repository/impl/PoseRepositoryImpl.kt +++ b/core/data/src/main/java/com/neki/android/core/data/repository/impl/PoseRepositoryImpl.kt @@ -13,6 +13,9 @@ import com.neki.android.core.model.SortOrder import kotlinx.coroutines.flow.Flow import javax.inject.Inject +private const val PAGE_SIZE = 20 +private const val PREFETCH_DISTANCE = 10 + class PoseRepositoryImpl @Inject constructor( private val poseService: PoseService, ) : PoseRepository { @@ -38,6 +41,8 @@ class PoseRepositoryImpl @Inject constructor( return Pager( config = PagingConfig( pageSize = PAGE_SIZE, + initialLoadSize = PAGE_SIZE, + prefetchDistance = PREFETCH_DISTANCE, enablePlaceholders = false, ), pagingSourceFactory = { @@ -57,8 +62,4 @@ class PoseRepositoryImpl @Inject constructor( override suspend fun getRandomPose(): Result = runSuspendCatching { poseService.getRandomPose().data.toModel() } - - companion object { - private const val PAGE_SIZE = 20 - } } From 3df5f8464bd6727d7df66da5abb6b999d963f904 Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Fri, 30 Jan 2026 02:12:34 +0900 Subject: [PATCH 09/22] =?UTF-8?q?[refactor]=20#70:=20=ED=8F=AC=EC=A6=88=20?= =?UTF-8?q?=EB=9E=9C=EB=8D=A4=20=ED=99=94=EB=A9=B4=20=ED=94=8C=EB=A1=9C?= =?UTF-8?q?=ED=8C=85=20=EB=B0=94=20=ED=8C=A8=EB=94=A9=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `RandomPoseScreen`에서 사용하던 `RandomPoseFloatingBarContent`의 `horizontal` 패딩을 제거하고, `RandomPoseFloatingBar`의 내부 컨테이너로 이동시킵니다. 이를 통해 플로팅 바 배경의 `horizontal` 패딩을 제거하여 화면 전체 너비를 차지하도록 수정했습니다. --- .../android/feature/pose/impl/random/RandomPoseScreen.kt | 4 +--- .../pose/impl/random/component/RandomPoseFloatingBar.kt | 5 +++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseScreen.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseScreen.kt index 36a19c24b..1f37cd77a 100644 --- a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseScreen.kt +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseScreen.kt @@ -79,9 +79,7 @@ internal fun RandomPoseScreen( pose = pose, ) RandomPoseFloatingBarContent( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp), + modifier = Modifier.fillMaxWidth(), isScrapped = pose.isScrapped, onClickClose = { onIntent(RandomPoseIntent.ClickCloseIcon) }, onClickGoToDetail = { onIntent(RandomPoseIntent.ClickGoToDetailIcon) }, diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/component/RandomPoseFloatingBar.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/component/RandomPoseFloatingBar.kt index 773bae28c..f3ab73eb3 100644 --- a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/component/RandomPoseFloatingBar.kt +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/component/RandomPoseFloatingBar.kt @@ -44,11 +44,13 @@ internal fun RandomPoseFloatingBarContent( ), alpha = 0.24f, ) + .padding(horizontal = 20.dp) .padding(top = 38.dp, bottom = 34.dp), ) { Row( modifier = Modifier .clip(CircleShape) + .fillMaxWidth() .background( color = NekiTheme.colorScheme.white.copy(alpha = 0.6f), shape = CircleShape, @@ -150,8 +152,7 @@ private fun RandomPoseFloatingBarContentScrappedPreview() { NekiTheme { RandomPoseFloatingBarContent( modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp), + .fillMaxWidth(), isScrapped = true, ) } From 323d24579fc047fa983232e6f92e039e34d95f09 Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Fri, 30 Jan 2026 02:13:50 +0900 Subject: [PATCH 10/22] =?UTF-8?q?[refactor]=20#70:=20=ED=8F=AC=EC=A6=88=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20API=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=EB=AA=A8=EB=8D=B8=EC=97=90=EC=84=9C=20'scrap'=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android/core/data/remote/model/response/PoseResponse.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/response/PoseResponse.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/PoseResponse.kt index ee5cdd081..9110aa952 100644 --- a/core/data/src/main/java/com/neki/android/core/data/remote/model/response/PoseResponse.kt +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/PoseResponse.kt @@ -18,14 +18,12 @@ data class PoseItemResponse( @SerialName("poseId") val poseId: Long, @SerialName("headCount") val headCount: String, @SerialName("imageUrl") val imageUrl: String, - @SerialName("scrap") val scrap: Boolean, @SerialName("contentType") val contentType: String, @SerialName("createdAt") val createdAt: String, ) { internal fun toModel() = Pose( id = poseId, poseImageUrl = imageUrl, - isScrapped = scrap, peopleCount = PeopleCount.entries.find { it.name == headCount }?.value ?: 1, ) } From 3d32488d8e53a58db8bb629a2c14f9b2d36b3288 Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Fri, 30 Jan 2026 02:19:06 +0900 Subject: [PATCH 11/22] =?UTF-8?q?[refactor]=20#70:=20=ED=8F=AC=EC=A6=88=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=ED=99=94=EB=A9=B4=20=EC=A7=84=EC=9E=85=20?= =?UTF-8?q?=EC=8B=9C=20Pose=20=EA=B0=9D=EC=B2=B4=20=EB=8C=80=EC=8B=A0=20ID?= =?UTF-8?q?=EB=A5=BC=20=EC=A0=84=EB=8B=AC=ED=95=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 포즈 목록에서 포즈 상세 화면으로 이동할 때 `Pose` 객체 전체를 직렬화하여 전달하던 방식에서, `poseId`만 전달하도록 수정합니다. 이를 통해 상세 화면에서는 전달받은 `poseId`로 포즈 데이터를 직접 조회하여 화면을 구성합니다. - `PoseNavKey.PoseDetail`의 인자를 `Pose`에서 `Long` (poseId)으로 변경 - `PoseDetailViewModel`에서 `poseId`를 받아 `poseRepository.getPose()`를 호출하여 데이터를 가져오도록 수정 - 화면 진입 시 데이터를 불러오는 `EnterPoseDetailScreen` 인텐트 추가 --- .../neki/android/core/model/PoseContract.kt | 2 +- .../android/feature/pose/api/PoseNavKey.kt | 6 ++--- .../pose/impl/detail/PoseDetailContract.kt | 1 + .../pose/impl/detail/PoseDetailViewModel.kt | 24 ++++++++++++++++--- .../feature/pose/impl/main/PoseScreen.kt | 4 ++-- .../feature/pose/impl/main/PoseViewModel.kt | 2 +- .../pose/impl/navigation/PoseEntryProvider.kt | 2 +- 7 files changed, 30 insertions(+), 11 deletions(-) diff --git a/core/model/src/main/java/com/neki/android/core/model/PoseContract.kt b/core/model/src/main/java/com/neki/android/core/model/PoseContract.kt index 100ab982c..c3ba585be 100644 --- a/core/model/src/main/java/com/neki/android/core/model/PoseContract.kt +++ b/core/model/src/main/java/com/neki/android/core/model/PoseContract.kt @@ -31,7 +31,7 @@ sealed interface PoseIntent { sealed interface PoseEffect { data object NavigateToNotification : PoseEffect data class NavigateToRandomPose(val peopleCount: PeopleCount) : PoseEffect - data class NavigateToPoseDetail(val pose: Pose) : PoseEffect + data class NavigateToPoseDetail(val poseId: Long) : PoseEffect data class ShowToast(val message: String) : PoseEffect } diff --git a/feature/pose/api/src/main/java/com/neki/android/feature/pose/api/PoseNavKey.kt b/feature/pose/api/src/main/java/com/neki/android/feature/pose/api/PoseNavKey.kt index 7da1185c3..f40d4cdd9 100644 --- a/feature/pose/api/src/main/java/com/neki/android/feature/pose/api/PoseNavKey.kt +++ b/feature/pose/api/src/main/java/com/neki/android/feature/pose/api/PoseNavKey.kt @@ -15,7 +15,7 @@ sealed interface PoseNavKey : NavKey { data class RandomPose(val peopleCount: PeopleCount) : PoseNavKey @Serializable - data class PoseDetail(val pose: Pose) : PoseNavKey + data class PoseDetail(val poseId: Long) : PoseNavKey } fun Navigator.navigateToPose() { @@ -26,6 +26,6 @@ fun Navigator.navigateToRandomPose(peopleCount: PeopleCount) { navigate(PoseNavKey.RandomPose(peopleCount)) } -fun Navigator.navigateToPoseDetail(pose: Pose) { - navigate(PoseNavKey.PoseDetail(pose)) +fun Navigator.navigateToPoseDetail(poseId: Long) { + navigate(PoseNavKey.PoseDetail(poseId)) } diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/detail/PoseDetailContract.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/detail/PoseDetailContract.kt index 108c3ee8e..245ef514f 100644 --- a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/detail/PoseDetailContract.kt +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/detail/PoseDetailContract.kt @@ -8,6 +8,7 @@ data class PoseDetailState( ) sealed interface PoseDetailIntent { + data object EnterPoseDetailScreen : PoseDetailIntent data object ClickBackIcon : PoseDetailIntent data object ClickScrapIcon : PoseDetailIntent } diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/detail/PoseDetailViewModel.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/detail/PoseDetailViewModel.kt index 1d681ada7..d818b20be 100644 --- a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/detail/PoseDetailViewModel.kt +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/detail/PoseDetailViewModel.kt @@ -1,6 +1,8 @@ package com.neki.android.feature.pose.impl.detail import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.neki.android.core.dataapi.repository.PoseRepository import com.neki.android.core.model.Pose import com.neki.android.core.ui.MviIntentStore import com.neki.android.core.ui.mviIntentStore @@ -8,20 +10,23 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import timber.log.Timber @HiltViewModel(assistedFactory = PoseDetailViewModel.Factory::class) class PoseDetailViewModel @AssistedInject constructor( - @Assisted private val pose: Pose, + @Assisted private val id: Long, + private val poseRepository: PoseRepository, ) : ViewModel() { @AssistedFactory interface Factory { - fun create(pose: Pose): PoseDetailViewModel + fun create(id: Long): PoseDetailViewModel } val store: MviIntentStore = mviIntentStore( - initialState = PoseDetailState(pose = pose), + initialState = PoseDetailState(), onIntent = ::onIntent, ) @@ -32,6 +37,7 @@ class PoseDetailViewModel @AssistedInject constructor( postSideEffect: (PoseDetailSideEffect) -> Unit, ) { when (intent) { + PoseDetailIntent.EnterPoseDetailScreen -> fetchPoseData(reduce) PoseDetailIntent.ClickBackIcon -> postSideEffect(PoseDetailSideEffect.NavigateBack) PoseDetailIntent.ClickScrapIcon -> { // TODO: API 연동 시 실제 스크랩 토글 구현 @@ -41,4 +47,16 @@ class PoseDetailViewModel @AssistedInject constructor( } } } + + private fun fetchPoseData(reduce: (PoseDetailState.() -> PoseDetailState) -> Unit) { + viewModelScope.launch { + poseRepository.getPose(poseId = id) + .onSuccess { data -> + reduce { copy(pose = data) } + } + .onFailure { error -> + Timber.e(error) + } + } + } } diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/PoseScreen.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/PoseScreen.kt index acb2a20c4..5c80b3e2c 100644 --- a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/PoseScreen.kt +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/PoseScreen.kt @@ -40,7 +40,7 @@ import com.neki.android.feature.pose.impl.main.component.RecommendationChip @Composable internal fun PoseRoute( viewModel: PoseViewModel = hiltViewModel(), - navigateToPoseDetail: (Pose) -> Unit, + navigateToPoseDetail: (Long) -> Unit, navigateToRandomPose: (PeopleCount) -> Unit, navigateToNotification: () -> Unit, ) { @@ -52,7 +52,7 @@ internal fun PoseRoute( when (sideEffect) { PoseEffect.NavigateToNotification -> navigateToNotification() is PoseEffect.NavigateToRandomPose -> navigateToRandomPose(sideEffect.peopleCount) - is PoseEffect.NavigateToPoseDetail -> navigateToPoseDetail(sideEffect.pose) + is PoseEffect.NavigateToPoseDetail -> navigateToPoseDetail(sideEffect.poseId) is PoseEffect.ShowToast -> Toast.makeText(context, sideEffect.message, Toast.LENGTH_SHORT).show() } } diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/PoseViewModel.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/PoseViewModel.kt index 5d8380390..125f18172 100644 --- a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/PoseViewModel.kt +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/PoseViewModel.kt @@ -78,7 +78,7 @@ internal class PoseViewModel @Inject constructor( } is PoseIntent.ClickPoseItem -> { - postSideEffect(PoseEffect.NavigateToPoseDetail(intent.item)) + postSideEffect(PoseEffect.NavigateToPoseDetail(intent.item.id)) } PoseIntent.ClickRandomPoseRecommendation -> reduce { copy(isShowRandomPosePeopleCountBottomSheet = true) } diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/navigation/PoseEntryProvider.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/navigation/PoseEntryProvider.kt index c6a966a8e..b8e79f8e8 100644 --- a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/navigation/PoseEntryProvider.kt +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/navigation/PoseEntryProvider.kt @@ -43,7 +43,7 @@ private fun EntryProviderScope.poseEntry(navigator: Navigator) { PoseDetailRoute( viewModel = hiltViewModel( creationCallback = { factory -> - factory.create(key.pose) + factory.create(key.poseId) }, ), navigateBack = navigator::goBack, From 40a158ad207ca7a80d743019dda7cd7f1b361b41 Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Fri, 30 Jan 2026 02:19:08 +0900 Subject: [PATCH 12/22] =?UTF-8?q?[feat]=20#70:=20=EB=9E=9C=EB=8D=A4=20?= =?UTF-8?q?=ED=8F=AC=EC=A6=88=20=EC=A2=8C=EC=9A=B0=20=EC=9D=B4=EB=8F=99=20?= =?UTF-8?q?=EB=B0=8F=20=ED=94=84=EB=A6=AC=ED=8C=A8=EC=B9=98=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 좌/우 클릭 시 이전/다음 포즈로 이동 - 다음 포즈 미리 로드하여 즉시 표시 - PoseDetail 네비게이션 poseId로 변경 - 스크랩 API 추가 --- .../core/dataapi/repository/PoseRepository.kt | 2 + .../core/data/remote/api/PoseService.kt | 11 ++ .../model/request/UpdateScrapRequest.kt | 9 ++ .../repository/impl/PoseRepositoryImpl.kt | 4 + .../pose/impl/random/RandomPoseContract.kt | 4 +- .../pose/impl/random/RandomPoseScreen.kt | 4 +- .../pose/impl/random/RandomPoseViewModel.kt | 116 +++++++++++++----- 7 files changed, 115 insertions(+), 35 deletions(-) create mode 100644 core/data/src/main/java/com/neki/android/core/data/remote/model/request/UpdateScrapRequest.kt diff --git a/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/PoseRepository.kt b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/PoseRepository.kt index 3e9d03e0e..0a5ed77a1 100644 --- a/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/PoseRepository.kt +++ b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/PoseRepository.kt @@ -22,4 +22,6 @@ interface PoseRepository { suspend fun getPose(poseId: Long): Result suspend fun getRandomPose(): Result + + suspend fun updateScrap(poseId: Long, scrap: Boolean): Result } diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/api/PoseService.kt b/core/data/src/main/java/com/neki/android/core/data/remote/api/PoseService.kt index f77c6b68f..a7573d062 100644 --- a/core/data/src/main/java/com/neki/android/core/data/remote/api/PoseService.kt +++ b/core/data/src/main/java/com/neki/android/core/data/remote/api/PoseService.kt @@ -1,5 +1,7 @@ package com.neki.android.core.data.remote.api +import com.neki.android.core.data.remote.model.request.UpdateScrapRequest +import com.neki.android.core.data.remote.model.response.BasicNullableResponse import com.neki.android.core.data.remote.model.response.BasicResponse import com.neki.android.core.data.remote.model.response.PoseItemResponse import com.neki.android.core.data.remote.model.response.PoseResponse @@ -7,6 +9,8 @@ import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.request.get import io.ktor.client.request.parameter +import io.ktor.client.request.patch +import io.ktor.client.request.setBody import javax.inject.Inject class PoseService @Inject constructor( @@ -36,4 +40,11 @@ class PoseService @Inject constructor( suspend fun getRandomPose(): BasicResponse { return client.get("/api/poses/random").body() } + + // 스크랩 업데이트 + suspend fun updateScrap(poseId: Long, scrap: Boolean): BasicNullableResponse { + return client.patch("/api/poses/$poseId/scrap") { + setBody(UpdateScrapRequest(scrap)) + }.body() + } } diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/request/UpdateScrapRequest.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/request/UpdateScrapRequest.kt new file mode 100644 index 000000000..6093883cf --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/request/UpdateScrapRequest.kt @@ -0,0 +1,9 @@ +package com.neki.android.core.data.remote.model.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class UpdateScrapRequest( + @SerialName("scrap") val scrap: Boolean, +) diff --git a/core/data/src/main/java/com/neki/android/core/data/repository/impl/PoseRepositoryImpl.kt b/core/data/src/main/java/com/neki/android/core/data/repository/impl/PoseRepositoryImpl.kt index 1f5b2e4ac..eb1867d8d 100644 --- a/core/data/src/main/java/com/neki/android/core/data/repository/impl/PoseRepositoryImpl.kt +++ b/core/data/src/main/java/com/neki/android/core/data/repository/impl/PoseRepositoryImpl.kt @@ -62,4 +62,8 @@ class PoseRepositoryImpl @Inject constructor( override suspend fun getRandomPose(): Result = runSuspendCatching { poseService.getRandomPose().data.toModel() } + + override suspend fun updateScrap(poseId: Long, scrap: Boolean): Result = runSuspendCatching { + poseService.updateScrap(poseId, scrap).data + } } diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseContract.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseContract.kt index 870feb885..d4ff3da77 100644 --- a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseContract.kt +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseContract.kt @@ -17,7 +17,7 @@ data class RandomPoseUiState( get() = currentIndex > 0 val hasNext: Boolean - get() = currentIndex < poseList.lastIndex + get() = poseList.isNotEmpty() // 랜덤 포즈는 항상 다음으로 이동 가능 } sealed interface RandomPoseIntent { @@ -38,6 +38,6 @@ sealed interface RandomPoseIntent { sealed interface RandomPoseEffect { data object NavigateBack : RandomPoseEffect - data class NavigateToDetail(val pose: Pose) : RandomPoseEffect + data class NavigateToDetail(val poseId: Long) : RandomPoseEffect data class ShowToast(val message: String) : RandomPoseEffect } diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseScreen.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseScreen.kt index 1f37cd77a..88e92165b 100644 --- a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseScreen.kt +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseScreen.kt @@ -31,14 +31,14 @@ import dev.chrisbanes.haze.rememberHazeState internal fun RandomPoseRoute( viewModel: RandomPoseViewModel = hiltViewModel(), navigateBack: () -> Unit, - navigateToPoseDetail: (Pose) -> Unit, + navigateToPoseDetail: (Long) -> Unit, ) { val uiState by viewModel.store.uiState.collectAsStateWithLifecycle() viewModel.store.sideEffects.collectWithLifecycle { sideEffect -> when (sideEffect) { RandomPoseEffect.NavigateBack -> navigateBack() - is RandomPoseEffect.NavigateToDetail -> navigateToPoseDetail(sideEffect.pose) + is RandomPoseEffect.NavigateToDetail -> navigateToPoseDetail(sideEffect.poseId) is RandomPoseEffect.ShowToast -> Unit // TODO: Toast 처리 } } diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseViewModel.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseViewModel.kt index 6d0db24c5..fdc181b14 100644 --- a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseViewModel.kt +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseViewModel.kt @@ -43,49 +43,119 @@ internal class RandomPoseViewModel @AssistedInject constructor( RandomPoseIntent.EnterRandomPoseScreen -> fetchInitialPoses(reduce, postSideEffect) // 튜토리얼 - RandomPoseIntent.ClickLeftSwipe -> Unit - RandomPoseIntent.ClickRightSwipe -> Unit + RandomPoseIntent.ClickLeftSwipe -> handleMovePrevious(state, reduce) + RandomPoseIntent.ClickRightSwipe -> handleMoveNext(state, reduce) RandomPoseIntent.ClickStartRandomPose -> reduce { copy(isShowTutorial = false) } // 기본화면 RandomPoseIntent.ClickCloseIcon -> postSideEffect(RandomPoseEffect.NavigateBack) RandomPoseIntent.ClickGoToDetailIcon -> { state.currentPose?.let { pose -> - postSideEffect(RandomPoseEffect.NavigateToDetail(pose)) + postSideEffect(RandomPoseEffect.NavigateToDetail(pose.id)) } } RandomPoseIntent.ClickScrapIcon -> { state.currentPose?.let { currentPose -> + val newScrapState = !currentPose.isScrapped + + // Optimistic UI 업데이트 reduce { copy( poseList = poseList.map { pose -> if (pose.id == currentPose.id) { - pose.copy(isScrapped = !pose.isScrapped) + pose.copy(isScrapped = newScrapState) } else { pose } }.toImmutableList(), ) } + + // API 호출 + viewModelScope.launch { + poseRepository.updateScrap(currentPose.id, newScrapState) + .onFailure { error -> + Timber.e(error) + // 실패 시 롤백 + reduce { + copy( + poseList = poseList.map { pose -> + if (pose.id == currentPose.id) { + pose.copy(isScrapped = !newScrapState) + } else { + pose + } + }.toImmutableList(), + ) + } + } + } } } - RandomPoseIntent.SwipeLeft -> { - if (state.hasPrevious) { - reduce { copy(currentIndex = currentIndex - 1) } - } + RandomPoseIntent.SwipeLeft -> handleMovePrevious(state, reduce) + RandomPoseIntent.SwipeRight -> handleMoveNext(state, reduce) + } + } + + private fun handleMovePrevious( + state: RandomPoseUiState, + reduce: (RandomPoseUiState.() -> RandomPoseUiState) -> Unit, + ) { + if (state.hasPrevious) { + reduce { copy(currentIndex = currentIndex - 1) } + } + } + + private fun handleMoveNext( + state: RandomPoseUiState, + reduce: (RandomPoseUiState.() -> RandomPoseUiState) -> Unit, + ) { + if (!state.hasNext) return + + val isAtLastItem = state.currentIndex >= state.poseList.lastIndex + + if (isAtLastItem) { + // 마지막 아이템이면 다음 포즈 로드 후 이동 + viewModelScope.launch { + poseRepository.getRandomPose() + .onSuccess { pose -> + reduce { + copy( + poseList = (poseList + pose).toImmutableList(), + currentIndex = currentIndex + 1, + ) + } + } + .onFailure { error -> + Timber.e(error) + } + } + } else { + // 아직 다음 아이템이 있으면 바로 이동 + reduce { copy(currentIndex = currentIndex + 1) } + + // 다음 아이템이 마지막이면 미리 로드 + if (state.currentIndex + 1 >= state.poseList.lastIndex) { + prefetchNextPose(reduce) } + } + } - RandomPoseIntent.SwipeRight -> { - if (state.hasNext) { - reduce { copy(currentIndex = currentIndex + 1) } - // 마지막 포즈에 도달하면 다음 포즈 미리 로드 - if (state.currentIndex + 1 >= state.poseList.lastIndex) { - fetchNextPose(reduce, postSideEffect) + private fun prefetchNextPose( + reduce: (RandomPoseUiState.() -> RandomPoseUiState) -> Unit, + ) { + viewModelScope.launch { + poseRepository.getRandomPose() + .onSuccess { pose -> + reduce { + copy(poseList = (poseList + pose).toImmutableList()) } } - } + .onFailure { error -> + Timber.e(error) + } } } @@ -120,20 +190,4 @@ internal class RandomPoseViewModel @AssistedInject constructor( } } - private fun fetchNextPose( - reduce: (RandomPoseUiState.() -> RandomPoseUiState) -> Unit, - postSideEffect: (RandomPoseEffect) -> Unit, - ) { - viewModelScope.launch { - poseRepository.getRandomPose() - .onSuccess { pose -> - reduce { - copy(poseList = (poseList + pose).toImmutableList()) - } - } - .onFailure { error -> - Timber.e(error) - } - } - } } From 523ebf2e47e4d881b232f4274496ce8d8e2456f2 Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Fri, 30 Jan 2026 02:42:08 +0900 Subject: [PATCH 13/22] =?UTF-8?q?[feat]=20#70:=20=ED=8F=AC=EC=A6=88=20?= =?UTF-8?q?=EC=83=81=EC=84=B8/=EB=9E=9C=EB=8D=A4=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=EC=97=90=20scrap=20=ED=95=84=EB=93=9C=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/data/remote/api/PoseService.kt | 6 ++-- .../model/response/PoseDetailResponse.kt | 23 +++++++++++++ .../remote/model/response/PoseResponse.kt | 32 +++++++++---------- 3 files changed, 42 insertions(+), 19 deletions(-) create mode 100644 core/data/src/main/java/com/neki/android/core/data/remote/model/response/PoseDetailResponse.kt diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/api/PoseService.kt b/core/data/src/main/java/com/neki/android/core/data/remote/api/PoseService.kt index a7573d062..575a90468 100644 --- a/core/data/src/main/java/com/neki/android/core/data/remote/api/PoseService.kt +++ b/core/data/src/main/java/com/neki/android/core/data/remote/api/PoseService.kt @@ -3,7 +3,7 @@ package com.neki.android.core.data.remote.api import com.neki.android.core.data.remote.model.request.UpdateScrapRequest import com.neki.android.core.data.remote.model.response.BasicNullableResponse import com.neki.android.core.data.remote.model.response.BasicResponse -import com.neki.android.core.data.remote.model.response.PoseItemResponse +import com.neki.android.core.data.remote.model.response.PoseDetailResponse import com.neki.android.core.data.remote.model.response.PoseResponse import io.ktor.client.HttpClient import io.ktor.client.call.body @@ -32,12 +32,12 @@ class PoseService @Inject constructor( } // 포즈 상세 조회 - suspend fun getPose(poseId: Long): BasicResponse { + suspend fun getPose(poseId: Long): BasicResponse { return client.get("/api/poses/$poseId").body() } // 랜덤 포즈 조회 - suspend fun getRandomPose(): BasicResponse { + suspend fun getRandomPose(): BasicResponse { return client.get("/api/poses/random").body() } diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/response/PoseDetailResponse.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/PoseDetailResponse.kt new file mode 100644 index 000000000..2cde355d0 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/PoseDetailResponse.kt @@ -0,0 +1,23 @@ +package com.neki.android.core.data.remote.model.response + +import com.neki.android.core.model.PeopleCount +import com.neki.android.core.model.Pose +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class PoseDetailResponse( + @SerialName("poseId") val poseId: Long, + @SerialName("headCount") val headCount: String, + @SerialName("imageUrl") val imageUrl: String, + @SerialName("scrap") val scrap: Boolean, + @SerialName("contentType") val contentType: String, + @SerialName("createdAt") val createdAt: String, +) { + internal fun toModel() = Pose( + id = poseId, + isScrapped = scrap, + poseImageUrl = imageUrl, + peopleCount = PeopleCount.entries.find { it.name == headCount }?.value ?: 1, + ) +} diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/model/response/PoseResponse.kt b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/PoseResponse.kt index 9110aa952..a105d9b5b 100644 --- a/core/data/src/main/java/com/neki/android/core/data/remote/model/response/PoseResponse.kt +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/PoseResponse.kt @@ -8,22 +8,22 @@ import kotlinx.serialization.Serializable @Serializable data class PoseResponse( @SerialName("hasNext") val hasNext: Boolean, - @SerialName("items") val items: List, + @SerialName("items") val items: List, ) { - fun toModels() = items.map { it.toModel() } -} + @Serializable + data class Item( + @SerialName("poseId") val poseId: Long, + @SerialName("headCount") val headCount: String, + @SerialName("imageUrl") val imageUrl: String, + @SerialName("contentType") val contentType: String, + @SerialName("createdAt") val createdAt: String, + ) { + internal fun toModel() = Pose( + id = poseId, + poseImageUrl = imageUrl, + peopleCount = PeopleCount.entries.find { it.name == headCount }?.value ?: 1, + ) + } -@Serializable -data class PoseItemResponse( - @SerialName("poseId") val poseId: Long, - @SerialName("headCount") val headCount: String, - @SerialName("imageUrl") val imageUrl: String, - @SerialName("contentType") val contentType: String, - @SerialName("createdAt") val createdAt: String, -) { - internal fun toModel() = Pose( - id = poseId, - poseImageUrl = imageUrl, - peopleCount = PeopleCount.entries.find { it.name == headCount }?.value ?: 1, - ) + fun toModels() = items.map { it.toModel() } } From 6855722c9bc82688a2070a3c63b5616b279c49ed Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Fri, 30 Jan 2026 02:56:17 +0900 Subject: [PATCH 14/22] =?UTF-8?q?[feat]=20#70:=20=ED=8F=AC=EC=A6=88=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=ED=99=94=EB=A9=B4=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=9E=A9=20API=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - debounce, applicationScope, onCleared 패턴 적용 - 스크랩 토글 시 Optimistic UI 업데이트 및 실패 시 롤백 --- .../pose/impl/detail/PoseDetailContract.kt | 3 + .../pose/impl/detail/PoseDetailViewModel.kt | 64 ++++++++++++++++--- 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/detail/PoseDetailContract.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/detail/PoseDetailContract.kt index 245ef514f..edcc304dd 100644 --- a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/detail/PoseDetailContract.kt +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/detail/PoseDetailContract.kt @@ -5,12 +5,15 @@ import com.neki.android.core.model.Pose data class PoseDetailState( val isLoading: Boolean = false, val pose: Pose = Pose(), + val committedScrap: Boolean = false, ) sealed interface PoseDetailIntent { data object EnterPoseDetailScreen : PoseDetailIntent data object ClickBackIcon : PoseDetailIntent data object ClickScrapIcon : PoseDetailIntent + data class ScrapCommitted(val newScrap: Boolean) : PoseDetailIntent + data class RevertScrap(val originalScrap: Boolean) : PoseDetailIntent } sealed interface PoseDetailSideEffect { diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/detail/PoseDetailViewModel.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/detail/PoseDetailViewModel.kt index d818b20be..c044b94e6 100644 --- a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/detail/PoseDetailViewModel.kt +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/detail/PoseDetailViewModel.kt @@ -2,23 +2,31 @@ package com.neki.android.feature.pose.impl.detail import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.neki.android.core.common.coroutine.di.ApplicationScope import com.neki.android.core.dataapi.repository.PoseRepository -import com.neki.android.core.model.Pose import com.neki.android.core.ui.MviIntentStore import com.neki.android.core.ui.mviIntentStore import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.launch import timber.log.Timber +@OptIn(FlowPreview::class) @HiltViewModel(assistedFactory = PoseDetailViewModel.Factory::class) class PoseDetailViewModel @AssistedInject constructor( @Assisted private val id: Long, private val poseRepository: PoseRepository, + @ApplicationScope private val applicationScope: CoroutineScope, ) : ViewModel() { + private val scrapRequests = MutableSharedFlow(extraBufferCapacity = 64) + @AssistedFactory interface Factory { fun create(id: Long): PoseDetailViewModel @@ -30,6 +38,27 @@ class PoseDetailViewModel @AssistedInject constructor( onIntent = ::onIntent, ) + init { + viewModelScope.launch { + scrapRequests + .debounce(500) + .collect { newScrap -> + val committedScrap = store.uiState.value.committedScrap + if (committedScrap != newScrap) { + poseRepository.updateScrap(id, newScrap) + .onSuccess { + Timber.d("updateScrap success") + store.onIntent(PoseDetailIntent.ScrapCommitted(newScrap)) + } + .onFailure { error -> + Timber.e(error, "updateScrap failed") + store.onIntent(PoseDetailIntent.RevertScrap(committedScrap)) + } + } + } + } + } + private fun onIntent( intent: PoseDetailIntent, state: PoseDetailState, @@ -39,24 +68,43 @@ class PoseDetailViewModel @AssistedInject constructor( when (intent) { PoseDetailIntent.EnterPoseDetailScreen -> fetchPoseData(reduce) PoseDetailIntent.ClickBackIcon -> postSideEffect(PoseDetailSideEffect.NavigateBack) - PoseDetailIntent.ClickScrapIcon -> { - // TODO: API 연동 시 실제 스크랩 토글 구현 - reduce { - copy(pose = state.pose.copy(isScrapped = !state.pose.isScrapped)) - } - } + PoseDetailIntent.ClickScrapIcon -> handleScrapToggle(state, reduce) + is PoseDetailIntent.ScrapCommitted -> reduce { copy(committedScrap = intent.newScrap) } + is PoseDetailIntent.RevertScrap -> reduce { copy(pose = pose.copy(isScrapped = intent.originalScrap)) } } } + private fun handleScrapToggle( + state: PoseDetailState, + reduce: (PoseDetailState.() -> PoseDetailState) -> Unit, + ) { + val newScrapStatus = !state.pose.isScrapped + viewModelScope.launch { scrapRequests.emit(newScrapStatus) } + reduce { copy(pose = pose.copy(isScrapped = newScrapStatus)) } + } + private fun fetchPoseData(reduce: (PoseDetailState.() -> PoseDetailState) -> Unit) { viewModelScope.launch { poseRepository.getPose(poseId = id) .onSuccess { data -> - reduce { copy(pose = data) } + reduce { copy(pose = data, committedScrap = data.isScrapped) } } .onFailure { error -> Timber.e(error) } } } + + override fun onCleared() { + super.onCleared() + + val currentScrap = store.uiState.value.pose.isScrapped + val committedScrap = store.uiState.value.committedScrap + + if (currentScrap != committedScrap) { + applicationScope.launch { + poseRepository.updateScrap(id, currentScrap) + } + } + } } From cd699b1ed398942ef44272590f40cc8518833d2d Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Fri, 30 Jan 2026 02:56:27 +0900 Subject: [PATCH 15/22] =?UTF-8?q?[feat]=20#70:=20=EB=9E=9C=EB=8D=A4=20?= =?UTF-8?q?=ED=8F=AC=EC=A6=88=20=EC=8A=A4=ED=81=AC=EB=9E=A9=20API=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20=EB=B0=8F=20UI=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 포즈별 독립적인 debounce Job으로 스크랩 API 호출 - applicationScope, onCleared 패턴 적용 - 좌우 탭 영역 클릭으로 포즈 이동 구현 - 다음 포즈 미리 캐싱 로직 개선 - Toast 처리 연동 --- .../android/feature/pose/api/PoseNavKey.kt | 1 - .../pose/impl/random/RandomPoseContract.kt | 11 +- .../pose/impl/random/RandomPoseScreen.kt | 59 ++++++-- .../pose/impl/random/RandomPoseViewModel.kt | 142 +++++++++++------- 4 files changed, 138 insertions(+), 75 deletions(-) diff --git a/feature/pose/api/src/main/java/com/neki/android/feature/pose/api/PoseNavKey.kt b/feature/pose/api/src/main/java/com/neki/android/feature/pose/api/PoseNavKey.kt index f40d4cdd9..15933b8a6 100644 --- a/feature/pose/api/src/main/java/com/neki/android/feature/pose/api/PoseNavKey.kt +++ b/feature/pose/api/src/main/java/com/neki/android/feature/pose/api/PoseNavKey.kt @@ -2,7 +2,6 @@ package com.neki.android.feature.pose.api import androidx.navigation3.runtime.NavKey import com.neki.android.core.model.PeopleCount -import com.neki.android.core.model.Pose import com.neki.android.core.navigation.Navigator import kotlinx.serialization.Serializable diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseContract.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseContract.kt index d4ff3da77..e160ba27a 100644 --- a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseContract.kt +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseContract.kt @@ -9,35 +9,32 @@ data class RandomPoseUiState( val isShowTutorial: Boolean = true, val currentIndex: Int = 0, val poseList: ImmutableList = persistentListOf(), + val committedScraps: Map = emptyMap(), ) { val currentPose: Pose? get() = poseList.getOrNull(currentIndex) val hasPrevious: Boolean get() = currentIndex > 0 - - val hasNext: Boolean - get() = poseList.isNotEmpty() // 랜덤 포즈는 항상 다음으로 이동 가능 } sealed interface RandomPoseIntent { data object EnterRandomPoseScreen : RandomPoseIntent // 튜토리얼 - data object ClickLeftSwipe : RandomPoseIntent - data object ClickRightSwipe : RandomPoseIntent data object ClickStartRandomPose : RandomPoseIntent // 기본화면 data object ClickCloseIcon : RandomPoseIntent data object ClickGoToDetailIcon : RandomPoseIntent data object ClickScrapIcon : RandomPoseIntent - data object SwipeLeft : RandomPoseIntent - data object SwipeRight : RandomPoseIntent + data object ClickLeftSwipe : RandomPoseIntent + data object ClickRightSwipe : RandomPoseIntent } sealed interface RandomPoseEffect { data object NavigateBack : RandomPoseEffect data class NavigateToDetail(val poseId: Long) : RandomPoseEffect data class ShowToast(val message: String) : RandomPoseEffect + data class RequestImageBuilder(val imageUrl: String) : RandomPoseEffect } diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseScreen.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseScreen.kt index 88e92165b..9ed345fa0 100644 --- a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseScreen.kt +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseScreen.kt @@ -3,25 +3,33 @@ package com.neki.android.feature.pose.impl.random import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.ImageLoader import coil3.compose.AsyncImage +import coil3.request.ImageRequest import com.neki.android.core.designsystem.DevicePreview +import com.neki.android.core.designsystem.modifier.noRippleClickableSingle import com.neki.android.core.designsystem.topbar.CloseTitleTopBar import com.neki.android.core.designsystem.ui.theme.NekiTheme import com.neki.android.core.model.Pose import com.neki.android.core.ui.compose.VerticalSpacer import com.neki.android.core.ui.compose.collectWithLifecycle +import com.neki.android.core.ui.toast.NekiToast import com.neki.android.feature.pose.impl.random.component.RandomPoseFloatingBarContent import com.neki.android.feature.pose.impl.random.component.RandomPoseTutorialOverlay import dev.chrisbanes.haze.hazeSource @@ -34,12 +42,21 @@ internal fun RandomPoseRoute( navigateToPoseDetail: (Long) -> Unit, ) { val uiState by viewModel.store.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + val nekiToast = remember { NekiToast(context) } + val imageLoader = ImageLoader(context) viewModel.store.sideEffects.collectWithLifecycle { sideEffect -> when (sideEffect) { RandomPoseEffect.NavigateBack -> navigateBack() is RandomPoseEffect.NavigateToDetail -> navigateToPoseDetail(sideEffect.poseId) - is RandomPoseEffect.ShowToast -> Unit // TODO: Toast 처리 + is RandomPoseEffect.ShowToast -> nekiToast.showToast(sideEffect.message) + is RandomPoseEffect.RequestImageBuilder -> { + val request = ImageRequest.Builder(context) + .data(sideEffect.imageUrl) + .build() + imageLoader.execute(request) + } } } @@ -77,6 +94,8 @@ internal fun RandomPoseScreen( .fillMaxWidth() .weight(1f), pose = pose, + onLeftSwipe = { onIntent(RandomPoseIntent.ClickLeftSwipe) }, + onRightSwipe = { onIntent(RandomPoseIntent.ClickRightSwipe) }, ) RandomPoseFloatingBarContent( modifier = Modifier.fillMaxWidth(), @@ -101,16 +120,38 @@ internal fun RandomPoseScreen( private fun RandomPoseImage( pose: Pose, modifier: Modifier = Modifier, + onLeftSwipe: () -> Unit = {}, + onRightSwipe: () -> Unit = {}, ) { - AsyncImage( - model = pose.poseImageUrl, - contentDescription = null, + Box( modifier = modifier - .padding(horizontal = 10.dp) - .fillMaxWidth() - .clip(RoundedCornerShape(20.dp)), - contentScale = ContentScale.FillWidth, - ) + .padding(horizontal = 10.dp), + ) { + AsyncImage( + model = pose.poseImageUrl, + contentDescription = null, + modifier = modifier + .matchParentSize() + .clip(RoundedCornerShape(20.dp)), + contentScale = ContentScale.FillWidth, + ) + Row( + modifier = Modifier.matchParentSize(), + ) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .noRippleClickableSingle(onClick = onLeftSwipe), + ) + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .noRippleClickableSingle(onClick = onRightSwipe), + ) + } + } } @DevicePreview diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseViewModel.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseViewModel.kt index fdc181b14..93ad118cd 100644 --- a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseViewModel.kt +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseViewModel.kt @@ -2,6 +2,7 @@ package com.neki.android.feature.pose.impl.random import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.neki.android.core.common.coroutine.di.ApplicationScope import com.neki.android.core.dataapi.repository.PoseRepository import com.neki.android.core.model.PeopleCount import com.neki.android.core.model.Pose @@ -12,6 +13,9 @@ import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import timber.log.Timber @@ -19,8 +23,11 @@ import timber.log.Timber internal class RandomPoseViewModel @AssistedInject constructor( @Suppress("UnusedPrivateProperty") @Assisted private val peopleCount: PeopleCount, private val poseRepository: PoseRepository, + @ApplicationScope private val applicationScope: CoroutineScope, ) : ViewModel() { + private val scrapJobs = mutableMapOf() + @AssistedFactory interface Factory { fun create(peopleCount: PeopleCount): RandomPoseViewModel @@ -43,8 +50,8 @@ internal class RandomPoseViewModel @AssistedInject constructor( RandomPoseIntent.EnterRandomPoseScreen -> fetchInitialPoses(reduce, postSideEffect) // 튜토리얼 - RandomPoseIntent.ClickLeftSwipe -> handleMovePrevious(state, reduce) - RandomPoseIntent.ClickRightSwipe -> handleMoveNext(state, reduce) + RandomPoseIntent.ClickLeftSwipe -> handleMovePrevious(state, reduce, postSideEffect) + RandomPoseIntent.ClickRightSwipe -> handleMoveNext(state, reduce, postSideEffect) RandomPoseIntent.ClickStartRandomPose -> reduce { copy(isShowTutorial = false) } // 기본화면 @@ -55,34 +62,52 @@ internal class RandomPoseViewModel @AssistedInject constructor( } } - RandomPoseIntent.ClickScrapIcon -> { - state.currentPose?.let { currentPose -> - val newScrapState = !currentPose.isScrapped + RandomPoseIntent.ClickScrapIcon -> handleScrapToggle(state, reduce) + } + } - // Optimistic UI 업데이트 - reduce { - copy( - poseList = poseList.map { pose -> - if (pose.id == currentPose.id) { - pose.copy(isScrapped = newScrapState) - } else { - pose - } - }.toImmutableList(), - ) - } + private fun handleScrapToggle( + state: RandomPoseUiState, + reduce: (RandomPoseUiState.() -> RandomPoseUiState) -> Unit, + ) { + state.currentPose?.let { currentPose -> + val poseId = currentPose.id + val newScrapStatus = !currentPose.isScrapped + + // UI 즉시 업데이트 + reduce { + copy( + poseList = poseList.map { pose -> + if (pose.id == poseId) { + pose.copy(isScrapped = newScrapStatus) + } else { + pose + } + }.toImmutableList(), + ) + } - // API 호출 - viewModelScope.launch { - poseRepository.updateScrap(currentPose.id, newScrapState) - .onFailure { error -> - Timber.e(error) - // 실패 시 롤백 + // 해당 포즈의 이전 Job 취소 후 새로운 Job 시작 + scrapJobs[poseId]?.cancel() + scrapJobs[poseId] = viewModelScope.launch { + delay(500) + val committedScrap = store.uiState.value.committedScraps[poseId] + if (committedScrap != newScrapStatus) { + poseRepository.updateScrap(poseId, newScrapStatus) + .onSuccess { + Timber.d("updateScrap success for poseId: $poseId") + reduce { + copy(committedScraps = committedScraps + (poseId to newScrapStatus)) + } + } + .onFailure { error -> + Timber.e(error, "updateScrap failed for poseId: $poseId") + committedScrap?.let { originalScrap -> reduce { copy( poseList = poseList.map { pose -> - if (pose.id == currentPose.id) { - pose.copy(isScrapped = !newScrapState) + if (pose.id == poseId) { + pose.copy(isScrapped = originalScrap) } else { pose } @@ -90,68 +115,53 @@ internal class RandomPoseViewModel @AssistedInject constructor( ) } } - } + } } } - - RandomPoseIntent.SwipeLeft -> handleMovePrevious(state, reduce) - RandomPoseIntent.SwipeRight -> handleMoveNext(state, reduce) } } private fun handleMovePrevious( state: RandomPoseUiState, reduce: (RandomPoseUiState.() -> RandomPoseUiState) -> Unit, + postSideEffect: (RandomPoseEffect) -> Unit, ) { if (state.hasPrevious) { reduce { copy(currentIndex = currentIndex - 1) } + }else { + postSideEffect(RandomPoseEffect.ShowToast("첫번째 포즈입니다.")) } } private fun handleMoveNext( state: RandomPoseUiState, reduce: (RandomPoseUiState.() -> RandomPoseUiState) -> Unit, + postSideEffect: (RandomPoseEffect) -> Unit, ) { - if (!state.hasNext) return - - val isAtLastItem = state.currentIndex >= state.poseList.lastIndex + // 뒤에서 2번째라면은 데이터를 가져오기 + val shouldFetchNextPost = state.currentIndex == state.poseList.lastIndex - 1 - if (isAtLastItem) { - // 마지막 아이템이면 다음 포즈 로드 후 이동 - viewModelScope.launch { - poseRepository.getRandomPose() - .onSuccess { pose -> - reduce { - copy( - poseList = (poseList + pose).toImmutableList(), - currentIndex = currentIndex + 1, - ) - } - } - .onFailure { error -> - Timber.e(error) - } - } + if (shouldFetchNextPost) { + fetchNextPose(reduce, postSideEffect) } else { - // 아직 다음 아이템이 있으면 바로 이동 reduce { copy(currentIndex = currentIndex + 1) } - - // 다음 아이템이 마지막이면 미리 로드 - if (state.currentIndex + 1 >= state.poseList.lastIndex) { - prefetchNextPose(reduce) - } } } - private fun prefetchNextPose( + private fun fetchNextPose( reduce: (RandomPoseUiState.() -> RandomPoseUiState) -> Unit, + postSideEffect: (RandomPoseEffect) -> Unit, ) { viewModelScope.launch { poseRepository.getRandomPose() .onSuccess { pose -> reduce { - copy(poseList = (poseList + pose).toImmutableList()) + copy( + poseList = (poseList + pose).toImmutableList(), + committedScraps = committedScraps + (pose.id to pose.isScrapped), + ) } + postSideEffect(RandomPoseEffect.RequestImageBuilder(pose.poseImageUrl)) } .onFailure { error -> Timber.e(error) @@ -168,8 +178,8 @@ internal class RandomPoseViewModel @AssistedInject constructor( val poses = mutableListOf() - // 초기에 2개 로드 - repeat(2) { + // 초기에 3개 로드 + repeat(3) { poseRepository.getRandomPose() .onSuccess { pose -> poses.add(pose) } .onFailure { error -> Timber.e(error) } @@ -180,6 +190,7 @@ internal class RandomPoseViewModel @AssistedInject constructor( copy( isLoading = false, poseList = poses.toImmutableList(), + committedScraps = poses.associate { it.id to it.isScrapped }, currentIndex = 0, ) } @@ -190,4 +201,19 @@ internal class RandomPoseViewModel @AssistedInject constructor( } } + override fun onCleared() { + super.onCleared() + + val state = store.uiState.value + state.poseList.forEach { pose -> + val currentScrap = pose.isScrapped + val committedScrap = state.committedScraps[pose.id] + + if (committedScrap != null && currentScrap != committedScrap) { + applicationScope.launch { + poseRepository.updateScrap(pose.id, currentScrap) + } + } + } + } } From f8aa7967ce02879f602bc037de0a6895c542e0d7 Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Fri, 30 Jan 2026 03:11:43 +0900 Subject: [PATCH 16/22] =?UTF-8?q?[fix]=20#70:=20detekt=20SpacingAroundCurl?= =?UTF-8?q?y=20=EB=A6=B0=ED=8A=B8=20=EC=98=A4=EB=A5=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android/feature/pose/impl/random/RandomPoseViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseViewModel.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseViewModel.kt index 93ad118cd..e4a7364fb 100644 --- a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseViewModel.kt +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseViewModel.kt @@ -128,7 +128,7 @@ internal class RandomPoseViewModel @AssistedInject constructor( ) { if (state.hasPrevious) { reduce { copy(currentIndex = currentIndex - 1) } - }else { + } else { postSideEffect(RandomPoseEffect.ShowToast("첫번째 포즈입니다.")) } } From 176e22429962df94fc4aa711d08ecddb3d2ff738 Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Fri, 30 Jan 2026 03:12:50 +0900 Subject: [PATCH 17/22] =?UTF-8?q?[refactor]=20#70:=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20`getPoses`=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paging3 라이브러리를 사용한 `getPosesFlow` 함수만 사용되므로, 더 이상 필요 없는 `getPoses` suspend 함수를 `PoseRepository` 인터페이스와 `PoseRepositoryImpl` 구현체에서 제거합니다. --- .../core/dataapi/repository/PoseRepository.kt | 6 ------ .../data/repository/impl/PoseRepositoryImpl.kt | 14 -------------- 2 files changed, 20 deletions(-) diff --git a/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/PoseRepository.kt b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/PoseRepository.kt index 0a5ed77a1..7a6a6725a 100644 --- a/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/PoseRepository.kt +++ b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/PoseRepository.kt @@ -7,12 +7,6 @@ import com.neki.android.core.model.SortOrder import kotlinx.coroutines.flow.Flow interface PoseRepository { - suspend fun getPoses( - page: Int = 0, - size: Int = 20, - headCount: PeopleCount? = null, - sortOrder: SortOrder = SortOrder.DESC, - ): Result> fun getPosesFlow( headCount: PeopleCount? = null, diff --git a/core/data/src/main/java/com/neki/android/core/data/repository/impl/PoseRepositoryImpl.kt b/core/data/src/main/java/com/neki/android/core/data/repository/impl/PoseRepositoryImpl.kt index eb1867d8d..d7cff5f39 100644 --- a/core/data/src/main/java/com/neki/android/core/data/repository/impl/PoseRepositoryImpl.kt +++ b/core/data/src/main/java/com/neki/android/core/data/repository/impl/PoseRepositoryImpl.kt @@ -20,20 +20,6 @@ class PoseRepositoryImpl @Inject constructor( private val poseService: PoseService, ) : PoseRepository { - override suspend fun getPoses( - page: Int, - size: Int, - headCount: PeopleCount?, - sortOrder: SortOrder, - ): Result> = runSuspendCatching { - poseService.getPoses( - page = page, - size = size, - headCount = headCount?.name, - sortOrder = sortOrder.name, - ).data.toModels() - } - override fun getPosesFlow( headCount: PeopleCount?, sortOrder: SortOrder, From 95deaa9885faf38943b173cfde54725f47a81890 Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Fri, 30 Jan 2026 03:20:09 +0900 Subject: [PATCH 18/22] =?UTF-8?q?[fix]=20#70:=20updateScrap=20=EB=B0=98?= =?UTF-8?q?=ED=99=98=EA=B0=92=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=9E=A9=20=EC=A1=B0=EA=B1=B4=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android/core/data/repository/impl/PoseRepositoryImpl.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/data/src/main/java/com/neki/android/core/data/repository/impl/PoseRepositoryImpl.kt b/core/data/src/main/java/com/neki/android/core/data/repository/impl/PoseRepositoryImpl.kt index d7cff5f39..028369e01 100644 --- a/core/data/src/main/java/com/neki/android/core/data/repository/impl/PoseRepositoryImpl.kt +++ b/core/data/src/main/java/com/neki/android/core/data/repository/impl/PoseRepositoryImpl.kt @@ -50,6 +50,6 @@ class PoseRepositoryImpl @Inject constructor( } override suspend fun updateScrap(poseId: Long, scrap: Boolean): Result = runSuspendCatching { - poseService.updateScrap(poseId, scrap).data + poseService.updateScrap(poseId, scrap) } } From a35534d6864d96cb5b6614a401753cebe0f8feb3 Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Fri, 30 Jan 2026 03:20:12 +0900 Subject: [PATCH 19/22] =?UTF-8?q?[fix]=20#70:=20handleMoveNext=20=EC=9D=B8?= =?UTF-8?q?=EB=8D=B1=EC=8A=A4=20=EC=9D=B4=EB=8F=99=20=EB=B0=8F=20=ED=94=84?= =?UTF-8?q?=EB=A6=AC=ED=8C=A8=EC=B9=98=20=EB=A1=9C=EC=A7=81=20=EB=B2=84?= =?UTF-8?q?=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pose/impl/random/RandomPoseViewModel.kt | 54 +++++++++---------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseViewModel.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseViewModel.kt index e4a7364fb..71db8e80e 100644 --- a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseViewModel.kt +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseViewModel.kt @@ -92,31 +92,31 @@ internal class RandomPoseViewModel @AssistedInject constructor( scrapJobs[poseId] = viewModelScope.launch { delay(500) val committedScrap = store.uiState.value.committedScraps[poseId] - if (committedScrap != newScrapStatus) { - poseRepository.updateScrap(poseId, newScrapStatus) - .onSuccess { - Timber.d("updateScrap success for poseId: $poseId") - reduce { - copy(committedScraps = committedScraps + (poseId to newScrapStatus)) - } + if (committedScrap != newScrapStatus) return@launch + + poseRepository.updateScrap(poseId, newScrapStatus) + .onSuccess { + Timber.d("updateScrap success for poseId: $poseId") + reduce { + copy(committedScraps = committedScraps + (poseId to newScrapStatus)) } - .onFailure { error -> - Timber.e(error, "updateScrap failed for poseId: $poseId") - committedScrap?.let { originalScrap -> - reduce { - copy( - poseList = poseList.map { pose -> - if (pose.id == poseId) { - pose.copy(isScrapped = originalScrap) - } else { - pose - } - }.toImmutableList(), - ) - } + } + .onFailure { error -> + Timber.e(error, "updateScrap failed for poseId: $poseId") + committedScrap?.let { originalScrap -> + reduce { + copy( + poseList = poseList.map { pose -> + if (pose.id == poseId) { + pose.copy(isScrapped = originalScrap) + } else { + pose + } + }.toImmutableList(), + ) } } - } + } } } } @@ -138,13 +138,13 @@ internal class RandomPoseViewModel @AssistedInject constructor( reduce: (RandomPoseUiState.() -> RandomPoseUiState) -> Unit, postSideEffect: (RandomPoseEffect) -> Unit, ) { - // 뒤에서 2번째라면은 데이터를 가져오기 - val shouldFetchNextPost = state.currentIndex == state.poseList.lastIndex - 1 + if (state.currentIndex >= state.poseList.lastIndex) return - if (shouldFetchNextPost) { + reduce { copy(currentIndex = currentIndex + 1) } + + // 뒤에서 2번째였으면 다음 포즈 미리 캐싱 + if (state.currentIndex == state.poseList.lastIndex - 1) { fetchNextPose(reduce, postSideEffect) - } else { - reduce { copy(currentIndex = currentIndex + 1) } } } From e94b2ea230bcaace41654caa81aac6d1cf094ad6 Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Fri, 30 Jan 2026 03:20:16 +0900 Subject: [PATCH 20/22] =?UTF-8?q?[fix]=20#70:=20RandomPoseScreen=20ImageLo?= =?UTF-8?q?ader=20remember=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../neki/android/feature/pose/impl/random/RandomPoseScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseScreen.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseScreen.kt index 9ed345fa0..fbb77ccbe 100644 --- a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseScreen.kt +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseScreen.kt @@ -44,7 +44,7 @@ internal fun RandomPoseRoute( val uiState by viewModel.store.uiState.collectAsStateWithLifecycle() val context = LocalContext.current val nekiToast = remember { NekiToast(context) } - val imageLoader = ImageLoader(context) + val imageLoader = remember { ImageLoader(context) } viewModel.store.sideEffects.collectWithLifecycle { sideEffect -> when (sideEffect) { From d7a27e320bd8e54f941b22ed84f57282bcdf55f9 Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Sat, 31 Jan 2026 12:54:31 +0900 Subject: [PATCH 21/22] =?UTF-8?q?[feat]=20#70=EB=9E=9C=EB=8D=A4=20?= =?UTF-8?q?=ED=8F=AC=EC=A6=88=20=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20=EC=9D=B8?= =?UTF-8?q?=EC=9B=90=20=EC=88=98=20=ED=95=84=ED=84=B0=EB=A7=81=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 랜덤 포즈를 조회하는 `getRandomPose` API 호출 시 `headCount` 파라미터를 추가하여, 선택된 인원 수에 맞는 포즈만 가져오도록 수정합니다. --- .../neki/android/core/dataapi/repository/PoseRepository.kt | 2 +- .../com/neki/android/core/data/remote/api/PoseService.kt | 6 ++++-- .../android/core/data/repository/impl/PoseRepositoryImpl.kt | 4 ++-- .../android/feature/pose/impl/random/RandomPoseViewModel.kt | 6 +++--- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/PoseRepository.kt b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/PoseRepository.kt index 7a6a6725a..43b247fb8 100644 --- a/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/PoseRepository.kt +++ b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/PoseRepository.kt @@ -15,7 +15,7 @@ interface PoseRepository { suspend fun getPose(poseId: Long): Result - suspend fun getRandomPose(): Result + suspend fun getRandomPose(headCount: PeopleCount): Result suspend fun updateScrap(poseId: Long, scrap: Boolean): Result } diff --git a/core/data/src/main/java/com/neki/android/core/data/remote/api/PoseService.kt b/core/data/src/main/java/com/neki/android/core/data/remote/api/PoseService.kt index 575a90468..156b167fd 100644 --- a/core/data/src/main/java/com/neki/android/core/data/remote/api/PoseService.kt +++ b/core/data/src/main/java/com/neki/android/core/data/remote/api/PoseService.kt @@ -37,8 +37,10 @@ class PoseService @Inject constructor( } // 랜덤 포즈 조회 - suspend fun getRandomPose(): BasicResponse { - return client.get("/api/poses/random").body() + suspend fun getRandomPose(headCount: String): BasicResponse { + return client.get("/api/poses/random") { + parameter("headCount", headCount) + }.body() } // 스크랩 업데이트 diff --git a/core/data/src/main/java/com/neki/android/core/data/repository/impl/PoseRepositoryImpl.kt b/core/data/src/main/java/com/neki/android/core/data/repository/impl/PoseRepositoryImpl.kt index 028369e01..5e94afa19 100644 --- a/core/data/src/main/java/com/neki/android/core/data/repository/impl/PoseRepositoryImpl.kt +++ b/core/data/src/main/java/com/neki/android/core/data/repository/impl/PoseRepositoryImpl.kt @@ -45,8 +45,8 @@ class PoseRepositoryImpl @Inject constructor( poseService.getPose(poseId).data.toModel() } - override suspend fun getRandomPose(): Result = runSuspendCatching { - poseService.getRandomPose().data.toModel() + override suspend fun getRandomPose(headCount: PeopleCount): Result = runSuspendCatching { + poseService.getRandomPose(headCount = headCount.name).data.toModel() } override suspend fun updateScrap(poseId: Long, scrap: Boolean): Result = runSuspendCatching { diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseViewModel.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseViewModel.kt index 71db8e80e..db8c7200d 100644 --- a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseViewModel.kt +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseViewModel.kt @@ -21,7 +21,7 @@ import timber.log.Timber @HiltViewModel(assistedFactory = RandomPoseViewModel.Factory::class) internal class RandomPoseViewModel @AssistedInject constructor( - @Suppress("UnusedPrivateProperty") @Assisted private val peopleCount: PeopleCount, + @Assisted private val peopleCount: PeopleCount, private val poseRepository: PoseRepository, @ApplicationScope private val applicationScope: CoroutineScope, ) : ViewModel() { @@ -153,7 +153,7 @@ internal class RandomPoseViewModel @AssistedInject constructor( postSideEffect: (RandomPoseEffect) -> Unit, ) { viewModelScope.launch { - poseRepository.getRandomPose() + poseRepository.getRandomPose(headCount = peopleCount) .onSuccess { pose -> reduce { copy( @@ -180,7 +180,7 @@ internal class RandomPoseViewModel @AssistedInject constructor( // 초기에 3개 로드 repeat(3) { - poseRepository.getRandomPose() + poseRepository.getRandomPose(headCount = peopleCount) .onSuccess { pose -> poses.add(pose) } .onFailure { error -> Timber.e(error) } } From 9271d37c89a9f88da747204543d8fbf4fd878425 Mon Sep 17 00:00:00 2001 From: ikseong00 <127182222+ikseong00@users.noreply.github.com> Date: Sat, 31 Jan 2026 14:15:04 +0900 Subject: [PATCH 22/22] =?UTF-8?q?[refactor]=20#70:=20=ED=8F=AC=EC=A6=88=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=9E=A9=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=9E=A9=20=EC=83=81=ED=83=9C=20=EB=A1=A4?= =?UTF-8?q?=EB=B0=B1=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 스크랩 API 호출에 실패했을 때, 롤백하는 `isScrapped` 상태 값을 `committedScrap` (API 호출 전의 원래 상태)에서 `committedScrap` (의도했던 상태)의 nullable 타입 `Boolean?`으로 변경합니다. 이는 스크랩 상태를 옵티미스틱 업데이트 이전 값으로 정확히 되돌리기 위함입니다. --- .../pose/impl/random/RandomPoseViewModel.kt | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseViewModel.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseViewModel.kt index db8c7200d..34ab72e47 100644 --- a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseViewModel.kt +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseViewModel.kt @@ -103,18 +103,16 @@ internal class RandomPoseViewModel @AssistedInject constructor( } .onFailure { error -> Timber.e(error, "updateScrap failed for poseId: $poseId") - committedScrap?.let { originalScrap -> - reduce { - copy( - poseList = poseList.map { pose -> - if (pose.id == poseId) { - pose.copy(isScrapped = originalScrap) - } else { - pose - } - }.toImmutableList(), - ) - } + reduce { + copy( + poseList = poseList.map { pose -> + if (pose.id == poseId) { + pose.copy(isScrapped = committedScrap) + } else { + pose + } + }.toImmutableList(), + ) } } }