diff --git a/app/src/main/java/com/runnect/runnect/data/dto/response/ResponseGetCourseRanking.kt b/app/src/main/java/com/runnect/runnect/data/dto/response/ResponseGetCourseRanking.kt new file mode 100644 index 00000000..fc190233 --- /dev/null +++ b/app/src/main/java/com/runnect/runnect/data/dto/response/ResponseGetCourseRanking.kt @@ -0,0 +1,44 @@ +package com.runnect.runnect.data.dto.response + +import com.runnect.runnect.domain.entity.CourseRanking +import com.runnect.runnect.domain.entity.CourseRankingEntry +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ResponseGetCourseRanking( + @SerialName("totalCount") + val totalCount: Long, + @SerialName("entries") + val entries: List +) { + @Serializable + data class Entry( + @SerialName("rank") + val rank: Int, + @SerialName("userId") + val userId: Long, + @SerialName("nickname") + val nickname: String, + @SerialName("recordId") + val recordId: Long, + @SerialName("time") + val time: String, + @SerialName("pace") + val pace: String, + ) + + fun toCourseRanking() = CourseRanking( + totalCount = totalCount.toInt(), + entries = entries.map { + CourseRankingEntry( + rank = it.rank, + userId = it.userId.toInt(), + nickname = it.nickname, + recordId = it.recordId.toInt(), + time = it.time, + pace = it.pace, + ) + } + ) +} diff --git a/app/src/main/java/com/runnect/runnect/data/dto/response/ResponseGetMyCourseRanking.kt b/app/src/main/java/com/runnect/runnect/data/dto/response/ResponseGetMyCourseRanking.kt new file mode 100644 index 00000000..a5b951d0 --- /dev/null +++ b/app/src/main/java/com/runnect/runnect/data/dto/response/ResponseGetMyCourseRanking.kt @@ -0,0 +1,32 @@ +package com.runnect.runnect.data.dto.response + +import com.runnect.runnect.domain.entity.MyCourseRanking +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ResponseGetMyCourseRanking( + @SerialName("hasRecord") + val hasRecord: Boolean, + @SerialName("rank") + val rank: Int? = null, + @SerialName("userId") + val userId: Long, + @SerialName("nickname") + val nickname: String? = null, + @SerialName("recordId") + val recordId: Long? = null, + @SerialName("time") + val time: String? = null, + @SerialName("pace") + val pace: String? = null, +) { + fun toMyCourseRanking() = MyCourseRanking( + hasRecord = hasRecord, + rank = rank, + userId = userId.toInt(), + nickname = nickname, + time = time, + pace = pace, + ) +} diff --git a/app/src/main/java/com/runnect/runnect/data/repository/CourseRepositoryImpl.kt b/app/src/main/java/com/runnect/runnect/data/repository/CourseRepositoryImpl.kt index dd2dacf6..c6e0a134 100644 --- a/app/src/main/java/com/runnect/runnect/data/repository/CourseRepositoryImpl.kt +++ b/app/src/main/java/com/runnect/runnect/data/repository/CourseRepositoryImpl.kt @@ -9,11 +9,13 @@ import com.runnect.runnect.data.dto.request.RequestPutMyDrawCourse import com.runnect.runnect.data.network.mapToFlowResult import com.runnect.runnect.data.source.remote.RemoteCourseDataSource import com.runnect.runnect.domain.entity.CourseDetail +import com.runnect.runnect.domain.entity.CourseRanking import com.runnect.runnect.domain.entity.DiscoverMultiViewItem.MarathonCourse import com.runnect.runnect.domain.entity.DiscoverSearchCourse import com.runnect.runnect.domain.entity.DiscoverUploadCourse import com.runnect.runnect.domain.entity.EditableCourseDetail import com.runnect.runnect.domain.entity.EditableMyDrawCourseDetail +import com.runnect.runnect.domain.entity.MyCourseRanking import com.runnect.runnect.domain.entity.MyDrawCourseDetail import com.runnect.runnect.domain.entity.PostScrap import com.runnect.runnect.domain.entity.RecommendCoursePagingData @@ -55,6 +57,16 @@ class CourseRepositoryImpl @Inject constructor( it.toCourseDetail() } + override suspend fun getCourseRanking(courseId: Int, limit: Int): Flow> = + remoteCourseDataSource.getCourseRanking(courseId = courseId, limit = limit).mapToFlowResult { + it.toCourseRanking() + } + + override suspend fun getMyCourseRanking(courseId: Int): Flow> = + remoteCourseDataSource.getMyCourseRanking(courseId = courseId).mapToFlowResult { + it.toMyCourseRanking() + } + override suspend fun getMyCourseLoad(): Flow>> { return remoteCourseDataSource.getMyCourseLoad().mapToFlowResult { it.toUploadCourses() diff --git a/app/src/main/java/com/runnect/runnect/data/service/CourseService.kt b/app/src/main/java/com/runnect/runnect/data/service/CourseService.kt index 47cf2d54..58f18705 100644 --- a/app/src/main/java/com/runnect/runnect/data/service/CourseService.kt +++ b/app/src/main/java/com/runnect/runnect/data/service/CourseService.kt @@ -7,10 +7,12 @@ import com.runnect.runnect.data.dto.request.RequestPostPublicCourse import com.runnect.runnect.data.dto.request.RequestPostRunningHistory import com.runnect.runnect.data.dto.request.RequestPutMyDrawCourse import com.runnect.runnect.data.dto.response.ResponseGetCourseDetail +import com.runnect.runnect.data.dto.response.ResponseGetCourseRanking import com.runnect.runnect.data.dto.response.ResponseGetDiscoverMarathon import com.runnect.runnect.data.dto.response.ResponseGetDiscoverRecommend import com.runnect.runnect.data.dto.response.ResponseGetDiscoverSearch import com.runnect.runnect.data.dto.response.ResponseGetDiscoverUploadCourse +import com.runnect.runnect.data.dto.response.ResponseGetMyCourseRanking import com.runnect.runnect.data.dto.response.ResponseGetMyDrawCourse import com.runnect.runnect.data.dto.response.ResponseGetMyDrawDetail import com.runnect.runnect.data.dto.response.ResponseGetMyScrapCourse @@ -49,6 +51,17 @@ interface CourseService { @GET("/api/public-course/marathon") suspend fun getMarathonCourse(): Result + @GET("/api/course/{courseId}/ranking") + suspend fun getCourseRanking( + @Path("courseId") courseId: Int, + @Query("limit") limit: Int, + ): Result + + @GET("/api/course/{courseId}/ranking/me") + suspend fun getMyCourseRanking( + @Path("courseId") courseId: Int, + ): Result + @GET("/api/public-course") suspend fun getRecommendCourse( @Query("pageNo") pageNo: String, diff --git a/app/src/main/java/com/runnect/runnect/data/source/remote/RemoteCourseDataSource.kt b/app/src/main/java/com/runnect/runnect/data/source/remote/RemoteCourseDataSource.kt index 81fcd5dc..1465adb2 100644 --- a/app/src/main/java/com/runnect/runnect/data/source/remote/RemoteCourseDataSource.kt +++ b/app/src/main/java/com/runnect/runnect/data/source/remote/RemoteCourseDataSource.kt @@ -7,8 +7,10 @@ import com.runnect.runnect.data.dto.request.RequestPostPublicCourse import com.runnect.runnect.data.dto.request.RequestPostRunningHistory import com.runnect.runnect.data.dto.request.RequestPutMyDrawCourse import com.runnect.runnect.data.dto.response.ResponseGetCourseDetail +import com.runnect.runnect.data.dto.response.ResponseGetCourseRanking import com.runnect.runnect.data.dto.response.ResponseGetDiscoverMarathon import com.runnect.runnect.data.dto.response.ResponseGetDiscoverRecommend +import com.runnect.runnect.data.dto.response.ResponseGetMyCourseRanking import com.runnect.runnect.data.dto.response.ResponsePatchMyDrawCourseTitle import com.runnect.runnect.data.dto.response.ResponsePatchPublicCourse import com.runnect.runnect.data.dto.response.ResponsePostScrap @@ -37,6 +39,12 @@ class RemoteCourseDataSource @Inject constructor( suspend fun getCourseDetail(publicCourseId: Int): Result = courseService.getCourseDetail(publicCourseId) + suspend fun getCourseRanking(courseId: Int, limit: Int): Result = + courseService.getCourseRanking(courseId, limit) + + suspend fun getMyCourseRanking(courseId: Int): Result = + courseService.getMyCourseRanking(courseId) + suspend fun getMyCourseLoad() = courseService.getMyCourseLoad() suspend fun postUploadMyCourse(requestPostPublicCourse: RequestPostPublicCourse) = diff --git a/app/src/main/java/com/runnect/runnect/domain/entity/CourseRanking.kt b/app/src/main/java/com/runnect/runnect/domain/entity/CourseRanking.kt new file mode 100644 index 00000000..3f1ec94a --- /dev/null +++ b/app/src/main/java/com/runnect/runnect/domain/entity/CourseRanking.kt @@ -0,0 +1,24 @@ +package com.runnect.runnect.domain.entity + +data class CourseRankingEntry( + val rank: Int, + val userId: Int, + val nickname: String, + val recordId: Int, + val time: String, + val pace: String, +) + +data class CourseRanking( + val totalCount: Int, + val entries: List, +) + +data class MyCourseRanking( + val hasRecord: Boolean, + val rank: Int?, + val userId: Int, + val nickname: String?, + val time: String?, + val pace: String?, +) diff --git a/app/src/main/java/com/runnect/runnect/domain/repository/CourseRepository.kt b/app/src/main/java/com/runnect/runnect/domain/repository/CourseRepository.kt index 7a013361..9ce5a4fb 100644 --- a/app/src/main/java/com/runnect/runnect/domain/repository/CourseRepository.kt +++ b/app/src/main/java/com/runnect/runnect/domain/repository/CourseRepository.kt @@ -7,11 +7,13 @@ import com.runnect.runnect.data.dto.request.RequestPostPublicCourse import com.runnect.runnect.data.dto.request.RequestPostRunningHistory import com.runnect.runnect.data.dto.request.RequestPutMyDrawCourse import com.runnect.runnect.domain.entity.CourseDetail +import com.runnect.runnect.domain.entity.CourseRanking import com.runnect.runnect.domain.entity.DiscoverMultiViewItem.MarathonCourse import com.runnect.runnect.domain.entity.DiscoverSearchCourse import com.runnect.runnect.domain.entity.DiscoverUploadCourse import com.runnect.runnect.domain.entity.EditableCourseDetail import com.runnect.runnect.domain.entity.EditableMyDrawCourseDetail +import com.runnect.runnect.domain.entity.MyCourseRanking import com.runnect.runnect.domain.entity.MyDrawCourseDetail import com.runnect.runnect.domain.entity.PostScrap import com.runnect.runnect.domain.entity.RecommendCoursePagingData @@ -32,6 +34,10 @@ interface CourseRepository { suspend fun getCourseDetail(publicCourseId: Int): Flow> + suspend fun getCourseRanking(courseId: Int, limit: Int): Flow> + + suspend fun getMyCourseRanking(courseId: Int): Flow> + suspend fun getMyCourseLoad(): Flow>> suspend fun getMyDrawDetail(courseId: Int): Flow> diff --git a/app/src/main/java/com/runnect/runnect/presentation/detail/CourseDetailActivity.kt b/app/src/main/java/com/runnect/runnect/presentation/detail/CourseDetailActivity.kt index c51a9de9..d76d77ce 100644 --- a/app/src/main/java/com/runnect/runnect/presentation/detail/CourseDetailActivity.kt +++ b/app/src/main/java/com/runnect/runnect/presentation/detail/CourseDetailActivity.kt @@ -9,6 +9,9 @@ import android.view.View import android.widget.EditText import androidx.activity.OnBackPressedCallback import androidx.activity.viewModels +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope import coil3.load @@ -32,9 +35,11 @@ import com.runnect.runnect.presentation.discover.DiscoverFragment.Companion.EXTR import com.runnect.runnect.presentation.discover.model.EditableDiscoverCourse import com.runnect.runnect.presentation.discover.search.DiscoverSearchActivity import com.runnect.runnect.presentation.mypage.upload.MyUploadActivity +import com.runnect.runnect.presentation.detail.ranking.RecordRankingSection import com.runnect.runnect.presentation.profile.ProfileActivity import com.runnect.runnect.presentation.scheme.SchemeActivity import com.runnect.runnect.presentation.state.UiStateV2 +import com.runnect.runnect.presentation.ui.theme.RunnectTheme import com.runnect.runnect.util.analytics.Analytics import com.runnect.runnect.util.analytics.EventName import com.runnect.runnect.util.analytics.EventName.EVENT_CLICK_SHARE @@ -92,12 +97,34 @@ class CourseDetailActivity : initCourseIdExtra() initRootScreenExtra() + initRankingComposeView() getCourseDetail() addListener() addObserver() registerBackPressedCallback() } + private fun initRankingComposeView() { + binding.composeCourseDetailRanking.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + RunnectTheme { + val rankingState by viewModel.courseRankingState.observeAsState() + val myRankingState by viewModel.myCourseRankingState.observeAsState() + + val ranking = (rankingState as? UiStateV2.Success)?.data + val myRanking = (myRankingState as? UiStateV2.Success)?.data + + RecordRankingSection( + ranking = ranking, + myRanking = myRanking, + onUserClick = { userId -> navigateToUserProfile(userId) }, + ) + } + } + } + } + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) intent?.let { newIntent -> @@ -133,6 +160,13 @@ class CourseDetailActivity : viewModel.getCourseDetail(publicCourseId) } + private fun getCourseRanking() { + viewModel.getCourseRanking(publicCourseId) + if (!isVisitorMode) { + viewModel.getMyCourseRanking(publicCourseId) + } + } + private fun addListener() { initScrapButtonClickListener() initStartRunButtonClickListener() @@ -220,7 +254,7 @@ class CourseDetailActivity : private fun initUserInfoClickListener() { binding.constCourseDetailUserInfo.setOnClickListener { if (courseDetail.userId != -1) { - navigateToUserProfile() + navigateToUserProfile(courseDetail.userId) } } } @@ -407,9 +441,9 @@ class CourseDetailActivity : applyScreenExitAnimation() } - private fun navigateToUserProfile() { + private fun navigateToUserProfile(userId: Int) { Intent(this@CourseDetailActivity, ProfileActivity::class.java).apply { - putExtra(EXTRA_COURSE_USER_ID, courseDetail.userId) + putExtra(EXTRA_COURSE_USER_ID, userId) addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT) startActivity(this) } @@ -436,6 +470,8 @@ class CourseDetailActivity : initDepartureLatLng() initConnectedSpots() + + getCourseRanking() } is UiStateV2.Failure -> { diff --git a/app/src/main/java/com/runnect/runnect/presentation/detail/CourseDetailViewModel.kt b/app/src/main/java/com/runnect/runnect/presentation/detail/CourseDetailViewModel.kt index 1a8d3293..a171cc2f 100644 --- a/app/src/main/java/com/runnect/runnect/presentation/detail/CourseDetailViewModel.kt +++ b/app/src/main/java/com/runnect/runnect/presentation/detail/CourseDetailViewModel.kt @@ -9,7 +9,9 @@ import com.runnect.runnect.data.dto.request.RequestPostCourseScrap import com.runnect.runnect.data.dto.response.ResponseDeleteUploadCourse import com.runnect.runnect.domain.common.toLog import com.runnect.runnect.domain.entity.CourseDetail +import com.runnect.runnect.domain.entity.CourseRanking import com.runnect.runnect.domain.entity.EditableCourseDetail +import com.runnect.runnect.domain.entity.MyCourseRanking import com.runnect.runnect.domain.entity.PostScrap import com.runnect.runnect.domain.repository.CourseRepository import com.runnect.runnect.domain.repository.UserRepository @@ -42,6 +44,14 @@ class CourseDetailViewModel @Inject constructor( val courseScrapState: LiveData> get() = _courseScrapState + private val _courseRankingState = MutableLiveData>() + val courseRankingState: LiveData> + get() = _courseRankingState + + private val _myCourseRankingState = MutableLiveData>() + val myCourseRankingState: LiveData> + get() = _myCourseRankingState + // 사용자가 수정할 수 있는 부분 (제목, 내용) val _title = MutableLiveData() val title: String get() = _title.value ?: "" @@ -58,6 +68,11 @@ class CourseDetailViewModel @Inject constructor( private var savedCourseDetail = EditableCourseDetail("", "") + // CourseDetailActivity가 FLAG_ACTIVITY_REORDER_TO_FRONT로 재사용될 때(ProfileActivity 등) + // onNewIntent로 다른 courseId가 들어올 수 있다. 이전 courseId 요청이 늦게 응답으로 돌아와 + // 최신 courseId의 결과를 덮어쓰지 않도록, 응답을 반영하기 전에 여전히 최신 요청인지 확인한다. + private var latestRankingCourseId: Int? = null + fun updateCourseDetailEditText(course: EditableCourseDetail) { _title.value = course.title _description.value = course.description @@ -140,7 +155,46 @@ class CourseDetailViewModel @Inject constructor( ) } + fun getCourseRanking(courseId: Int) = launchWithHandler { + latestRankingCourseId = courseId + _courseRankingState.value = UiStateV2.Loading + + courseRepository.getCourseRanking(courseId = courseId, limit = RANKING_LIST_LIMIT) + .collectResult( + onSuccess = { + if (latestRankingCourseId == courseId) { + _courseRankingState.value = UiStateV2.Success(it) + } + }, + onFailure = { + if (latestRankingCourseId == courseId) { + _courseRankingState.value = UiStateV2.Failure(it.toLog()) + } + } + ) + } + + fun getMyCourseRanking(courseId: Int) = launchWithHandler { + latestRankingCourseId = courseId + _myCourseRankingState.value = UiStateV2.Loading + + courseRepository.getMyCourseRanking(courseId = courseId) + .collectResult( + onSuccess = { + if (latestRankingCourseId == courseId) { + _myCourseRankingState.value = UiStateV2.Success(it) + } + }, + onFailure = { + if (latestRankingCourseId == courseId) { + _myCourseRankingState.value = UiStateV2.Failure(it.toLog()) + } + } + ) + } + companion object { private const val CODE_AUTHORIZATION_ERROR = 401 + private const val RANKING_LIST_LIMIT = 10 } } \ No newline at end of file diff --git a/app/src/main/java/com/runnect/runnect/presentation/detail/ranking/RecordRankingSection.kt b/app/src/main/java/com/runnect/runnect/presentation/detail/ranking/RecordRankingSection.kt new file mode 100644 index 00000000..f8c37cda --- /dev/null +++ b/app/src/main/java/com/runnect/runnect/presentation/detail/ranking/RecordRankingSection.kt @@ -0,0 +1,313 @@ +package com.runnect.runnect.presentation.detail.ranking + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +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.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.runnect.runnect.domain.entity.CourseRanking +import com.runnect.runnect.domain.entity.CourseRankingEntry +import com.runnect.runnect.domain.entity.MyCourseRanking +import com.runnect.runnect.presentation.ui.theme.G1 +import com.runnect.runnect.presentation.ui.theme.G2 +import com.runnect.runnect.presentation.ui.theme.G4 +import com.runnect.runnect.presentation.ui.theme.G5 +import com.runnect.runnect.presentation.ui.theme.M1 +import com.runnect.runnect.presentation.ui.theme.M3 +import com.runnect.runnect.presentation.ui.theme.PretendardFontFamily +import com.runnect.runnect.presentation.ui.theme.RunnectTheme + +private val GoldBg = Color(0xFFFBEFD4) +private val Gold = Color(0xFFC9971F) +private val SilverBg = Color(0xFFEEEFF1) +private val Silver = Color(0xFF8A8F98) +private val BronzeBg = Color(0xFFF3E3D3) +private val Bronze = Color(0xFFB0703B) + +/** + * 코스 상세 화면에 추가되는 기록 랭킹 섹션. + * ranking이 null이면(아직 응답 오기 전) 아무것도 그리지 않아 로딩 중 깜빡임을 피한다. + * 응답이 왔는데 완주자가 0명이면 섹션은 그대로 두고 목록 대신 빈 상태 안내를 보여준다. + */ +@Composable +fun RecordRankingSection( + ranking: CourseRanking?, + myRanking: MyCourseRanking?, + onUserClick: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + if (ranking == null) return + + Column(modifier = modifier.fillMaxWidth()) { + Spacer(modifier = Modifier.height(20.dp)) + Box( + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .background(G5) + ) + + Column(modifier = Modifier.padding(horizontal = 15.dp, vertical = 18.dp)) { + Text( + text = "🏅 이 코스 기록 랭킹", + fontFamily = PretendardFontFamily, + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + color = G1, + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = "완주자 ${ranking.totalCount}명", + fontFamily = PretendardFontFamily, + fontWeight = FontWeight.Normal, + fontSize = 12.5.sp, + color = G2, + ) + Spacer(modifier = Modifier.height(12.dp)) + + if (ranking.entries.isEmpty()) { + EmptyRankingState() + } else { + ranking.entries.forEach { entry -> + RankingRow(entry, onClick = { onUserClick(entry.userId) }) + } + + if (myRanking != null && myRanking.hasRecord) { + Spacer(modifier = Modifier.height(6.dp)) + MyRankingRow(myRanking, onClick = { onUserClick(myRanking.userId) }) + } + } + } + } +} + +@Composable +private fun EmptyRankingState() { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(text = "🏁", fontSize = 26.sp) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "아직 완주 기록이 없어요", + fontFamily = PretendardFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 13.5.sp, + color = G1, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "가장 먼저 완주하고 1위에 도전해보세요", + fontFamily = PretendardFontFamily, + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + color = G2, + ) + } +} + +@Composable +private fun RankingRow(entry: CourseRankingEntry, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RankBadge(rank = entry.rank) + Spacer(modifier = Modifier.width(10.dp)) + Box( + modifier = Modifier + .size(22.dp) + .clip(CircleShape) + .background(G4) + ) + Spacer(modifier = Modifier.width(10.dp)) + Text( + text = entry.nickname, + fontFamily = PretendardFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 13.5.sp, + color = G1, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + RankingNumbers(time = entry.time, pace = entry.pace, color = G1) + } +} + +@Composable +private fun MyRankingRow(myRanking: MyCourseRanking, onClick: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(M3) + .clickable(onClick = onClick) + .padding(horizontal = 10.dp, vertical = 9.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + myRanking.rank?.let { + Box( + modifier = Modifier.size(24.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = "$it", + fontFamily = PretendardFontFamily, + fontWeight = FontWeight.Bold, + fontSize = 11.5.sp, + color = M1, + ) + } + } + Spacer(modifier = Modifier.width(10.dp)) + Box( + modifier = Modifier + .size(22.dp) + .clip(CircleShape) + .background(G4) + ) + Spacer(modifier = Modifier.width(10.dp)) + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "나", + fontFamily = PretendardFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 13.5.sp, + color = M1, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.width(6.dp)) + Box( + modifier = Modifier + .clip(RoundedCornerShape(100.dp)) + .background(M1) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) { + Text( + text = "PB", + fontFamily = PretendardFontFamily, + fontWeight = FontWeight.Bold, + fontSize = 9.5.sp, + color = Color.White, + ) + } + } + RankingNumbers(time = myRanking.time.orEmpty(), pace = myRanking.pace.orEmpty(), color = M1) + } +} + +@Composable +private fun RankingNumbers(time: String, pace: String, color: Color) { + Column(horizontalAlignment = Alignment.End) { + Text( + text = time, + fontFamily = PretendardFontFamily, + fontWeight = FontWeight.Bold, + fontSize = 13.5.sp, + color = color, + ) + Text( + text = pace, + fontFamily = PretendardFontFamily, + fontWeight = FontWeight.Normal, + fontSize = 10.5.sp, + color = G2, + ) + } +} + +@Composable +private fun RankBadge(rank: Int) { + val (bg, fg) = when (rank) { + 1 -> GoldBg to Gold + 2 -> SilverBg to Silver + 3 -> BronzeBg to Bronze + else -> G4 to G2 + } + + Box( + modifier = Modifier + .size(24.dp) + .clip(CircleShape) + .background(bg), + contentAlignment = Alignment.Center, + ) { + Text( + text = "$rank", + fontFamily = PretendardFontFamily, + fontWeight = FontWeight.Bold, + fontSize = 11.5.sp, + color = fg, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun RecordRankingSectionPreview() { + RunnectTheme { + RecordRankingSection( + ranking = CourseRanking( + totalCount = 128, + entries = listOf( + CourseRankingEntry(1, 1, "런너_지훈", 1, "11:24", "4'58\"/km"), + CourseRankingEntry(2, 2, "soo_running", 2, "11:47", "5'07\"/km"), + CourseRankingEntry(3, 3, "이번엔완주", 3, "12:02", "5'14\"/km"), + CourseRankingEntry(4, 4, "한강러너", 4, "12:31", "5'26\"/km"), + ) + ), + myRanking = MyCourseRanking( + hasRecord = true, + rank = 14, + userId = 57, + nickname = "말랑콩떡", + time = "14:52", + pace = "6'28\"/km", + ), + onUserClick = {}, + ) + } +} + +@Preview(showBackground = true) +@Composable +private fun RecordRankingSectionEmptyPreview() { + RunnectTheme { + RecordRankingSection( + ranking = CourseRanking(totalCount = 0, entries = emptyList()), + myRanking = null, + onUserClick = {}, + ) + } +} diff --git a/app/src/main/res/layout/activity_course_detail.xml b/app/src/main/res/layout/activity_course_detail.xml index 5372cf3c..6fadce2e 100644 --- a/app/src/main/res/layout/activity_course_detail.xml +++ b/app/src/main/res/layout/activity_course_detail.xml @@ -275,16 +275,27 @@ android:layout_height="wrap_content" android:layout_marginHorizontal="16dp" android:layout_marginTop="20dp" + android:layout_marginBottom="20dp" android:fontFamily="@font/pretendard_regular" android:lineHeight="24dp" android:maxLength="150" android:text="@{courseDetail.description}" android:textColor="@color/G1" android:textSize="14sp" + app:layout_constraintBottom_toTopOf="@id/compose_course_detail_ranking" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/view_course_detail_line" + app:layout_constraintVertical_bias="0" tools:text="석촌 호수 한 바퀴 뛰는 코스에요! 평탄한 길과 느린 페이스,난이도 하 코스입니다! 롯데월드 야경 감상 하면서 뛰기에 좋은 야간 코스에요! 석촌 호수 한 바퀴 뛰는 코스에요! 평탄한 길과 느린 페이스, 난이도 하 코스입니다! 롯데월드 야경 감상 하면서 뛰기에 좋은 야간 코스에요!" /> + + @@ -300,6 +311,7 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginBottom="18dp" + android:background="@color/W1" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent"> diff --git a/app/src/test/java/com/runnect/runnect/presentation/detail/CourseDetailViewModelTest.kt b/app/src/test/java/com/runnect/runnect/presentation/detail/CourseDetailViewModelTest.kt new file mode 100644 index 00000000..f4662050 --- /dev/null +++ b/app/src/test/java/com/runnect/runnect/presentation/detail/CourseDetailViewModelTest.kt @@ -0,0 +1,156 @@ +package com.runnect.runnect.presentation.detail + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.runnect.runnect.domain.entity.CourseRanking +import com.runnect.runnect.domain.entity.CourseRankingEntry +import com.runnect.runnect.domain.entity.MyCourseRanking +import com.runnect.runnect.domain.repository.CourseRepository +import com.runnect.runnect.domain.repository.UserRepository +import com.runnect.runnect.presentation.state.UiStateV2 +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class CourseDetailViewModelTest { + + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + private val testDispatcher = StandardTestDispatcher() + + private lateinit var courseRepository: CourseRepository + private lateinit var userRepository: UserRepository + private lateinit var viewModel: CourseDetailViewModel + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + courseRepository = mockk() + userRepository = mockk() + viewModel = CourseDetailViewModel(courseRepository, userRepository) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `getCourseRanking 성공 시 랭킹 목록으로 상태가 갱신된다`() = runTest(testDispatcher) { + val ranking = CourseRanking( + totalCount = 2, + entries = listOf( + CourseRankingEntry(1, 1, "런너A", 100, "11:24", "4'58\"/km"), + CourseRankingEntry(2, 2, "런너B", 101, "11:47", "5'07\"/km"), + ) + ) + coEvery { courseRepository.getCourseRanking(courseId = 1, limit = 10) } returns flow { + delay(1) + emit(Result.success(ranking)) + } + + viewModel.getCourseRanking(1) + advanceUntilIdle() + + assertEquals(UiStateV2.Success(ranking), viewModel.courseRankingState.value) + } + + @Test + fun `getCourseRanking 실패 시 Failure 상태로 갱신된다`() = runTest(testDispatcher) { + coEvery { courseRepository.getCourseRanking(courseId = 1, limit = 10) } returns flow { + delay(1) + emit(Result.failure(RuntimeException("네트워크 오류"))) + } + + viewModel.getCourseRanking(1) + advanceUntilIdle() + + assertTrue(viewModel.courseRankingState.value is UiStateV2.Failure) + } + + @Test + fun `이전 코스의 늦은 응답이 나중에 요청한 코스의 랭킹을 덮어쓰지 않는다`() = runTest(testDispatcher) { + val rankingForCourse1 = CourseRanking( + totalCount = 1, + entries = listOf(CourseRankingEntry(1, 1, "코스1런너", 100, "10:00", "5'00\"/km")) + ) + val rankingForCourse2 = CourseRanking( + totalCount = 1, + entries = listOf(CourseRankingEntry(1, 2, "코스2런너", 200, "20:00", "8'00\"/km")) + ) + // CourseDetailActivity가 FLAG_ACTIVITY_REORDER_TO_FRONT로 재사용될 때(ProfileActivity 등) + // 이전 코스(1)의 요청이 늦게 응답할 수 있는 상황을 재현: 코스1은 느리게, 코스2는 빠르게 응답. + coEvery { courseRepository.getCourseRanking(courseId = 1, limit = 10) } returns flow { + delay(100) + emit(Result.success(rankingForCourse1)) + } + coEvery { courseRepository.getCourseRanking(courseId = 2, limit = 10) } returns flow { + delay(1) + emit(Result.success(rankingForCourse2)) + } + + viewModel.getCourseRanking(1) + viewModel.getCourseRanking(2) + advanceUntilIdle() + + assertEquals(UiStateV2.Success(rankingForCourse2), viewModel.courseRankingState.value) + } + + @Test + fun `getMyCourseRanking 성공 시 내 랭킹 상태가 갱신된다`() = runTest(testDispatcher) { + val myRanking = MyCourseRanking( + hasRecord = true, + rank = 14, + userId = 57, + nickname = "나", + time = "14:52", + pace = "6'28\"/km" + ) + coEvery { courseRepository.getMyCourseRanking(courseId = 1) } returns flow { + delay(1) + emit(Result.success(myRanking)) + } + + viewModel.getMyCourseRanking(1) + advanceUntilIdle() + + assertEquals(UiStateV2.Success(myRanking), viewModel.myCourseRankingState.value) + } + + @Test + fun `기록이 없는 유저는 hasRecord=false 상태를 성공으로 받는다`() = runTest(testDispatcher) { + val myRanking = MyCourseRanking( + hasRecord = false, + rank = null, + userId = 57, + nickname = null, + time = null, + pace = null + ) + coEvery { courseRepository.getMyCourseRanking(courseId = 1) } returns flow { + delay(1) + emit(Result.success(myRanking)) + } + + viewModel.getMyCourseRanking(1) + advanceUntilIdle() + + val state = viewModel.myCourseRankingState.value as UiStateV2.Success + assertEquals(false, state.data.hasRecord) + } +}