Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
0ac2ac8
[feat] #77 스크랩 포즈 목록 조회 API 구현
ikseong00 Feb 4, 2026
621099e
[feat] #77 PoseDetail 스크랩 변경 ResultEventBus 전파 구현
ikseong00 Feb 4, 2026
c46d2f9
[feat] #77 PoseViewModel 스크랩 필터 및 상태 동기화 구현
ikseong00 Feb 4, 2026
66c3186
[fix] #77: 동일한 인원 수 선택 시 인원 수가 null 이 되도록 수정
ikseong00 Feb 4, 2026
cfe8e89
[refactor] #77: 최초 로딩 시에만 로딩 다이얼로그가 표시되도록 조건 수정
ikseong00 Feb 4, 2026
ccfee45
[fix] #77: 랜덤 포즈 인원 수 선택 바텀시트에서 선택 버튼 클릭 시 `selectedRandomPosePeopleC…
ikseong00 Feb 4, 2026
f9ec582
[refactor] #77: RandomPoseFloatingBar 컴포저블 분리 및 border 수정
ikseong00 Feb 4, 2026
d918fc7
[refactor] #77: 랜덤 포즈 초기 로드 개수 3→4, 여분 포즈 2→3으로 변경 및 매직넘버를 PoseConst …
ikseong00 Feb 4, 2026
93b9c3e
[feat] #77: 랜덤 포즈 추천 Horizontal Pager 를 사용하도록 변경
ikseong00 Feb 4, 2026
28c1562
[refactor] #77: 포즈 이미지 스와이프 애니메이션 속도 조절
ikseong00 Feb 4, 2026
e44604d
[feat] #77: 랜덤 포즈 이미지 페이저 흰색 배경 추가 및 Alignment.Center 추가
ikseong00 Feb 4, 2026
842f571
[refactor] #77: 랜덤 포즈 중복 호출 로직 분리 및 페이징 시 이미지 스와이프 효과 추가
ikseong00 Feb 4, 2026
2a94c29
[refactor] #77: Coil 이미지 캐싱 로직 제거
ikseong00 Feb 4, 2026
ab11309
[refactor] #77: 랜덤 포즈 API 호출 결과 세분화 및 예외 처리 강화
ikseong00 Feb 4, 2026
8a80e88
[refactor] #77: 랜덤 포즈 API 호출 결과값을 별도 변수로 관리하지 않도록 변경
ikseong00 Feb 4, 2026
8d05fb4
[feat] #77: 포즈 이미지 어두워지는 그라데이션 효과 추가
ikseong00 Feb 4, 2026
e7f7ac1
[chore] #77: 랜덤 포즈 추천 문구의 띄어쓰기를 수정합니다
ikseong00 Feb 5, 2026
1256dce
[chore] #77: 포즈 인원 수 5인 이상 제거
ikseong00 Feb 5, 2026
749802d
[feat] #77: 포즈피드 스크랩 아이콘 추가 및 스크랩 아이콘 변경
ikseong00 Feb 6, 2026
7382415
[fix] #77: 스크랩 포즈 조회 리스폰스 매핑 시 isScrapped 필드 설정
ikseong00 Feb 6, 2026
7ac7a4b
[refactor] #77: 포즈피드 상세화면 상단 그라데이션 알파 값 변경
ikseong00 Feb 6, 2026
3a366a3
[refactor] #77: 불필요한 파라미터 제거
ikseong00 Feb 6, 2026
abe53c8
[refactor] #78: 포즈 아이콘 리소스를 core:designsystem 모듈로 이동
ikseong00 Feb 6, 2026
438bff0
[chore] #77: DesignR 대신 R 네이밍 수정
ikseong00 Feb 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ interface PoseRepository {
sortOrder: SortOrder = SortOrder.DESC,
): Flow<PagingData<Pose>>

fun getScrappedPosesFlow(
sortOrder: SortOrder = SortOrder.DESC,
): Flow<PagingData<Pose>>

suspend fun getPose(poseId: Long): Result<Pose>

suspend fun getRandomPose(headCount: PeopleCount): Result<Pose>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Int, Pose>() {

override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Pose> {
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, Pose>): Int? {
return state.anchorPosition?.let { anchorPosition ->
state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)
?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,19 @@ class PoseService @Inject constructor(
}.body()
}

// 스크랩된 포즈 목록 조회
suspend fun getScrappedPoses(
page: Int = 0,
size: Int = 20,
sortOrder: String = "DESC",
): BasicResponse<PoseResponse> {
return client.get("/api/poses/scrap") {
parameter("page", page)
parameter("size", size)
parameter("sortOrder", sortOrder)
}.body()
}

// 스크랩 업데이트
suspend fun updateScrap(poseId: Long, scrap: Boolean): BasicNullableResponse<Unit> {
return client.patch("/api/poses/$poseId/scrap") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -41,6 +42,25 @@ class PoseRepositoryImpl @Inject constructor(
).flow
}

override fun getScrappedPosesFlow(
sortOrder: SortOrder,
): Flow<PagingData<Pose>> {
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<Pose> = runSuspendCatching {
poseService.getPose(poseId).data.toModel()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.neki.android.feature.pose.api

sealed interface PoseResult {
data class ScrapChanged(val poseId: Long, val isScrapped: Boolean) : PoseResult
}
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@ 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.api.PoseResult
import com.neki.android.feature.pose.impl.R

@Composable
Expand All @@ -35,13 +37,20 @@ 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) {
PoseDetailSideEffect.NavigateBack -> navigateBack()
is PoseDetailSideEffect.ShowToast -> {
nekiToast.showToast(sideEffect.message)
}
is PoseDetailSideEffect.NotifyScrapChanged -> {
resultEventBus.sendResult(
result = PoseResult.ScrapChanged(sideEffect.poseId, sideEffect.isScrapped),
allowDuplicate = false,
)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ class PoseDetailViewModel @AssistedInject constructor(
mviIntentStore(
initialState = PoseDetailState(),
onIntent = ::onIntent,
initialFetchData = { store.onIntent(PoseDetailIntent.EnterPoseDetailScreen) },
)

init {
Expand Down Expand Up @@ -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)) }
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -67,6 +69,12 @@ fun PoseScreen(
posePagingItems: LazyPagingItems<Pose>,
onIntent: (PoseIntent) -> Unit = {},
) {
val isRefreshing by remember {
derivedStateOf {
posePagingItems.loadState.refresh is LoadState.Loading && posePagingItems.itemCount == 0
}
}

Box(
modifier = Modifier.fillMaxSize(),
) {
Expand All @@ -88,6 +96,10 @@ fun PoseScreen(
)
}

if (isRefreshing) {
LoadingDialog()
}

if (uiState.isShowPeopleCountBottomSheet) {
PeopleCountBottomSheet(
selectedItem = uiState.selectedPeopleCount,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -23,16 +26,36 @@ internal class PoseViewModel @Inject constructor(
) : ViewModel() {

private val _headCountFilter = MutableStateFlow<PeopleCount?>(null)
private val _isScrapOnly = MutableStateFlow(false)
private val updatedScraps = MutableStateFlow<Map<Long, Boolean>>(emptyMap())

@OptIn(ExperimentalCoroutinesApi::class)
val posePagingData: Flow<PagingData<Pose>> = _headCountFilter
.flatMapLatest { headCount ->
private val originalPagingData: Flow<PagingData<Pose>> = combine(
_headCountFilter,
_isScrapOnly,
) { headCount, isScrapOnly ->
headCount to isScrapOnly
}.flatMapLatest { (headCount, isScrapOnly) ->
if (isScrapOnly) {
poseRepository.getScrappedPosesFlow(sortOrder = SortOrder.DESC)
Comment thread
Ojongseok marked this conversation as resolved.
Outdated
} else {
poseRepository.getPosesFlow(
headCount = headCount,
sortOrder = SortOrder.DESC,
)
}
.cachedIn(viewModelScope)
}.cachedIn(viewModelScope)

val posePagingData: Flow<PagingData<Pose>> = combine(
originalPagingData,
updatedScraps,
) { pagingData, scraps ->
pagingData.map { pose ->
scraps[pose.id]?.let { isScrapped ->
pose.copy(isScrapped = isScrapped)
} ?: pose
}
}

val store: MviIntentStore<PoseState, PoseIntent, PoseEffect> =
mviIntentStore(
Expand All @@ -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,
)
}
Expand All @@ -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,
)
}
}
}
}
Loading