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 43b247fb8..5765455d6 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 @@ -13,6 +13,10 @@ interface PoseRepository { sortOrder: SortOrder = SortOrder.DESC, ): Flow> + fun getScrappedPosesFlow( + sortOrder: SortOrder = SortOrder.DESC, + ): Flow> + suspend fun getPose(poseId: Long): Result suspend fun getRandomPose(headCount: PeopleCount): Result diff --git a/core/data/src/main/java/com/neki/android/core/data/paging/ScrapPosePagingSource.kt b/core/data/src/main/java/com/neki/android/core/data/paging/ScrapPosePagingSource.kt new file mode 100644 index 000000000..ef936d7f8 --- /dev/null +++ b/core/data/src/main/java/com/neki/android/core/data/paging/ScrapPosePagingSource.kt @@ -0,0 +1,40 @@ +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.Pose +import com.neki.android.core.model.SortOrder + +class ScrapPosePagingSource( + private val poseService: PoseService, + private val sortOrder: SortOrder, +) : PagingSource() { + + override suspend fun load(params: LoadParams): LoadResult { + return try { + val page = params.key ?: 0 + val response = poseService.getScrappedPoses( + page = page, + size = params.loadSize, + 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 index 156b167fd..2d30cb7c2 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 @@ -5,6 +5,7 @@ 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 com.neki.android.core.data.remote.model.response.ScrappedPoseResponse import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.request.get @@ -43,6 +44,19 @@ class PoseService @Inject constructor( }.body() } + // 스크랩된 포즈 목록 조회 + suspend fun getScrappedPoses( + page: Int = 0, + size: Int = 20, + sortOrder: String = "DESC", + ): BasicResponse { + return client.get("/api/poses/scrap") { + parameter("page", page) + parameter("size", size) + parameter("sortOrder", sortOrder) + }.body() + } + // 스크랩 업데이트 suspend fun updateScrap(poseId: Long, scrap: Boolean): BasicNullableResponse { return client.patch("/api/poses/$poseId/scrap") { 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 a105d9b5b..9e6cac3ef 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 @@ -22,6 +22,31 @@ data class PoseResponse( id = poseId, poseImageUrl = imageUrl, peopleCount = PeopleCount.entries.find { it.name == headCount }?.value ?: 1, + isScrapped = false, + ) + } + + fun toModels() = items.map { it.toModel() } +} + +@Serializable +data class ScrappedPoseResponse( + @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, + isScrapped = true, ) } 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 5e94afa19..900b828f3 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 @@ -4,6 +4,7 @@ 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.paging.ScrapPosePagingSource 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 @@ -41,6 +42,25 @@ class PoseRepositoryImpl @Inject constructor( ).flow } + override fun getScrappedPosesFlow( + sortOrder: SortOrder, + ): Flow> { + return Pager( + config = PagingConfig( + pageSize = PAGE_SIZE, + initialLoadSize = PAGE_SIZE, + prefetchDistance = PREFETCH_DISTANCE, + enablePlaceholders = false, + ), + pagingSourceFactory = { + ScrapPosePagingSource( + poseService = poseService, + sortOrder = sortOrder, + ) + }, + ).flow + } + override suspend fun getPose(poseId: Long): Result = runSuspendCatching { poseService.getPose(poseId).data.toModel() } diff --git a/core/designsystem/src/main/java/com/neki/android/core/designsystem/modifier/Background.kt b/core/designsystem/src/main/java/com/neki/android/core/designsystem/modifier/Background.kt index dfb5ffbea..b93915064 100644 --- a/core/designsystem/src/main/java/com/neki/android/core/designsystem/modifier/Background.kt +++ b/core/designsystem/src/main/java/com/neki/android/core/designsystem/modifier/Background.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color @@ -35,6 +36,22 @@ fun Modifier.photoBackground( shape = shape, ) +/** + * 포즈 컴포넌트에 적용되는 그라데이션 배경 + * 상단에서 134/242 지점까지 어두워지는 효과 + */ +fun Modifier.poseBackground( + shape: Shape = RoundedCornerShape(12.dp), +): Modifier = this.background( + brush = Brush.verticalGradient( + colorStops = arrayOf( + 0f to Color.Black.copy(alpha = 0.2f), + 134f / 242f to Color.Black.copy(alpha = 0f), + ), + ), + shape = shape, +) + /** * 블러 효과가 적용된 배경을 설정하는 Modifier 확장 함수 * diff --git a/feature/pose/impl/src/main/res/drawable/icon_arrow_top_right.xml b/core/designsystem/src/main/res/drawable/icon_arrow_top_right.xml similarity index 100% rename from feature/pose/impl/src/main/res/drawable/icon_arrow_top_right.xml rename to core/designsystem/src/main/res/drawable/icon_arrow_top_right.xml diff --git a/feature/pose/impl/src/main/res/drawable/icon_repeat_recommendation.xml b/core/designsystem/src/main/res/drawable/icon_repeat_recommendation.xml similarity index 100% rename from feature/pose/impl/src/main/res/drawable/icon_repeat_recommendation.xml rename to core/designsystem/src/main/res/drawable/icon_repeat_recommendation.xml diff --git a/feature/pose/impl/src/main/res/drawable/ic_scrap_selected.xml b/core/designsystem/src/main/res/drawable/icon_scrap.xml similarity index 100% rename from feature/pose/impl/src/main/res/drawable/ic_scrap_selected.xml rename to core/designsystem/src/main/res/drawable/icon_scrap.xml diff --git a/feature/pose/impl/src/main/res/drawable/icon_scrap_unselected.xml b/core/designsystem/src/main/res/drawable/icon_scrap_unselected.xml similarity index 100% rename from feature/pose/impl/src/main/res/drawable/icon_scrap_unselected.xml rename to core/designsystem/src/main/res/drawable/icon_scrap_unselected.xml diff --git a/core/model/src/main/java/com/neki/android/core/model/PeopleCount.kt b/core/model/src/main/java/com/neki/android/core/model/PeopleCount.kt index 415418f0d..1ea4d763d 100644 --- a/core/model/src/main/java/com/neki/android/core/model/PeopleCount.kt +++ b/core/model/src/main/java/com/neki/android/core/model/PeopleCount.kt @@ -8,7 +8,6 @@ enum class PeopleCount(val displayText: String, val value: Int) { TWO("2인", 2), THREE("3인", 3), FOUR("4인", 4), - FIVE_OR_MORE("5인 이상", 5), ; override fun toString(): String = displayText diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo/AllPhotoScreen.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo/AllPhotoScreen.kt index f56d9dfb8..8cf2fce06 100644 --- a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo/AllPhotoScreen.kt +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo/AllPhotoScreen.kt @@ -121,7 +121,11 @@ internal fun AllPhotoScreen( } } - val isRefreshing by remember { derivedStateOf { pagingItems.loadState.refresh is LoadState.Loading } } + val isRefreshing by remember { + derivedStateOf { + pagingItems.loadState.refresh is LoadState.Loading && pagingItems.itemCount == 0 + } + } BackHandler(enabled = true) { onIntent(AllPhotoIntent.OnBackPressed) diff --git a/feature/pose/api/src/main/java/com/neki/android/feature/pose/api/PoseResult.kt b/feature/pose/api/src/main/java/com/neki/android/feature/pose/api/PoseResult.kt new file mode 100644 index 000000000..a2e93fea8 --- /dev/null +++ b/feature/pose/api/src/main/java/com/neki/android/feature/pose/api/PoseResult.kt @@ -0,0 +1,5 @@ +package com.neki.android.feature.pose.api + +sealed interface PoseResult { + data class ScrapChanged(val poseId: Long, val isScrapped: Boolean) : PoseResult +} diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/const/PoseConst.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/const/PoseConst.kt index 2a4658a83..0ed219f22 100644 --- a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/const/PoseConst.kt +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/const/PoseConst.kt @@ -1,6 +1,10 @@ package com.neki.android.feature.pose.impl.const internal object PoseConst { + internal const val INITIAL_POSE_LOAD_COUNT = 4 + internal const val POSE_PREFETCH_THRESHOLD = 3 + internal const val MAXIMUM_RANDOM_POSE_FALLBACK_COUNT = 7 + internal const val POSE_LAYOUT_DEFAULT_TOP_PADDING = 12 internal const val POSE_LAYOUT_BOTTOM_PADDING = 28 internal const val POSE_LAYOUT_VERTICAL_SPACING = 12 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 edcc304dd..65444039e 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 @@ -19,4 +19,5 @@ sealed interface PoseDetailIntent { sealed interface PoseDetailSideEffect { data object NavigateBack : PoseDetailSideEffect data class ShowToast(val message: String) : PoseDetailSideEffect + data class NotifyScrapChanged(val poseId: Long, val isScrapped: Boolean) : PoseDetailSideEffect } diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/detail/PoseDetailScreen.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/detail/PoseDetailScreen.kt index 89fad1b05..7e2059b8c 100644 --- a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/detail/PoseDetailScreen.kt +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/detail/PoseDetailScreen.kt @@ -23,9 +23,11 @@ import com.neki.android.core.designsystem.button.NekiIconButton import com.neki.android.core.designsystem.topbar.BackTitleTopBar import com.neki.android.core.designsystem.ui.theme.NekiTheme import com.neki.android.core.model.Pose +import com.neki.android.core.navigation.result.LocalResultEventBus import com.neki.android.core.ui.compose.collectWithLifecycle import com.neki.android.core.ui.toast.NekiToast -import com.neki.android.feature.pose.impl.R +import com.neki.android.feature.pose.api.PoseResult +import com.neki.android.core.designsystem.R @Composable internal fun PoseDetailRoute( @@ -35,6 +37,7 @@ internal fun PoseDetailRoute( val uiState by viewModel.store.uiState.collectAsStateWithLifecycle() val context = LocalContext.current val nekiToast = remember { NekiToast(context) } + val resultEventBus = LocalResultEventBus.current viewModel.store.sideEffects.collectWithLifecycle { sideEffect -> when (sideEffect) { @@ -42,6 +45,12 @@ internal fun PoseDetailRoute( is PoseDetailSideEffect.ShowToast -> { nekiToast.showToast(sideEffect.message) } + is PoseDetailSideEffect.NotifyScrapChanged -> { + resultEventBus.sendResult( + result = PoseResult.ScrapChanged(sideEffect.poseId, sideEffect.isScrapped), + allowDuplicate = false, + ) + } } } @@ -85,7 +94,7 @@ internal fun PoseDetailScreen( ) { Icon( imageVector = ImageVector.vectorResource( - if (uiState.pose.isScrapped) R.drawable.ic_scrap_selected + if (uiState.pose.isScrapped) R.drawable.icon_scrap else R.drawable.icon_scrap_unselected, ), contentDescription = null, 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 c044b94e6..b7572b0a4 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 @@ -36,6 +36,7 @@ class PoseDetailViewModel @AssistedInject constructor( mviIntentStore( initialState = PoseDetailState(), onIntent = ::onIntent, + initialFetchData = { store.onIntent(PoseDetailIntent.EnterPoseDetailScreen) }, ) init { @@ -69,7 +70,10 @@ class PoseDetailViewModel @AssistedInject constructor( PoseDetailIntent.EnterPoseDetailScreen -> fetchPoseData(reduce) PoseDetailIntent.ClickBackIcon -> postSideEffect(PoseDetailSideEffect.NavigateBack) PoseDetailIntent.ClickScrapIcon -> handleScrapToggle(state, reduce) - is PoseDetailIntent.ScrapCommitted -> reduce { copy(committedScrap = intent.newScrap) } + is PoseDetailIntent.ScrapCommitted -> { + reduce { copy(committedScrap = intent.newScrap) } + postSideEffect(PoseDetailSideEffect.NotifyScrapChanged(id, intent.newScrap)) + } is PoseDetailIntent.RevertScrap -> reduce { copy(pose = pose.copy(isScrapped = intent.originalScrap)) } } } diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/PoseContract.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/PoseContract.kt index 6e319830e..e1e9754e9 100644 --- a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/PoseContract.kt +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/PoseContract.kt @@ -27,6 +27,7 @@ sealed interface PoseIntent { data object ClickRandomPoseRecommendation : PoseIntent data class ClickRandomPosePeopleCountSheetItem(val peopleCount: PeopleCount) : PoseIntent data object ClickRandomPoseBottomSheetSelectButton : PoseIntent + data class ScrapChanged(val poseId: Long, val isScrapped: Boolean) : PoseIntent } sealed interface PoseEffect { 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 324ceee18..e752c235f 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,6 +21,7 @@ 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 androidx.paging.LoadState import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems import com.neki.android.core.model.PeopleCount @@ -29,6 +30,7 @@ import com.neki.android.core.ui.compose.collectWithLifecycle import com.neki.android.feature.pose.impl.const.PoseConst.POSE_LAYOUT_DEFAULT_TOP_PADDING import com.neki.android.feature.pose.impl.main.component.FilterBar import com.neki.android.feature.pose.impl.main.component.PeopleCountBottomSheet +import com.neki.android.core.ui.component.LoadingDialog 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 @@ -67,6 +69,12 @@ fun PoseScreen( posePagingItems: LazyPagingItems, onIntent: (PoseIntent) -> Unit = {}, ) { + val isRefreshing by remember { + derivedStateOf { + posePagingItems.loadState.refresh is LoadState.Loading && posePagingItems.itemCount == 0 + } + } + Box( modifier = Modifier.fillMaxSize(), ) { @@ -88,6 +96,10 @@ fun PoseScreen( ) } + if (isRefreshing) { + LoadingDialog() + } + if (uiState.isShowPeopleCountBottomSheet) { PeopleCountBottomSheet( selectedItem = uiState.selectedPeopleCount, @@ -160,5 +172,3 @@ fun PoseContent( } } } - -// 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 607130760..ac0d30a90 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 @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.cachedIn +import androidx.paging.map import com.neki.android.core.dataapi.repository.PoseRepository import com.neki.android.core.model.PeopleCount import com.neki.android.core.model.Pose @@ -14,7 +15,9 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.update import javax.inject.Inject @HiltViewModel @@ -23,16 +26,36 @@ internal class PoseViewModel @Inject constructor( ) : ViewModel() { private val _headCountFilter = MutableStateFlow(null) + private val _isScrapOnly = MutableStateFlow(false) + private val updatedScraps = MutableStateFlow>(emptyMap()) @OptIn(ExperimentalCoroutinesApi::class) - val posePagingData: Flow> = _headCountFilter - .flatMapLatest { headCount -> + private val originalPagingData: Flow> = combine( + _headCountFilter, + _isScrapOnly, + ) { headCount, isScrapOnly -> + headCount to isScrapOnly + }.flatMapLatest { (headCount, isScrapOnly) -> + if (isScrapOnly) { + poseRepository.getScrappedPosesFlow() + } else { poseRepository.getPosesFlow( headCount = headCount, sortOrder = SortOrder.DESC, ) } - .cachedIn(viewModelScope) + }.cachedIn(viewModelScope) + + val posePagingData: Flow> = combine( + originalPagingData, + updatedScraps, + ) { pagingData, scraps -> + pagingData.map { pose -> + scraps[pose.id]?.let { isScrapped -> + pose.copy(isScrapped = isScrapped) + } ?: pose + } + } val store: MviIntentStore = mviIntentStore( @@ -51,24 +74,16 @@ internal class PoseViewModel @Inject constructor( 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, - selectedPeopleCount = intent.peopleCount, - isShowPeopleCountBottomSheet = false, - ) - } - } - + is PoseIntent.ClickPeopleCountSheetItem -> handlePeopleCountSheetItem(intent, state, reduce) PoseIntent.DismissPeopleCountBottomSheet -> reduce { copy(isShowPeopleCountBottomSheet = false) } PoseIntent.DismissRandomPosePeopleCountBottomSheet -> reduce { copy(isShowRandomPosePeopleCountBottomSheet = false) } PoseIntent.ClickScrapChip -> { + val newValue = !state.isShowScrappedPose + _isScrapOnly.value = newValue _headCountFilter.value = null reduce { copy( - isShowScrappedPose = !isShowScrappedPose, + isShowScrappedPose = newValue, selectedPeopleCount = null, ) } @@ -82,9 +97,45 @@ internal class PoseViewModel @Inject constructor( is PoseIntent.ClickRandomPosePeopleCountSheetItem -> reduce { copy(selectedRandomPosePeopleCount = intent.peopleCount) } PoseIntent.ClickRandomPoseBottomSheetSelectButton -> { val selectedCount = state.selectedRandomPosePeopleCount ?: return - reduce { copy(isShowRandomPosePeopleCountBottomSheet = false) } + reduce { + copy( + selectedRandomPosePeopleCount = null, + isShowRandomPosePeopleCountBottomSheet = false, + ) + } postSideEffect(PoseEffect.NavigateToRandomPose(selectedCount)) } + + is PoseIntent.ScrapChanged -> { + updatedScraps.update { it + (intent.poseId to intent.isScrapped) } + } + } + } + + private fun handlePeopleCountSheetItem( + intent: PoseIntent.ClickPeopleCountSheetItem, + state: PoseState, + reduce: (PoseState.() -> PoseState) -> Unit, + ) { + _isScrapOnly.value = false + if (intent.peopleCount == state.selectedPeopleCount) { + _headCountFilter.value = null + reduce { + copy( + isShowScrappedPose = false, + isShowPeopleCountBottomSheet = false, + selectedPeopleCount = null, + ) + } + } else { + _headCountFilter.value = intent.peopleCount + reduce { + copy( + isShowScrappedPose = false, + selectedPeopleCount = intent.peopleCount.takeIf { it != state.selectedPeopleCount }, + isShowPeopleCountBottomSheet = false, + ) + } } } } 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 26e1d0ad1..383c76065 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 @@ -1,25 +1,36 @@ package com.neki.android.feature.pose.impl.main.component import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size 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.rememberLazyStaggeredGridState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.vectorResource 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.modifier.poseBackground +import com.neki.android.core.designsystem.ui.theme.NekiTheme import com.neki.android.core.model.Pose +import com.neki.android.core.designsystem.R 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 @@ -61,14 +72,45 @@ private fun PoseItem( modifier: Modifier = Modifier, onClickItem: (Pose) -> Unit = {}, ) { - AsyncImage( + Box( modifier = modifier .clip(RoundedCornerShape(12.dp)) .noRippleClickable { onClickItem(pose) }, - model = pose.poseImageUrl, - contentDescription = null, - contentScale = ContentScale.FillWidth, - ) + ) { + AsyncImage( + modifier = Modifier.fillMaxWidth(), + model = pose.poseImageUrl, + contentDescription = null, + contentScale = ContentScale.FillWidth, + ) + Box( + modifier = Modifier + .matchParentSize() + .poseBackground(shape = RectangleShape), + ) + if (pose.isScrapped) { + Icon( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 10.dp, end = 10.dp) + .size(20.dp), + imageVector = ImageVector.vectorResource(R.drawable.icon_scrap), + contentDescription = null, + tint = NekiTheme.colorScheme.white, + ) + } + } } -// Preview is not available for PagingItems component +@ComponentPreview +@Composable +private fun PoseItemPreview() { + NekiTheme { + PoseItem( + pose = Pose( + id = 1, + poseImageUrl = "", + ), + ) + } +} diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/component/RecommendationChip.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/component/RecommendationChip.kt index 621c1b415..ad1e724d7 100644 --- a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/component/RecommendationChip.kt +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/component/RecommendationChip.kt @@ -18,7 +18,7 @@ import androidx.compose.ui.unit.dp import com.neki.android.core.designsystem.ComponentPreview import com.neki.android.core.designsystem.modifier.clickableSingle import com.neki.android.core.designsystem.ui.theme.NekiTheme -import com.neki.android.feature.pose.impl.R +import com.neki.android.core.designsystem.R @Composable internal fun RecommendationChip( @@ -40,7 +40,7 @@ internal fun RecommendationChip( tint = Color.Unspecified, ) Text( - text = "랜덤포즈 추천", + text = "랜덤 포즈 추천", style = NekiTheme.typography.title18SemiBold, color = NekiTheme.colorScheme.white, ) 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 b8e79f8e8..7c338421f 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 @@ -5,12 +5,17 @@ import androidx.navigation3.runtime.EntryProviderScope import androidx.navigation3.runtime.NavKey import com.neki.android.core.navigation.EntryProviderInstaller import com.neki.android.core.navigation.Navigator +import com.neki.android.core.navigation.result.LocalResultEventBus +import com.neki.android.core.navigation.result.ResultEffect import com.neki.android.feature.pose.api.PoseNavKey +import com.neki.android.feature.pose.api.PoseResult import com.neki.android.feature.pose.api.navigateToPoseDetail import com.neki.android.feature.pose.api.navigateToRandomPose import com.neki.android.feature.pose.impl.detail.PoseDetailRoute import com.neki.android.feature.pose.impl.detail.PoseDetailViewModel +import com.neki.android.feature.pose.impl.main.PoseIntent import com.neki.android.feature.pose.impl.main.PoseRoute +import com.neki.android.feature.pose.impl.main.PoseViewModel import com.neki.android.feature.pose.impl.random.RandomPoseRoute import com.neki.android.feature.pose.impl.random.RandomPoseViewModel import dagger.Module @@ -32,7 +37,19 @@ object PoseEntryProviderModule { private fun EntryProviderScope.poseEntry(navigator: Navigator) { entry { + val resultBus = LocalResultEventBus.current + val viewModel = hiltViewModel() + + ResultEffect(resultBus) { result -> + when (result) { + is PoseResult.ScrapChanged -> { + viewModel.store.onIntent(PoseIntent.ScrapChanged(result.poseId, result.isScrapped)) + } + } + } + PoseRoute( + viewModel = viewModel, navigateToPoseDetail = navigator::navigateToPoseDetail, navigateToRandomPose = navigator::navigateToRandomPose, navigateToNotification = {}, 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 e160ba27a..5d9663e7e 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 @@ -18,6 +18,12 @@ data class RandomPoseUiState( get() = currentIndex > 0 } +internal sealed class FetchPoseResult(val tryCount: Int) { + class Success(tryCount: Int, val pose: Pose) : FetchPoseResult(tryCount) + class Duplicated(tryCount: Int) : FetchPoseResult(tryCount) + class Failure(tryCount: Int, val throwable: Throwable) : FetchPoseResult(tryCount) +} + sealed interface RandomPoseIntent { data object EnterRandomPoseScreen : RandomPoseIntent @@ -35,6 +41,6 @@ sealed interface RandomPoseIntent { sealed interface RandomPoseEffect { data object NavigateBack : RandomPoseEffect data class NavigateToDetail(val poseId: Long) : RandomPoseEffect + data class SwipePoseImage(val index: Int) : 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 4419fa05c..2ef4f7f47 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 @@ -1,36 +1,29 @@ package com.neki.android.feature.pose.impl.random +import androidx.compose.animation.core.tween 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.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState 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.RandomPoseImagePager import com.neki.android.feature.pose.impl.random.component.RandomPoseTutorialOverlay import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.rememberHazeState @@ -44,25 +37,25 @@ internal fun RandomPoseRoute( val uiState by viewModel.store.uiState.collectAsStateWithLifecycle() val context = LocalContext.current val nekiToast = remember { NekiToast(context) } - val imageLoader = remember { ImageLoader(context) } + val pagerState = rememberPagerState { uiState.poseList.size } viewModel.store.sideEffects.collectWithLifecycle { sideEffect -> when (sideEffect) { RandomPoseEffect.NavigateBack -> navigateBack() is RandomPoseEffect.NavigateToDetail -> navigateToPoseDetail(sideEffect.poseId) + is RandomPoseEffect.SwipePoseImage -> pagerState.animateScrollToPage( + page = sideEffect.index, + animationSpec = tween(durationMillis = 500), + ) + is RandomPoseEffect.ShowToast -> nekiToast.showToast(sideEffect.message) - is RandomPoseEffect.RequestImageBuilder -> { - val request = ImageRequest.Builder(context) - .data(sideEffect.imageUrl) - .build() - imageLoader.execute(request) - } } } RandomPoseScreen( uiState = uiState, onIntent = viewModel.store::onIntent, + pagerState = pagerState, ) } @@ -70,6 +63,7 @@ internal fun RandomPoseRoute( internal fun RandomPoseScreen( uiState: RandomPoseUiState = RandomPoseUiState(), onIntent: (RandomPoseIntent) -> Unit = {}, + pagerState: PagerState = rememberPagerState { uiState.poseList.size }, ) { val hazeState = rememberHazeState() @@ -88,15 +82,18 @@ internal fun RandomPoseScreen( onClose = { onIntent(RandomPoseIntent.ClickCloseIcon) }, ) VerticalSpacer(42.dp) + + RandomPoseImagePager( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + poseList = uiState.poseList, + pagerState = pagerState, + onLeftSwipe = { onIntent(RandomPoseIntent.ClickLeftSwipe) }, + onRightSwipe = { onIntent(RandomPoseIntent.ClickRightSwipe) }, + ) + 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, @@ -105,6 +102,7 @@ internal fun RandomPoseScreen( onClickScrap = { onIntent(RandomPoseIntent.ClickScrapIcon) }, ) } + VerticalSpacer(4.dp) } if (uiState.isShowTutorial) { @@ -116,44 +114,6 @@ internal fun RandomPoseScreen( } } -@Composable -private fun RandomPoseImage( - pose: Pose, - modifier: Modifier = Modifier, - onLeftSwipe: () -> Unit = {}, - onRightSwipe: () -> Unit = {}, -) { - Box( - modifier = modifier - .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 @Composable private fun RandomPoseScreenPreview() { 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 9bb3d99cb..b0f50bdae 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 @@ -8,6 +8,7 @@ import com.neki.android.core.model.PeopleCount import com.neki.android.core.model.Pose import com.neki.android.core.ui.MviIntentStore import com.neki.android.core.ui.mviIntentStore +import com.neki.android.feature.pose.impl.const.PoseConst import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -125,7 +126,9 @@ internal class RandomPoseViewModel @AssistedInject constructor( postSideEffect: (RandomPoseEffect) -> Unit, ) { if (state.hasPrevious) { - reduce { copy(currentIndex = currentIndex - 1) } + val previousIndex = state.currentIndex - 1 + reduce { copy(currentIndex = previousIndex) } + postSideEffect(RandomPoseEffect.SwipePoseImage(previousIndex)) } else { postSideEffect(RandomPoseEffect.ShowToast("첫번째 포즈입니다.")) } @@ -136,34 +139,36 @@ internal class RandomPoseViewModel @AssistedInject constructor( 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) + // 마지막 인덱스에 도달 + if (state.currentIndex >= state.poseList.lastIndex) { + postSideEffect(RandomPoseEffect.ShowToast("모든 포즈를 불러왔어요")) + return } - } - private fun fetchNextPose( - reduce: (RandomPoseUiState.() -> RandomPoseUiState) -> Unit, - postSideEffect: (RandomPoseEffect) -> Unit, - ) { - viewModelScope.launch { - poseRepository.getRandomPose(headCount = peopleCount) - .onSuccess { pose -> - reduce { + val nextIndex = state.currentIndex + 1 + reduce { copy(currentIndex = nextIndex) } + postSideEffect(RandomPoseEffect.SwipePoseImage(nextIndex)) + + // 여분 포즈가 POSE_PREFETCH_THRESHOLD 이하이면 다음 포즈 미리 캐싱 + if (state.poseList.lastIndex - nextIndex < PoseConst.POSE_PREFETCH_THRESHOLD) { + viewModelScope.launch { + when (val result = fetchRandomPose(poseList = state.poseList)) { + is FetchPoseResult.Success -> reduce { copy( - poseList = (poseList + pose).toImmutableList(), - committedScraps = committedScraps + (pose.id to pose.isScrapped), + poseList = (poseList + result.pose).toImmutableList(), + committedScraps = committedScraps + (result.pose.id to result.pose.isScrapped), ) } - postSideEffect(RandomPoseEffect.RequestImageBuilder(pose.poseImageUrl)) - } - .onFailure { error -> - Timber.e(error) + + is FetchPoseResult.Duplicated -> + Timber.d("프리페치 생략: ${result.tryCount}회 시도 후 중복 포즈") + + is FetchPoseResult.Failure -> { + Timber.e(result.throwable) + postSideEffect(RandomPoseEffect.ShowToast("포즈를 불러오는데 실패했어요")) + } } + } } } @@ -175,12 +180,31 @@ internal class RandomPoseViewModel @AssistedInject constructor( reduce { copy(isLoading = true) } val poses = mutableListOf() + var totalFallbackCount = 0 - // 초기에 3개 로드 - repeat(3) { - poseRepository.getRandomPose(headCount = peopleCount) - .onSuccess { pose -> poses.add(pose) } - .onFailure { error -> Timber.e(error) } + // 초기에 INITIAL_POSE_LOAD_COUNT개 로드 + while ( + poses.size < PoseConst.INITIAL_POSE_LOAD_COUNT && + totalFallbackCount < PoseConst.MAXIMUM_RANDOM_POSE_FALLBACK_COUNT + ) { + val result = fetchRandomPose( + poseList = poses, + maxFallbackCount = PoseConst.MAXIMUM_RANDOM_POSE_FALLBACK_COUNT - totalFallbackCount, + ) + + totalFallbackCount += result.tryCount + + when (result) { + is FetchPoseResult.Success -> poses.add(result.pose) + is FetchPoseResult.Failure -> { + Timber.e(result.throwable) + postSideEffect(RandomPoseEffect.ShowToast("포즈를 불러오는데 실패했어요")) + break + } + + is FetchPoseResult.Duplicated -> + Timber.d("초기 로드: ${result.tryCount}회 시도 후 중복 포즈") + } } if (poses.isNotEmpty()) { @@ -199,6 +223,29 @@ internal class RandomPoseViewModel @AssistedInject constructor( } } + private suspend fun fetchRandomPose( + poseList: List, + maxFallbackCount: Int = PoseConst.MAXIMUM_RANDOM_POSE_FALLBACK_COUNT, + ): FetchPoseResult { + var tryCount = 0 + + while (tryCount < maxFallbackCount) { + tryCount++ + poseRepository.getRandomPose(headCount = peopleCount) + .onSuccess { pose -> + if (poseList.none { it.id == pose.id }) { + return FetchPoseResult.Success(tryCount, pose) + } + } + .onFailure { error -> + Timber.e(error) + return FetchPoseResult.Failure(tryCount, error) + } + } + + return FetchPoseResult.Duplicated(tryCount) + } + override fun onCleared() { super.onCleared() 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 f3ab73eb3..9cf25d328 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 @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape @@ -18,12 +19,12 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.neki.android.core.designsystem.ComponentPreview import com.neki.android.core.designsystem.button.NekiIconButton import com.neki.android.core.designsystem.ui.theme.NekiTheme -import com.neki.android.feature.pose.impl.R -import com.neki.android.core.designsystem.R as DesignR +import com.neki.android.core.designsystem.R @Composable internal fun RandomPoseFloatingBarContent( @@ -32,6 +33,28 @@ internal fun RandomPoseFloatingBarContent( onClickClose: () -> Unit = {}, onClickGoToDetail: () -> Unit = {}, onClickScrap: () -> Unit = {}, +) { + Box( + modifier = modifier, + ) { + RandomPoseFloatingBarBackground( + modifier = Modifier.matchParentSize(), + ) + RandomPoseFloatingBar( + modifier = Modifier + .padding(horizontal = 20.dp) + .padding(top = 38.dp, bottom = 34.dp), + isScrapped = isScrapped, + onClickClose = onClickClose, + onClickGoToDetail = onClickGoToDetail, + onClickScrap = onClickScrap, + ) + } +} + +@Composable +private fun RandomPoseFloatingBarBackground( + modifier: Modifier = Modifier, ) { Box( modifier = modifier @@ -43,74 +66,81 @@ internal fun RandomPoseFloatingBarContent( ), ), alpha = 0.24f, + ), + ) +} + +@Composable +private fun RandomPoseFloatingBar( + modifier: Modifier = Modifier, + isScrapped: Boolean = false, + onClickClose: () -> Unit = {}, + onClickGoToDetail: () -> Unit = {}, + onClickScrap: () -> Unit = {}, +) { + Row( + modifier = modifier + .fillMaxWidth() + .clip(CircleShape) + .background( + color = NekiTheme.colorScheme.white.copy(alpha = 0.6f), + shape = CircleShape, ) - .padding(horizontal = 20.dp) - .padding(top = 38.dp, bottom = 34.dp), + .border( + width = 1.dp, + brush = Brush.verticalGradient( + colors = listOf( + NekiTheme.colorScheme.white, + NekiTheme.colorScheme.white.copy(alpha = 0f), + ), + ), + shape = CircleShape, + ) + .padding(8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { + RandomPoseButton( + onClick = onClickClose, + backgroundColor = NekiTheme.colorScheme.gray25.copy(alpha = 0.9f), + ) { + Icon( + modifier = Modifier.size(28.dp), + imageVector = ImageVector.vectorResource(R.drawable.icon_close), + contentDescription = "닫기", + tint = NekiTheme.colorScheme.gray800, + ) + } + Row( - modifier = Modifier - .clip(CircleShape) - .fillMaxWidth() - .background( - color = NekiTheme.colorScheme.white.copy(alpha = 0.6f), - shape = CircleShape, - ) - .border( - width = 1.dp, - brush = Brush.verticalGradient( - colors = listOf( - NekiTheme.colorScheme.white.copy(alpha = 0.2f), - NekiTheme.colorScheme.white, - ), - ), - shape = CircleShape, - ) - .padding(8.dp), - horizontalArrangement = Arrangement.SpaceBetween, + horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { RandomPoseButton( - onClick = onClickClose, - backgroundColor = NekiTheme.colorScheme.gray25.copy(alpha = 0.9f), + onClick = onClickGoToDetail, + backgroundColor = NekiTheme.colorScheme.primary400, ) { Icon( - modifier = Modifier.size(28.dp), - imageVector = ImageVector.vectorResource(DesignR.drawable.icon_close), - contentDescription = "닫기", - tint = NekiTheme.colorScheme.gray800, + modifier = Modifier.size(24.dp), + imageVector = ImageVector.vectorResource(R.drawable.icon_arrow_top_right), + contentDescription = "상세 보기", + tint = NekiTheme.colorScheme.white, ) } - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, + RandomPoseButton( + onClick = onClickScrap, + backgroundColor = NekiTheme.colorScheme.primary400, ) { - RandomPoseButton( - onClick = onClickGoToDetail, - backgroundColor = NekiTheme.colorScheme.primary400, - ) { - Icon( - modifier = Modifier.size(24.dp), - imageVector = ImageVector.vectorResource(R.drawable.icon_arrow_top_right), - contentDescription = "상세 보기", - tint = NekiTheme.colorScheme.white, - ) - } - - RandomPoseButton( - onClick = onClickScrap, - backgroundColor = NekiTheme.colorScheme.primary400, - ) { - Icon( - modifier = Modifier.size(24.dp), - imageVector = ImageVector.vectorResource( - if (isScrapped) R.drawable.ic_scrap_selected - else R.drawable.icon_scrap_unselected, - ), - contentDescription = "스크랩", - tint = NekiTheme.colorScheme.white, - ) - } + Icon( + modifier = Modifier.size(24.dp), + imageVector = ImageVector.vectorResource( + if (isScrapped) R.drawable.icon_scrap + else R.drawable.icon_scrap_unselected, + ), + contentDescription = "스크랩", + tint = NekiTheme.colorScheme.white, + ) } } } @@ -134,6 +164,34 @@ private fun RandomPoseButton( } } +@ComponentPreview +@Composable +private fun RandomPoseFloatingBarBackgroundPreview() { + NekiTheme { + Box( + modifier = Modifier + .fillMaxWidth() + .height(120.dp), + ) { + RandomPoseFloatingBarBackground( + modifier = Modifier.matchParentSize(), + ) + } + } +} + +@Preview +@Composable +private fun RandomPoseFloatingBarPreview() { + NekiTheme { + RandomPoseFloatingBar( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + ) + } +} + @ComponentPreview @Composable private fun RandomPoseFloatingBarContentPreview() { diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/component/RandomPoseImagePager.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/component/RandomPoseImagePager.kt new file mode 100644 index 000000000..b1652c83d --- /dev/null +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/component/RandomPoseImagePager.kt @@ -0,0 +1,108 @@ +package com.neki.android.feature.pose.impl.random.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.neki.android.core.designsystem.modifier.noRippleClickableSingle +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 +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Composable +internal fun RandomPoseImagePager( + pagerState: PagerState, + poseList: ImmutableList, + modifier: Modifier = Modifier, + onLeftSwipe: () -> Unit = {}, + onRightSwipe: () -> Unit = {}, +) { + HorizontalPager( + modifier = modifier, + state = pagerState, + beyondViewportPageCount = PoseConst.POSE_PREFETCH_THRESHOLD, + userScrollEnabled = false, + ) { index -> + RandomPoseImage( + pose = poseList[index], + onLeftSwipe = onLeftSwipe, + onRightSwipe = onRightSwipe, + ) + } +} + +@Composable +private fun RandomPoseImage( + pose: Pose, + modifier: Modifier = Modifier, + onLeftSwipe: () -> Unit = {}, + onRightSwipe: () -> Unit = {}, +) { + Box( + modifier = modifier.padding(horizontal = 10.dp), + ) { + AsyncImage( + model = pose.poseImageUrl, + contentDescription = null, + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(20.dp)) + .background(NekiTheme.colorScheme.white), + contentScale = ContentScale.FillWidth, + alignment = Alignment.Center, + ) + Row( + modifier = Modifier.matchParentSize(), + ) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .noRippleClickableSingle(onClick = onLeftSwipe), + ) + Box( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .noRippleClickableSingle(onClick = onRightSwipe), + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun RandomPoseImagePagerPreview() { + val poseList = persistentListOf( + Pose(id = 1L, poseImageUrl = "https://example.com/pose1.jpg"), + Pose(id = 2L, poseImageUrl = "https://example.com/pose2.jpg"), + ) + RandomPoseImagePager( + pagerState = rememberPagerState { poseList.size }, + poseList = poseList, + ) +} + +@Preview(showBackground = true) +@Composable +private fun RandomPoseImagePreview() { + RandomPoseImage( + pose = Pose(id = 1L, poseImageUrl = "https://example.com/pose.jpg"), + ) +}