Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 @@ -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
Expand Down Expand Up @@ -43,6 +44,19 @@ class PoseService @Inject constructor(
}.body()
}

// 스크랩된 포즈 목록 조회
suspend fun getScrappedPoses(
page: Int = 0,
size: Int = 20,
sortOrder: String = "DESC",
): BasicResponse<ScrappedPoseResponse> {
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 @@ -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<Item>,
) {
@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,
)
}

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 @@ -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
Comment thread
ikseong00 marked this conversation as resolved.
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
Expand Down Expand Up @@ -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 확장 함수
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
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,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(
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 Expand Up @@ -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,
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 Expand Up @@ -160,5 +172,3 @@ fun PoseContent(
}
}
}

// Preview is not available for PagingItems component
Loading