Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
474ade5
[feat] #70: Pose API 레이어 구현
ikseong00 Jan 29, 2026
d42572c
[feat] #70: 포즈 목록 화면 API 연동
ikseong00 Jan 29, 2026
92d595b
[feat] #70: 랜덤 포즈 화면 API 연동
ikseong00 Jan 29, 2026
f4f5ff3
[refactor] #70: BottomSheet이 중간에 걸치지 않도록 수정
ikseong00 Jan 29, 2026
12b564f
[feat] #70: 포즈 목록 Paging 적용
ikseong00 Jan 29, 2026
63f00d4
[build] #70: detekt UnusedPrivateProperty suppress 추가
ikseong00 Jan 29, 2026
3fe975d
[refactor] #70: PoseDetailResponse를 PoseItemResponse로 이름 변경
ikseong00 Jan 29, 2026
a956c89
[refactor] #70: PagingConfig 상수 위치 변경 및 설정 추가
ikseong00 Jan 29, 2026
3df5f84
[refactor] #70: 포즈 랜덤 화면 플로팅 바 패딩 수정
ikseong00 Jan 29, 2026
323d245
[refactor] #70: 포즈 상세 조회 API 응답 모델에서 'scrap' 필드 제거
ikseong00 Jan 29, 2026
3d32488
[refactor] #70: 포즈 상세화면 진입 시 Pose 객체 대신 ID를 전달하도록 변경
ikseong00 Jan 29, 2026
40a158a
[feat] #70: 랜덤 포즈 좌우 이동 및 프리패치 구현
ikseong00 Jan 29, 2026
523ebf2
[feat] #70: 포즈 상세/랜덤 응답에 scrap 필드 반영
ikseong00 Jan 29, 2026
6855722
[feat] #70: 포즈 상세화면 스크랩 API 연동
ikseong00 Jan 29, 2026
cd699b1
[feat] #70: 랜덤 포즈 스크랩 API 연동 및 UI 개선
ikseong00 Jan 29, 2026
f8aa796
[fix] #70: detekt SpacingAroundCurly 린트 오류 수정
ikseong00 Jan 29, 2026
176e224
[refactor] #70: 사용하지 않는 `getPoses` 함수 제거
ikseong00 Jan 29, 2026
95deaa9
[fix] #70: updateScrap 반환값 수정 및 스크랩 조건 로직 개선
ikseong00 Jan 29, 2026
a35534d
[fix] #70: handleMoveNext 인덱스 이동 및 프리패치 로직 버그 수정
ikseong00 Jan 29, 2026
e94b2ea
[fix] #70: RandomPoseScreen ImageLoader remember 처리
ikseong00 Jan 29, 2026
d7a27e3
[feat] #70랜덤 포즈 조회 시 인원 수 필터링 기능 추가
ikseong00 Jan 31, 2026
9271d37
[refactor] #70: 포즈 스크랩 실패 시 스크랩 상태 롤백 로직 수정
ikseong00 Jan 31, 2026
6126a61
Merge branch 'develop' into feat/#70-pose-api
Ojongseok Feb 2, 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
2 changes: 1 addition & 1 deletion core/data-api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
@@ -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<PagingData<Pose>>

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

suspend fun getRandomPose(headCount: PeopleCount): Result<Pose>

suspend fun updateScrap(poseId: Long, scrap: Boolean): Result<Unit>
}
Original file line number Diff line number Diff line change
@@ -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<Int, Pose>() {

override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Pose> {
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, 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
@@ -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<PoseResponse> {
return client.get("/api/poses") {
parameter("page", page)
parameter("size", size)
parameter("headCount", headCount)
parameter("sortOrder", sortOrder)
}.body()
}

// 포즈 상세 조회
suspend fun getPose(poseId: Long): BasicResponse<PoseDetailResponse> {
return client.get("/api/poses/$poseId").body()
}

// 랜덤 포즈 조회
suspend fun getRandomPose(headCount: String): BasicResponse<PoseDetailResponse> {
return client.get("/api/poses/random") {
parameter("headCount", headCount)
}.body()
}

// 스크랩 업데이트
suspend fun updateScrap(poseId: Long, scrap: Boolean): BasicNullableResponse<Unit> {
return client.patch("/api/poses/$poseId/scrap") {
setBody(UpdateScrapRequest(scrap))
}.body()
}
}
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
@@ -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,
)
}
Original file line number Diff line number Diff line change
@@ -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<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,
)
}

fun toModels() = items.map { it.toModel() }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -73,4 +75,10 @@ internal interface RepositoryModule {
fun bindMapRepositoryImpl(
mapRepositoryImpl: MapRepositoryImpl,
): MapRepository

@Binds
@Singleton
fun bindPoseRepositoryImpl(
poseRepositoryImpl: PoseRepositoryImpl,
): PoseRepository
}
Original file line number Diff line number Diff line change
@@ -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<PagingData<Pose>> {
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<Pose> = runSuspendCatching {
poseService.getPose(poseId).data.toModel()
}

override suspend fun getRandomPose(headCount: PeopleCount): Result<Pose> = runSuspendCatching {
poseService.getRandomPose(headCount = headCount.name).data.toModel()
}

override suspend fun updateScrap(poseId: Long, scrap: Boolean): Result<Unit> = runSuspendCatching {
poseService.updateScrap(poseId, scrap)
}
Comment thread
ikseong00 marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Pose> = dummyPoseList,
val scrappedPoseList: ImmutableList<Pose> = scrappedDummyList,
val scrappedPoseList: ImmutableList<Pose> = persistentListOf(),
val isShowPeopleCountBottomSheet: Boolean = false,
val isShowRandomPosePeopleCountBottomSheet: Boolean = false,
)
Expand All @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ fun <T> DoubleButtonOptionBottomSheet(
onOptionSelect: (T) -> Unit,
modifier: Modifier = Modifier,
buttonEnabled: Boolean = true,
sheetState: SheetState = rememberModalBottomSheetState(),
sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
) {
ModalBottomSheet(
modifier = modifier,
Expand Down
Loading
Loading