diff --git a/core/data-api/build.gradle.kts b/core/data-api/build.gradle.kts index c3d918b7f..dc29c6c33 100644 --- a/core/data-api/build.gradle.kts +++ b/core/data-api/build.gradle.kts @@ -10,5 +10,5 @@ dependencies { implementation(projects.core.model) implementation(libs.kotlinx.coroutines.core) api(libs.androidx.datastore.preferences) - api(libs.androidx.paging.runtime) + 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 new file mode 100644 index 000000000..43b247fb8 --- /dev/null +++ b/core/data-api/src/main/java/com/neki/android/core/dataapi/repository/PoseRepository.kt @@ -0,0 +1,21 @@ +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 { + + fun getPosesFlow( + headCount: PeopleCount? = null, + sortOrder: SortOrder = SortOrder.DESC, + ): Flow> + + suspend fun getPose(poseId: Long): 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/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/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..156b167fd --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/api/PoseService.kt @@ -0,0 +1,52 @@ +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.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 io.ktor.client.request.patch +import io.ktor.client.request.setBody +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(headCount: String): BasicResponse { + return client.get("/api/poses/random") { + parameter("headCount", headCount) + }.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/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 new file mode 100644 index 000000000..a105d9b5b --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/remote/model/response/PoseResponse.kt @@ -0,0 +1,29 @@ +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, + peopleCount = PeopleCount.entries.find { it.name == headCount }?.value ?: 1, + ) + } + + fun toModels() = items.map { it.toModel() } +} 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 dfb98f460..d22855fb2 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 @@ -7,6 +7,7 @@ import com.neki.android.core.data.repository.impl.MediaUploadRepositoryImpl import com.neki.android.core.data.repository.impl.FolderRepositoryImpl 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.FolderRepository @@ -15,6 +16,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 @@ -73,4 +75,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..5e94afa19 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/repository/impl/PoseRepositoryImpl.kt @@ -0,0 +1,55 @@ +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 + +private const val PAGE_SIZE = 20 +private const val PREFETCH_DISTANCE = 10 + +class PoseRepositoryImpl @Inject constructor( + private val poseService: PoseService, +) : PoseRepository { + + override fun getPosesFlow( + headCount: PeopleCount?, + sortOrder: SortOrder, + ): Flow> { + return Pager( + config = PagingConfig( + pageSize = PAGE_SIZE, + initialLoadSize = PAGE_SIZE, + prefetchDistance = PREFETCH_DISTANCE, + enablePlaceholders = false, + ), + pagingSourceFactory = { + PosePagingSource( + poseService = poseService, + headCount = headCount, + sortOrder = sortOrder, + ) + }, + ).flow + } + + override suspend fun getPose(poseId: Long): Result = runSuspendCatching { + poseService.getPose(poseId).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 { + poseService.updateScrap(poseId, scrap) + } +} 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..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 @@ -4,83 +4,12 @@ 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 scrappedPoseList: ImmutableList = persistentListOf(), val isShowPeopleCountBottomSheet: Boolean = false, val isShowRandomPosePeopleCountBottomSheet: Boolean = false, ) @@ -102,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/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/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..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 @@ -15,7 +14,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 +25,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/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/detail/PoseDetailContract.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/detail/PoseDetailContract.kt index 108c3ee8e..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,11 +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 1d681ada7..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 @@ -1,30 +1,64 @@ package com.neki.android.feature.pose.impl.detail import androidx.lifecycle.ViewModel -import com.neki.android.core.model.Pose +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.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 pose: Pose, + @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(pose: Pose): PoseDetailViewModel + fun create(id: Long): PoseDetailViewModel } val store: MviIntentStore = mviIntentStore( - initialState = PoseDetailState(pose = pose), + initialState = PoseDetailState(), 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, @@ -32,12 +66,44 @@ class PoseDetailViewModel @AssistedInject constructor( postSideEffect: (PoseDetailSideEffect) -> Unit, ) { 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, 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) } } } 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..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 @@ -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,29 +36,30 @@ 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( viewModel: PoseViewModel = hiltViewModel(), - navigateToPoseDetail: (Pose) -> Unit, + navigateToPoseDetail: (Long) -> Unit, navigateToRandomPose: (PeopleCount) -> Unit, navigateToNotification: () -> Unit, ) { val uiState by viewModel.store.uiState.collectAsStateWithLifecycle() + val posePagingItems = viewModel.posePagingData.collectAsLazyPagingItems() val context = LocalContext.current viewModel.store.sideEffects.collectWithLifecycle { sideEffect -> 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() } } 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.randomPoseList, + 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 c520b122c..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 @@ -1,16 +1,41 @@ 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.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flatMapLatest import javax.inject.Inject @HiltViewModel -internal class PoseViewModel @Inject constructor() : ViewModel() { +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( @@ -26,28 +51,34 @@ internal class PoseViewModel @Inject constructor() : ViewModel() { ) { when (intent) { // Pose Main - PoseIntent.EnterPoseScreen -> fetchInitialData(reduce) + PoseIntent.EnterPoseScreen -> Unit 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 -> { + _headCountFilter.value = intent.peopleCount + reduce { + copy( + isShowScrappedPose = false, + selectedPeopleCount = intent.peopleCount, + isShowPeopleCountBottomSheet = false, + ) + } } 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 -> { - postSideEffect(PoseEffect.NavigateToPoseDetail(intent.item)) + postSideEffect(PoseEffect.NavigateToPoseDetail(intent.item.id)) } PoseIntent.ClickRandomPoseRecommendation -> reduce { copy(isShowRandomPosePeopleCountBottomSheet = true) } @@ -59,8 +90,4 @@ internal class PoseViewModel @Inject constructor() : ViewModel() { } } } - - private fun fetchInitialData(reduce: (PoseState.() -> PoseState) -> Unit) { - reduce { copy() } - } } 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, 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/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, 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..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 @@ -7,25 +7,34 @@ 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 committedScraps: Map = emptyMap(), +) { + val currentPose: Pose? + get() = poseList.getOrNull(currentIndex) + + val hasPrevious: Boolean + get() = currentIndex > 0 +} 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 ClickLeftSwipe : RandomPoseIntent + data object ClickRightSwipe : 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 + 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 c66f2887d..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 @@ -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 @@ -31,14 +39,24 @@ 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() + val context = LocalContext.current + val nekiToast = remember { NekiToast(context) } + val imageLoader = remember { ImageLoader(context) } 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 -> nekiToast.showToast(sideEffect.message) + is RandomPoseEffect.RequestImageBuilder -> { + val request = ImageRequest.Builder(context) + .data(sideEffect.imageUrl) + .build() + imageLoader.execute(request) + } } } @@ -70,21 +88,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, + onLeftSwipe = { onIntent(RandomPoseIntent.ClickLeftSwipe) }, + onRightSwipe = { onIntent(RandomPoseIntent.ClickRightSwipe) }, + ) + RandomPoseFloatingBarContent( + modifier = Modifier.fillMaxWidth(), + isScrapped = pose.isScrapped, + onClickClose = { onIntent(RandomPoseIntent.ClickCloseIcon) }, + onClickGoToDetail = { onIntent(RandomPoseIntent.ClickGoToDetailIcon) }, + onClickScrap = { onIntent(RandomPoseIntent.ClickScrapIcon) }, + ) + } } if (uiState.isShowTutorial) { @@ -100,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 43aa4cf90..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 @@ -1,6 +1,9 @@ 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 import com.neki.android.core.ui.MviIntentStore @@ -9,24 +12,27 @@ 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.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +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, + @ApplicationScope private val applicationScope: CoroutineScope, ) : ViewModel() { + private val scrapJobs = mutableMapOf() + @AssistedFactory interface Factory { 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,33 +47,171 @@ internal class RandomPoseViewModel @AssistedInject constructor( postSideEffect: (RandomPoseEffect) -> Unit, ) { when (intent) { - RandomPoseIntent.EnterRandomPoseScreen -> fetchInitialData(reduce) + RandomPoseIntent.EnterRandomPoseScreen -> fetchInitialPoses(reduce, postSideEffect) // 튜토리얼 - RandomPoseIntent.ClickLeftSwipe -> Unit - RandomPoseIntent.ClickRightSwipe -> Unit + RandomPoseIntent.ClickLeftSwipe -> handleMovePrevious(state, reduce, postSideEffect) + RandomPoseIntent.ClickRightSwipe -> handleMoveNext(state, reduce, postSideEffect) RandomPoseIntent.ClickStartRandomPose -> reduce { copy(isShowTutorial = false) } // 기본화면 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.id)) + } + } + + RandomPoseIntent.ClickScrapIcon -> handleScrapToggle(state, reduce) + } + } + + 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(), + ) + } + + // 해당 포즈의 이전 Job 취소 후 새로운 Job 시작 + scrapJobs[poseId]?.cancel() + scrapJobs[poseId] = viewModelScope.launch { + delay(500) + val committedScrap = store.uiState.value.committedScraps[poseId] + 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") + reduce { + copy( + poseList = poseList.map { pose -> + if (pose.id == poseId) { + pose.copy(isScrapped = committedScrap) + } else { + pose + } + }.toImmutableList(), + ) + } + } + } + } + } + + 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.currentIndex >= state.poseList.lastIndex) return + + reduce { copy(currentIndex = currentIndex + 1) } + + // 뒤에서 2번째였으면 다음 포즈 미리 캐싱 + if (state.currentIndex == state.poseList.lastIndex - 1) { + fetchNextPose(reduce, postSideEffect) + } + } + + private fun fetchNextPose( + reduce: (RandomPoseUiState.() -> RandomPoseUiState) -> Unit, + postSideEffect: (RandomPoseEffect) -> Unit, + ) { + viewModelScope.launch { + poseRepository.getRandomPose(headCount = peopleCount) + .onSuccess { pose -> + reduce { + copy( + poseList = (poseList + pose).toImmutableList(), + committedScraps = committedScraps + (pose.id to pose.isScrapped), + ) + } + postSideEffect(RandomPoseEffect.RequestImageBuilder(pose.poseImageUrl)) + } + .onFailure { error -> + Timber.e(error) } + } + } + + private fun fetchInitialPoses( + reduce: (RandomPoseUiState.() -> RandomPoseUiState) -> Unit, + postSideEffect: (RandomPoseEffect) -> Unit, + ) { + viewModelScope.launch { + reduce { copy(isLoading = true) } + + val poses = mutableListOf() + + // 초기에 3개 로드 + repeat(3) { + poseRepository.getRandomPose(headCount = peopleCount) + .onSuccess { pose -> poses.add(pose) } + .onFailure { error -> Timber.e(error) } } - RandomPoseIntent.ClickScrapIcon -> reduce { - copy(currentPose = currentPose.copy(isScrapped = !currentPose.isScrapped)) + if (poses.isNotEmpty()) { + reduce { + copy( + isLoading = false, + poseList = poses.toImmutableList(), + committedScraps = poses.associate { it.id to it.isScrapped }, + currentIndex = 0, + ) + } + } else { + reduce { copy(isLoading = false) } + postSideEffect(RandomPoseEffect.ShowToast("포즈를 불러오는데 실패했어요")) } } } - private fun fetchInitialData(reduce: (RandomPoseUiState.() -> RandomPoseUiState) -> Unit) { - reduce { - copy( - randomPoseList = dummyPoseList, - currentPose = dummyPoseList.firstOrNull() ?: Pose(), - ) + 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) + } + } } } } 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, ) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b0b1b5b35..2f0ceb810 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" @@ -64,6 +65,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" }