diff --git a/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/datasource/AppjamtampDataSource.kt b/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/datasource/AppjamtampDataSource.kt index ac9560daf..680b550d5 100644 --- a/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/datasource/AppjamtampDataSource.kt +++ b/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/datasource/AppjamtampDataSource.kt @@ -1,5 +1,7 @@ package org.sopt.official.data.appjamtamp.datasource +import org.sopt.official.data.appjamtamp.dto.response.AppjamtampTop10MissionScoreResponse +import org.sopt.official.data.appjamtamp.dto.response.AppjamtampTop3RecentMissionResponse import org.sopt.official.data.appjamtamp.dto.AppjamtampMissionsResponseDto interface AppjamtampDataSource { @@ -7,4 +9,6 @@ interface AppjamtampDataSource { teamNumber: String? = null, isCompleted: Boolean? = null ): AppjamtampMissionsResponseDto + suspend fun getAppjamtampMissionTop3(size: Int): AppjamtampTop3RecentMissionResponse + suspend fun getAppjamtampMissionRanking(size: Int): AppjamtampTop10MissionScoreResponse } \ No newline at end of file diff --git a/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/datasourceimpl/AppjamtampDataSourceImpl.kt b/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/datasourceimpl/AppjamtampDataSourceImpl.kt index 67721cf98..8cd6bd2e9 100644 --- a/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/datasourceimpl/AppjamtampDataSourceImpl.kt +++ b/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/datasourceimpl/AppjamtampDataSourceImpl.kt @@ -1,6 +1,8 @@ package org.sopt.official.data.appjamtamp.datasourceimpl import org.sopt.official.data.appjamtamp.datasource.AppjamtampDataSource +import org.sopt.official.data.appjamtamp.dto.response.AppjamtampTop10MissionScoreResponse +import org.sopt.official.data.appjamtamp.dto.response.AppjamtampTop3RecentMissionResponse import org.sopt.official.data.appjamtamp.dto.AppjamtampMissionsResponseDto import org.sopt.official.data.appjamtamp.service.AppjamtampService import javax.inject.Inject @@ -13,4 +15,10 @@ internal class AppjamtampDataSourceImpl @Inject constructor( isCompleted: Boolean? ): AppjamtampMissionsResponseDto = appjamtampService.getAppjamtampMissions(teamNumber, isCompleted) -} \ No newline at end of file + + override suspend fun getAppjamtampMissionTop3(size: Int): AppjamtampTop3RecentMissionResponse = + appjamtampService.getAppjamtampMissionTop3(size = size) + + override suspend fun getAppjamtampMissionRanking(size: Int): AppjamtampTop10MissionScoreResponse = + appjamtampService.getAppjamtampMissionRanking(size = size) +} diff --git a/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/di/RepositoryModule.kt b/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/di/RepositoryModule.kt index c1d1cde2a..1d6a9560a 100644 --- a/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/di/RepositoryModule.kt +++ b/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/di/RepositoryModule.kt @@ -16,5 +16,4 @@ internal abstract class RepositoryModule { abstract fun bindAppjamtampRepository( appjamtampRepositoryImpl: AppjamtampRepositoryImpl ): AppjamtampRepository - -} \ No newline at end of file +} diff --git a/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/dto/AppjamtampMissionsResponseDto.kt b/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/dto/AppjamtampMissionsResponseDto.kt index 772acfb3c..073897aef 100644 --- a/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/dto/AppjamtampMissionsResponseDto.kt +++ b/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/dto/AppjamtampMissionsResponseDto.kt @@ -3,6 +3,7 @@ package org.sopt.official.data.appjamtamp.dto import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import org.sopt.official.domain.appjamtamp.entity.AppjamtampMissionEntity +import org.sopt.official.domain.appjamtamp.entity.AppjamtampMissionListEntity @Serializable data class AppjamtampMissionsResponseDto( @@ -12,7 +13,15 @@ data class AppjamtampMissionsResponseDto( val teamName: String, @SerialName("missions") val missions: List -) +) { + fun toEntity(): AppjamtampMissionListEntity { + return AppjamtampMissionListEntity( + teamNumber = teamNumber, + teamName = teamName, + missions = missions.map { it.toEntity() } + ) + } +} @Serializable data class AppjamtampMissionItemDto( @@ -21,11 +30,11 @@ data class AppjamtampMissionItemDto( @SerialName("title") val title: String, @SerialName("ownerName") - val ownerName: String, + val ownerName: String? = null, @SerialName("level") val level: Int, @SerialName("profileImage") - val profileImage: List, + val profileImage: List? = emptyList(), @SerialName("isCompleted") val isCompleted: Boolean ) { diff --git a/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/dto/response/AppjamtampTop10MissionScoreResponse.kt b/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/dto/response/AppjamtampTop10MissionScoreResponse.kt new file mode 100644 index 000000000..4df7ee5b0 --- /dev/null +++ b/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/dto/response/AppjamtampTop10MissionScoreResponse.kt @@ -0,0 +1,25 @@ +package org.sopt.official.data.appjamtamp.dto.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class AppjamtampTop10MissionScoreResponse( + @SerialName("ranks") + val ranks: List +) + +@Serializable +data class AppjamtampMissionScoreResponse( + @SerialName("rank") + val rank: Int, + + @SerialName("teamName") + val teamName: String, + + @SerialName("todayPoints") + val todayPoints: Int, + + @SerialName("totalPoints") + val totalPoints: Int +) diff --git a/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/dto/response/AppjamtampTop3RecentMissionResponse.kt b/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/dto/response/AppjamtampTop3RecentMissionResponse.kt new file mode 100644 index 000000000..d59656409 --- /dev/null +++ b/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/dto/response/AppjamtampTop3RecentMissionResponse.kt @@ -0,0 +1,40 @@ +package org.sopt.official.data.appjamtamp.dto.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class AppjamtampTop3RecentMissionResponse( + @SerialName("ranks") + val ranks: List +) + +@Serializable +data class AppjamtampRecentMissionResponse( + @SerialName("stampId") + val stampId: Long, + + @SerialName("missionId") + val missionId: Long, + + @SerialName("userId") + val userId: Long, + + @SerialName("imageUrl") + val imageUrl: String, + + @SerialName("createdAt") + val createdAt: String?, + + @SerialName("userName") + val userName: String, + + @SerialName("userProfileImage") + val userProfileImage: String?, + + @SerialName("teamName") + val teamName: String, + + @SerialName("teamNumber") + val teamNumber: String +) \ No newline at end of file diff --git a/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/mapper/AppjamptampMapper.kt b/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/mapper/AppjamptampMapper.kt new file mode 100644 index 000000000..4e1bafaf5 --- /dev/null +++ b/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/mapper/AppjamptampMapper.kt @@ -0,0 +1,41 @@ +package org.sopt.official.data.appjamtamp.mapper + +import org.sopt.official.data.appjamtamp.dto.response.AppjamtampMissionScoreResponse +import org.sopt.official.data.appjamtamp.dto.response.AppjamtampRecentMissionResponse +import org.sopt.official.data.appjamtamp.dto.response.AppjamtampTop3RecentMissionResponse +import org.sopt.official.data.appjamtamp.dto.response.AppjamtampTop10MissionScoreResponse +import org.sopt.official.domain.appjamtamp.entity.AppjamtampMissionScore +import org.sopt.official.domain.appjamtamp.entity.AppjamtampRecentMission + + +internal fun AppjamtampTop3RecentMissionResponse.toDomain(): List { + return this.ranks.map { it.toDomain() } +} + +internal fun AppjamtampRecentMissionResponse.toDomain(): AppjamtampRecentMission { + return AppjamtampRecentMission( + stampId = this.stampId, + missionId = this.missionId, + userId = this.userId, + imageUrl = this.imageUrl, + createdAt = this.createdAt, + userName = this.userName, + userProfileImage = this.userProfileImage, + teamName = this.teamName, + teamNumber = this.teamNumber + ) +} + +internal fun AppjamtampTop10MissionScoreResponse.toDomain(): List { + return this.ranks.map { it.toDomain() } +} + +internal fun AppjamtampMissionScoreResponse.toDomain(): AppjamtampMissionScore { + return AppjamtampMissionScore( + rank = this.rank, + teamName = this.teamName, + todayPoints = this.todayPoints, + totalPoints = this.todayPoints + ) +} + diff --git a/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/repository/AppjamtampRepositoryImpl.kt b/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/repository/AppjamtampRepositoryImpl.kt index 4f0c91947..a16c1d060 100644 --- a/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/repository/AppjamtampRepositoryImpl.kt +++ b/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/repository/AppjamtampRepositoryImpl.kt @@ -2,7 +2,10 @@ package org.sopt.official.data.appjamtamp.repository import org.sopt.official.common.coroutines.suspendRunCatching import org.sopt.official.data.appjamtamp.datasource.AppjamtampDataSource -import org.sopt.official.domain.appjamtamp.entity.AppjamtampMissionEntity +import org.sopt.official.data.appjamtamp.mapper.toDomain +import org.sopt.official.domain.appjamtamp.entity.AppjamtampMissionListEntity +import org.sopt.official.domain.appjamtamp.entity.AppjamtampMissionScore +import org.sopt.official.domain.appjamtamp.entity.AppjamtampRecentMission import org.sopt.official.domain.appjamtamp.repository.AppjamtampRepository import javax.inject.Inject @@ -12,7 +15,19 @@ internal class AppjamtampRepositoryImpl @Inject constructor( override suspend fun getAppjamtampMissions( teamNumber: String?, isCompleted: Boolean? - ): Result> = suspendRunCatching { - appjamtampDataSource.getAppjamtampMissions(teamNumber, isCompleted).missions.map { it.toEntity() } + ): Result = suspendRunCatching { + appjamtampDataSource.getAppjamtampMissions(teamNumber, isCompleted).toEntity() } -} \ No newline at end of file + + override suspend fun getAppjamtampMissionRanking( + size: Int + ): Result> = suspendRunCatching { + appjamtampDataSource.getAppjamtampMissionRanking(size = size).toDomain() + } + + override suspend fun getAppjamtampMissionTop3( + size: Int + ): Result> = suspendRunCatching { + appjamtampDataSource.getAppjamtampMissionTop3(size = size).toDomain() + } +} diff --git a/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/service/AppjamtampService.kt b/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/service/AppjamtampService.kt index 2eb26b86f..d0e4b014d 100644 --- a/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/service/AppjamtampService.kt +++ b/data/appjamtamp/src/main/java/org/sopt/official/data/appjamtamp/service/AppjamtampService.kt @@ -1,6 +1,8 @@ package org.sopt.official.data.appjamtamp.service import org.sopt.official.data.appjamtamp.dto.AppjamtampMissionsResponseDto +import org.sopt.official.data.appjamtamp.dto.response.AppjamtampTop3RecentMissionResponse +import org.sopt.official.data.appjamtamp.dto.response.AppjamtampTop10MissionScoreResponse import retrofit2.http.GET import retrofit2.http.Query @@ -10,4 +12,20 @@ interface AppjamtampService { @Query("teamNumber") teamNumber: String? = null, @Query("isCompleted") isCompleted : Boolean? = null ) : AppjamtampMissionsResponseDto -} \ No newline at end of file + + /** + * 앱잼에 참여하는 전체 팀의 득점 랭킹 조회 + * - 서버 명세에서 API명: 앱잼팀 득점 랭킹 TOP10 조회 + * - 실제로는 모든 팀의 순위를 조회 해야함. 전체 팀 수(예: 12팀)를 [size]에 전달해야 함 + * * @param size 조회할 팀의 수 (기본값 10) + */ + @GET("appjamrank/today") + suspend fun getAppjamtampMissionRanking( + @Query("size") size: Int? = 10 + ): AppjamtampTop10MissionScoreResponse + + @GET("appjamrank/recent") + suspend fun getAppjamtampMissionTop3( + @Query("size") size: Int? = 3 + ): AppjamtampTop3RecentMissionResponse +} diff --git a/domain/appjamtamp/src/main/java/org/sopt/official/domain/appjamtamp/entity/AppjamtampMissionEntity.kt b/domain/appjamtamp/src/main/java/org/sopt/official/domain/appjamtamp/entity/AppjamtampMissionEntity.kt index c3233cc76..f3b623b04 100644 --- a/domain/appjamtamp/src/main/java/org/sopt/official/domain/appjamtamp/entity/AppjamtampMissionEntity.kt +++ b/domain/appjamtamp/src/main/java/org/sopt/official/domain/appjamtamp/entity/AppjamtampMissionEntity.kt @@ -3,8 +3,8 @@ package org.sopt.official.domain.appjamtamp.entity data class AppjamtampMissionEntity( val id: Int, val title: String, - val ownerName: String, + val ownerName: String?, val level: Int, - val profileImage: List, + val profileImage: List?, val isCompleted: Boolean, ) diff --git a/domain/appjamtamp/src/main/java/org/sopt/official/domain/appjamtamp/entity/AppjamtampMissionListEntity.kt b/domain/appjamtamp/src/main/java/org/sopt/official/domain/appjamtamp/entity/AppjamtampMissionListEntity.kt new file mode 100644 index 000000000..c5a7e6613 --- /dev/null +++ b/domain/appjamtamp/src/main/java/org/sopt/official/domain/appjamtamp/entity/AppjamtampMissionListEntity.kt @@ -0,0 +1,7 @@ +package org.sopt.official.domain.appjamtamp.entity + +data class AppjamtampMissionListEntity( + val teamNumber: String, + val teamName: String, + val missions: List +) diff --git a/domain/appjamtamp/src/main/java/org/sopt/official/domain/appjamtamp/entity/AppjamtampMissionScore.kt b/domain/appjamtamp/src/main/java/org/sopt/official/domain/appjamtamp/entity/AppjamtampMissionScore.kt new file mode 100644 index 000000000..dbfdad832 --- /dev/null +++ b/domain/appjamtamp/src/main/java/org/sopt/official/domain/appjamtamp/entity/AppjamtampMissionScore.kt @@ -0,0 +1,8 @@ +package org.sopt.official.domain.appjamtamp.entity + +data class AppjamtampMissionScore( + val rank: Int, + val teamName: String, + val todayPoints: Int, + val totalPoints: Int +) diff --git a/domain/appjamtamp/src/main/java/org/sopt/official/domain/appjamtamp/entity/AppjamtampRecentMission.kt b/domain/appjamtamp/src/main/java/org/sopt/official/domain/appjamtamp/entity/AppjamtampRecentMission.kt new file mode 100644 index 000000000..8eb52550c --- /dev/null +++ b/domain/appjamtamp/src/main/java/org/sopt/official/domain/appjamtamp/entity/AppjamtampRecentMission.kt @@ -0,0 +1,13 @@ +package org.sopt.official.domain.appjamtamp.entity + +data class AppjamtampRecentMission( + val stampId: Long, + val missionId: Long, + val userId: Long, + val imageUrl: String, + val createdAt: String?, + val userName: String, + val userProfileImage: String?, + val teamName: String, + val teamNumber: String +) diff --git a/domain/appjamtamp/src/main/java/org/sopt/official/domain/appjamtamp/repository/AppjamtampRepository.kt b/domain/appjamtamp/src/main/java/org/sopt/official/domain/appjamtamp/repository/AppjamtampRepository.kt index 1c2828749..b6a803179 100644 --- a/domain/appjamtamp/src/main/java/org/sopt/official/domain/appjamtamp/repository/AppjamtampRepository.kt +++ b/domain/appjamtamp/src/main/java/org/sopt/official/domain/appjamtamp/repository/AppjamtampRepository.kt @@ -1,10 +1,18 @@ package org.sopt.official.domain.appjamtamp.repository -import org.sopt.official.domain.appjamtamp.entity.AppjamtampMissionEntity +import org.sopt.official.domain.appjamtamp.entity.AppjamtampMissionListEntity +import org.sopt.official.domain.appjamtamp.entity.AppjamtampMissionScore +import org.sopt.official.domain.appjamtamp.entity.AppjamtampRecentMission interface AppjamtampRepository { suspend fun getAppjamtampMissions( teamNumber: String? = null, isCompleted: Boolean? = null - ): Result> -} \ No newline at end of file + ): Result + suspend fun getAppjamtampMissionRanking( + size: Int + ): Result> + suspend fun getAppjamtampMissionTop3( + size: Int + ): Result> +} diff --git a/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/missionlist/AppjamtampMissionScreen.kt b/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/missionlist/AppjamtampMissionScreen.kt index fd37f8348..e14054081 100644 --- a/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/missionlist/AppjamtampMissionScreen.kt +++ b/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/missionlist/AppjamtampMissionScreen.kt @@ -22,7 +22,7 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.flowWithLifecycle -import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.persistentListOf import org.sopt.official.common.navigator.DeepLinkType import org.sopt.official.designsystem.SoptTheme import org.sopt.official.domain.appjamtamp.entity.MissionLevel @@ -30,9 +30,11 @@ import org.sopt.official.feature.appjamtamp.component.MissionsGridComponent import org.sopt.official.feature.appjamtamp.missionlist.component.AppjamtampDescription import org.sopt.official.feature.appjamtamp.missionlist.component.AppjamtampFloatingButton import org.sopt.official.feature.appjamtamp.missionlist.component.DropDownHeader +import org.sopt.official.feature.appjamtamp.missionlist.model.AppjamtampMissionListUiModel import org.sopt.official.feature.appjamtamp.missionlist.model.AppjamtampMissionUiModel import org.sopt.official.feature.appjamtamp.missionlist.state.AppjamtampMissionState import org.sopt.official.feature.appjamtamp.missionlist.state.AppjamtampSideEffect +import org.sopt.official.feature.appjamtamp.model.MissionFilter import org.sopt.official.model.UserStatus import org.sopt.official.webview.view.WebViewActivity @@ -122,9 +124,8 @@ private fun AppjamtampMissionScreen( ) { Spacer(modifier = Modifier.height(4.dp)) - // Todo : 서버에서 주는 팀 네임(state)로 변경하기 AppjamtampDescription( - teamName = "도키", + teamName = state.teamName, modifier = Modifier .fillMaxWidth() ) @@ -132,7 +133,7 @@ private fun AppjamtampMissionScreen( Spacer(modifier = Modifier.height(16.dp)) MissionsGridComponent( - missionList = state.missionList, + missionList = state.missionList.missionList, onMissionItemClick = { item -> // Todo : 미션 상세화면으로 이동 } @@ -141,54 +142,62 @@ private fun AppjamtampMissionScreen( } } -@Preview +@Preview(showBackground = true) @Composable private fun AppjamtampMissionScreenPreview() { SoptTheme { - val missionList = AppjamtampMissionState( - missionList = - listOf( - AppjamtampMissionUiModel( - id = 1, - title = "세미나 끝나고 뒷풀이 1시까지 달리기", - level = MissionLevel.of(3), - isCompleted = true, - ), - AppjamtampMissionUiModel( - id = 2, - title = "세미나 끝나고 뒷풀이 2시까지 달리기", - level = MissionLevel.of(1), - isCompleted = true, - ), - AppjamtampMissionUiModel( - id = 3, - title = "세미나 끝나고 뒷풀이 3시까지 달리기", - level = MissionLevel.of(2), - isCompleted = true, - ), - AppjamtampMissionUiModel( - id = 4, - title = "세미나 끝나고 뒷풀이 4시까지 달리기", - level = MissionLevel.of(10), - isCompleted = true, - ), - AppjamtampMissionUiModel( - id = 5, - title = "세미나 끝나고 뒷풀이 5시까지 달리기", - level = MissionLevel.of(1), - isCompleted = false, - ), - AppjamtampMissionUiModel( - id = 6, - title = "세미나 끝나고 뒷풀이 6시까지 달리기", - level = MissionLevel.of(2), - isCompleted = false, - ), - ).toImmutableList(), + val mockMissions = persistentListOf( + AppjamtampMissionUiModel( + id = 1, + title = "세미나 끝나고 뒷풀이 1시까지 달리기", + level = MissionLevel.of(3), + isCompleted = true, + ), + AppjamtampMissionUiModel( + id = 2, + title = "세미나 끝나고 뒷풀이 2시까지 달리기", + level = MissionLevel.of(1), + isCompleted = true, + ), + AppjamtampMissionUiModel( + id = 3, + title = "세미나 끝나고 뒷풀이 3시까지 달리기", + level = MissionLevel.of(2), + isCompleted = true, + ), + AppjamtampMissionUiModel( + id = 4, + title = "세미나 끝나고 뒷풀이 4시까지 달리기", + level = MissionLevel.of(10), + isCompleted = true, + ), + AppjamtampMissionUiModel( + id = 5, + title = "세미나 끝나고 뒷풀이 5시까지 달리기", + level = MissionLevel.of(1), + isCompleted = false, + ), + AppjamtampMissionUiModel( + id = 6, + title = "세미나 끝나고 뒷풀이 6시까지 달리기", + level = MissionLevel.of(2), + isCompleted = false, + ), + ) + + val mockState = AppjamtampMissionState( + teamName = "도키", + missionList = AppjamtampMissionListUiModel( + teamNumber = "1", + teamName = "도키", + missionList = mockMissions + ), + currentMissionFilter = MissionFilter.ALL ) + AppjamtampMissionScreen( paddingValues = PaddingValues(), - state = missionList + state = mockState ) } -} \ No newline at end of file +} diff --git a/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/missionlist/AppjamtampMissionViewModel.kt b/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/missionlist/AppjamtampMissionViewModel.kt index 3d78f906e..bc14c0c9a 100644 --- a/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/missionlist/AppjamtampMissionViewModel.kt +++ b/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/missionlist/AppjamtampMissionViewModel.kt @@ -20,7 +20,7 @@ import timber.log.Timber import javax.inject.Inject @HiltViewModel -class AppjamtampMissionViewModel @Inject constructor( +internal class AppjamtampMissionViewModel @Inject constructor( private val appjamtampRepository: AppjamtampRepository, private val stampRepository: StampRepository ) : ViewModel() { @@ -42,7 +42,8 @@ class AppjamtampMissionViewModel @Inject constructor( ).onSuccess { missions -> _state.update { currentState -> currentState.copy( - missionList = missions.map { it.toUiModel() }.toImmutableList() + teamName = missions.teamName, + missionList = missions.toUiModel() ) } }.onFailure(Timber::e) diff --git a/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/missionlist/model/AppjamtampMissionUiModel.kt b/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/missionlist/model/AppjamtampMissionUiModel.kt index 17934a29a..a64c42c02 100644 --- a/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/missionlist/model/AppjamtampMissionUiModel.kt +++ b/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/missionlist/model/AppjamtampMissionUiModel.kt @@ -1,18 +1,34 @@ package org.sopt.official.feature.appjamtamp.missionlist.model +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList import org.sopt.official.domain.appjamtamp.entity.AppjamtampMissionEntity +import org.sopt.official.domain.appjamtamp.entity.AppjamtampMissionListEntity import org.sopt.official.domain.appjamtamp.entity.MissionLevel +data class AppjamtampMissionListUiModel( + val teamNumber: String = "", + val teamName: String = "", + val missionList: ImmutableList = persistentListOf(), +) + +internal fun AppjamtampMissionListEntity.toUiModel() = AppjamtampMissionListUiModel( + teamNumber = teamNumber, + teamName = teamName, + missionList = missions.map { it.toUiModel() }.toImmutableList() +) + data class AppjamtampMissionUiModel( val id: Int = -1, val title: String = "", - val ownerName: String = "", + val ownerName: String? = "", val level: MissionLevel = MissionLevel.of(1), - val profileImage: List = emptyList(), + val profileImage: List? = emptyList(), val isCompleted: Boolean = false, ) -fun AppjamtampMissionEntity.toUiModel() = AppjamtampMissionUiModel( +internal fun AppjamtampMissionEntity.toUiModel() = AppjamtampMissionUiModel( id = id, title = title, ownerName = ownerName, diff --git a/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/missionlist/state/AppjamtampContract.kt b/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/missionlist/state/AppjamtampContract.kt index 96f91bb76..596ea9e03 100644 --- a/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/missionlist/state/AppjamtampContract.kt +++ b/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/missionlist/state/AppjamtampContract.kt @@ -1,13 +1,12 @@ package org.sopt.official.feature.appjamtamp.missionlist.state -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf -import org.sopt.official.feature.appjamtamp.missionlist.model.AppjamtampMissionUiModel +import org.sopt.official.feature.appjamtamp.missionlist.model.AppjamtampMissionListUiModel import org.sopt.official.feature.appjamtamp.model.MissionFilter data class AppjamtampMissionState( val reportUrl: String = "", - val missionList: ImmutableList = persistentListOf(), + val teamName: String = "", + val missionList: AppjamtampMissionListUiModel = AppjamtampMissionListUiModel(), val currentMissionFilter: MissionFilter = MissionFilter.ALL ) diff --git a/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/ranking/AppjamtampRankingScreen.kt b/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/ranking/AppjamtampRankingScreen.kt index 344d7c6a0..8a8c3bf83 100644 --- a/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/ranking/AppjamtampRankingScreen.kt +++ b/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/ranking/AppjamtampRankingScreen.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -19,6 +20,8 @@ import androidx.compose.material3.Icon import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -28,23 +31,51 @@ import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.res.vectorResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import org.sopt.official.designsystem.SoptTheme import org.sopt.official.designsystem.White +import org.sopt.official.designsystem.component.indicator.LoadingIndicator import org.sopt.official.feature.appjamtamp.R import org.sopt.official.feature.appjamtamp.component.BackButtonHeader import org.sopt.official.feature.appjamtamp.ranking.component.TodayScoreRaking -import org.sopt.official.feature.appjamtamp.ranking.component.TopRankingTeamMission +import org.sopt.official.feature.appjamtamp.ranking.component.Top3RecentRankingMission +import org.sopt.official.feature.appjamtamp.ranking.model.AppjamtampRankingState +import org.sopt.official.feature.appjamtamp.ranking.model.Top10MissionScoreListUiModel +import org.sopt.official.feature.appjamtamp.ranking.model.Top3RecentRankingListUiModel +import org.sopt.official.feature.appjamtamp.ranking.model.Top3RecentRankingUiModel +import org.sopt.official.feature.appjamtamp.ranking.model.TopMissionScoreUiModel @Composable internal fun AppjamtampRankingRoute( - + viewModel:AppjamtampRankingViewModel= hiltViewModel() ) { - AppjamtampRankingScreen() + val state by viewModel.state.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.getRankingData() + } + + when(state) { + is AppjamtampRankingState.Loading -> { LoadingIndicator() } + is AppjamtampRankingState.Success -> { + val top3RecentRankingList = (state as AppjamtampRankingState.Success).top3RecentRankingListUiModel.top3RecentRankingList + val top10MissionScoreList = (state as AppjamtampRankingState.Success).top10MissionScoreListUiModel.top10MissionScoreList + AppjamtampRankingScreen( + top3RecentRankings = top3RecentRankingList, + top10MissionScores = top10MissionScoreList + ) + } + is AppjamtampRankingState.Failure -> {} + } } @Composable internal fun AppjamtampRankingScreen( - + top3RecentRankings: ImmutableList, + top10MissionScores: ImmutableList ) { val scrollState = rememberScrollState() @@ -60,7 +91,9 @@ internal fun AppjamtampRankingScreen( topBar = { BackButtonHeader( title = "앱잼팀 랭킹", - onBackButtonClick = {}, // TODO - 뒤로가기 + onBackButtonClick = { + // TODO - 뒤로가기 (앱잼탬프 홈 - AppjamtampMissionScreen) + }, modifier = Modifier .padding(vertical = 12.dp) .padding(start = 16.dp) @@ -113,8 +146,9 @@ internal fun AppjamtampRankingScreen( .horizontalScroll(state = rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(space = 10.dp) ) { - repeat(times = 3) { - TopRankingTeamMission( + top3RecentRankings.forEach { top3RecentRanking -> + Top3RecentRankingMission( + top3RecentRanking = top3RecentRanking, modifier = Modifier.width(topRankingItemWidth) ) } @@ -152,22 +186,22 @@ internal fun AppjamtampRankingScreen( Spacer(modifier = Modifier.height(height = 20.dp)) - val rankingList = List(10) { it } // TODO - 10개 표시하기 위한 임시 리스트 - Column( + FlowRow( modifier = Modifier .fillMaxWidth() .padding(bottom = 56.dp), - verticalArrangement = Arrangement.spacedBy(space = 10.dp) + horizontalArrangement = Arrangement.spacedBy(space = 10.dp), + verticalArrangement = Arrangement.spacedBy(space = 10.dp), + maxItemsInEachRow = 2 ) { - rankingList.chunked(size = 2).forEach { rowItems -> - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(space = 10.dp) - ) { - rowItems.forEach { item -> - TodayScoreRaking(modifier = Modifier.weight(weight = 1f)) + top10MissionScores.forEach { item -> + TodayScoreRaking( + top10MissionScore = item, + modifier = Modifier.weight(1f), + onTeamRankingClick = { + // Todo : 앱잼 팀 미션 화면으로 이동 (teamNumber 전달) } - } + ) } } } @@ -178,6 +212,62 @@ internal fun AppjamtampRankingScreen( @Composable private fun AppjamtampRankingScreenPreview() { SoptTheme { - AppjamtampRankingScreen() + val mockTop3RecentRankingListUiModel = Top3RecentRankingListUiModel( + top3RecentRankingList = persistentListOf( + Top3RecentRankingUiModel( + stampId = 1L, + missionId = 101L, + userId = 1L, + imageUrl = "", + createdAt = "2025-12-31T05:03:49", + userName = "솝트", + userProfileImage = null, + teamName = "보핏", + teamNumber = "FIRST" + ), + Top3RecentRankingUiModel( + stampId = 2L, + missionId = 102L, + userId = 2L, + imageUrl = "", + createdAt = "2025-12-31T04:50:12", + userName = "안드", + userProfileImage = "", + teamName = "노바", + teamNumber = "SECOND" + ), + Top3RecentRankingUiModel( + stampId = 3L, + missionId = 103L, + userId = 3L, + imageUrl = "", + createdAt = "2025-12-31T03:20:00", + userName = "안드로이드", + userProfileImage = null, + teamName = "하이링구얼", + teamNumber = "THIRD" + ) + ) + ) + + val mockTop10MissionScoreListUiModel = Top10MissionScoreListUiModel( + top10MissionScoreList = persistentListOf( + TopMissionScoreUiModel(rank = 1, teamName = "보핏", todayPoints = 1200, totalPoints = 5000), + TopMissionScoreUiModel(rank = 2, teamName = "노바", todayPoints = 1100, totalPoints = 4800), + TopMissionScoreUiModel(rank = 3, teamName = "비트", todayPoints = 950, totalPoints = 4200), + TopMissionScoreUiModel(rank = 4, teamName = "하이링구얼", todayPoints = 800, totalPoints = 3900), + TopMissionScoreUiModel(rank = 5, teamName = "납작마켓", todayPoints = 750, totalPoints = 3500), + TopMissionScoreUiModel(rank = 6, teamName = "웹", todayPoints = 600, totalPoints = 3100), + TopMissionScoreUiModel(rank = 7, teamName = "안드로이드", todayPoints = 550, totalPoints = 2800), + TopMissionScoreUiModel(rank = 8, teamName = "iOS", todayPoints = 400, totalPoints = 2500), + TopMissionScoreUiModel(rank = 9, teamName = "디자인", todayPoints = 300, totalPoints = 2000), + TopMissionScoreUiModel(rank = 10, teamName = "기획", todayPoints = 100, totalPoints = 1500) + ) + ) + + AppjamtampRankingScreen( + top3RecentRankings = mockTop3RecentRankingListUiModel.top3RecentRankingList, + top10MissionScores = mockTop10MissionScoreListUiModel.top10MissionScoreList + ) } } diff --git a/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/ranking/AppjamtampRankingViewModel.kt b/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/ranking/AppjamtampRankingViewModel.kt new file mode 100644 index 000000000..3251d3b99 --- /dev/null +++ b/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/ranking/AppjamtampRankingViewModel.kt @@ -0,0 +1,47 @@ +package org.sopt.official.feature.appjamtamp.ranking + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.sopt.official.domain.appjamtamp.repository.AppjamtampRepository +import org.sopt.official.feature.appjamtamp.ranking.model.AppjamtampRankingState +import org.sopt.official.feature.appjamtamp.ranking.model.toUiModel +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +internal class AppjamtampRankingViewModel @Inject constructor( + private val appjamtampRepository: AppjamtampRepository +) : ViewModel() { + + private val _state: MutableStateFlow = MutableStateFlow(AppjamtampRankingState.Loading) + val state: StateFlow = _state.asStateFlow() + + fun getRankingData() { + viewModelScope.launch { + _state.value = AppjamtampRankingState.Loading + + val top3Deferred = async { appjamtampRepository.getAppjamtampMissionTop3(size = 3) } + val top10Deferred = async { appjamtampRepository.getAppjamtampMissionRanking(size = 12) } // size = 현재 기수 전체 앱잼 팀 수 + + val top3Result = top3Deferred.await() + val top10Result = top10Deferred.await() + + if (top3Result.isSuccess && top10Result.isSuccess) { + _state.value = AppjamtampRankingState.Success( + top3RecentRankingListUiModel = top3Result.getOrThrow().toUiModel(), + top10MissionScoreListUiModel = top10Result.getOrThrow().toUiModel() + ) + } else { + val error = top3Result.exceptionOrNull() ?: top10Result.exceptionOrNull() ?: Exception("Unknown error") + Timber.tag("AppjamtampRanking").e(error, "Failed to load ranking data") + _state.value = AppjamtampRankingState.Failure(error) + } + } + } +} diff --git a/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/ranking/component/TodayScoreRaking.kt b/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/ranking/component/TodayScoreRaking.kt new file mode 100644 index 000000000..72bc763cb --- /dev/null +++ b/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/ranking/component/TodayScoreRaking.kt @@ -0,0 +1,113 @@ +package org.sopt.official.feature.appjamtamp.ranking.component + +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.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +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.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.sopt.official.designsystem.SoptTheme +import org.sopt.official.designsystem.White +import org.sopt.official.feature.appjamtamp.R +import org.sopt.official.feature.appjamtamp.ranking.model.TopMissionScoreUiModel +import org.sopt.official.feature.appjamtamp.util.noRippleClickable + +@Composable +internal fun TodayScoreRaking( + top10MissionScore: TopMissionScoreUiModel, + modifier: Modifier = Modifier, + onTeamRankingClick: (teamNumber: String) -> Unit = {} +) { + val rankIconRes = when (top10MissionScore.rank) { + 1 -> R.drawable.ic_rank_1 + 2 -> R.drawable.ic_rank_2 + 3 -> R.drawable.ic_rank_3 + else -> R.drawable.ic_ranking_default + } + val rankTextColor = if (top10MissionScore.rank <= 3) Color.White else SoptTheme.colors.onSurface100 + + Column( + modifier = modifier + .clip(shape = RoundedCornerShape(size = 10.dp)) + .background(color = SoptTheme.colors.onSurface900) + .padding(start = 12.dp, end = 16.dp, top = 12.dp, bottom = 8.dp) + .noRippleClickable { onTeamRankingClick } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.Start), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = ImageVector.vectorResource(id = rankIconRes), + contentDescription = null, + tint = Color.Unspecified + ) + Text( + text = top10MissionScore.rank.toString(), + style = SoptTheme.typography.heading16B, + color = rankTextColor, + modifier = Modifier.padding(vertical = 2.dp, horizontal = 9.dp) + ) + } + + Text( + text = top10MissionScore.teamName, + color = White, + style = SoptTheme.typography.heading16B, + modifier = Modifier + .padding(vertical = 2.dp) + .padding(start = 5.dp) + ) + } + + Spacer(modifier = Modifier.height(height = 14.dp)) + + Text( + text = "총 ${top10MissionScore.totalPoints}점", + color = SoptTheme.colors.onSurface300, + style = SoptTheme.typography.title14SB, + modifier = Modifier.align(Alignment.End) + ) + + Text( + text = "+${top10MissionScore.todayPoints}점", + color = White, + style = SoptTheme.typography.heading20B, + modifier = Modifier.align(Alignment.End) + ) + } +} + +@Preview +@Composable +private fun TodayScoreRakingPreview() { + SoptTheme { + val mockTopMissionScoreUiModel = TopMissionScoreUiModel( + rank = 2, + teamName = "노바", + todayPoints = 1000, + totalPoints = 3000 + ) + + TodayScoreRaking(top10MissionScore = mockTopMissionScoreUiModel) + } +} diff --git a/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/ranking/component/AppjamtampMissionScore.kt b/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/ranking/component/Top3RecentRankingMission.kt similarity index 60% rename from feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/ranking/component/AppjamtampMissionScore.kt rename to feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/ranking/component/Top3RecentRankingMission.kt index 85b408816..d1365f15b 100644 --- a/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/ranking/component/AppjamtampMissionScore.kt +++ b/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/ranking/component/Top3RecentRankingMission.kt @@ -1,6 +1,5 @@ package org.sopt.official.feature.appjamtamp.ranking.component -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -20,7 +19,6 @@ 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.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource @@ -32,14 +30,20 @@ import coil.compose.AsyncImage import org.sopt.official.designsystem.GrayAlpha100 import org.sopt.official.designsystem.SoptTheme import org.sopt.official.designsystem.White +import org.sopt.official.designsystem.component.UrlImage import org.sopt.official.feature.appjamtamp.R +import org.sopt.official.feature.appjamtamp.ranking.model.Top3RecentRankingUiModel +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone +import java.util.concurrent.TimeUnit @Composable -internal fun TopRankingTeamMission( - // TODO - 서버 응답 값 +internal fun Top3RecentRankingMission( + top3RecentRanking: Top3RecentRankingUiModel, modifier: Modifier = Modifier ) { - Column( modifier = modifier.background(color = SoptTheme.colors.onSurface950) ) { @@ -49,22 +53,15 @@ internal fun TopRankingTeamMission( .aspectRatio(ratio = 146f / 232f) .clip(shape = RoundedCornerShape(size = 12.dp)) ) { -// TODO - 서버 응답 -> UrlImage 사용 -// UrlImage( -// url = "", -// contentDescription = null -// ) - - // TODO 임시 이미지 - Image( - imageVector = ImageVector.vectorResource(id = org.sopt.official.designsystem.R.drawable.img_fake_red), + UrlImage( + url = top3RecentRanking.imageUrl, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize() ) Text( - text = "11분 전", + text = top3RecentRanking.createdAt.toRelativeTime(), modifier = Modifier .align(Alignment.TopEnd) .padding(top = 8.dp, end = 8.dp) @@ -81,7 +78,7 @@ internal fun TopRankingTeamMission( Spacer(modifier = Modifier.height(height = 8.dp)) Text( - text = "보이지 않는 손", + text = top3RecentRanking.teamName, color = White, style = SoptTheme.typography.heading16B, overflow = TextOverflow.Ellipsis, @@ -101,14 +98,7 @@ internal fun TopRankingTeamMission( .background(color = SoptTheme.colors.onSurface700), contentAlignment = Alignment.Center ) { - if (false) { // TODO - 프로필 이미지 존재 여부 - AsyncImage( - model = "", - contentDescription = null, - contentScale = ContentScale.Crop, - error = painterResource(id = R.drawable.ic_user_profile) - ) - } else { + if (top3RecentRanking.userProfileImage?.isEmpty() == true) { Icon( imageVector = ImageVector.vectorResource(id = R.drawable.ic_user_profile), contentDescription = null, @@ -116,11 +106,18 @@ internal fun TopRankingTeamMission( modifier = Modifier .padding(all = 4.dp) ) + } else { + AsyncImage( + model = top3RecentRanking.userProfileImage, + contentDescription = null, + contentScale = ContentScale.Crop, + error = painterResource(id = R.drawable.ic_user_profile) + ) } } Text( - text = "노바고은비", + text = "${top3RecentRanking.teamName}${top3RecentRanking.userName}", color = SoptTheme.colors.onSurface300, style = SoptTheme.typography.label12SB, modifier = Modifier @@ -131,79 +128,54 @@ internal fun TopRankingTeamMission( } } -@Composable -internal fun TodayScoreRaking( - // TODO - 서버 응답 값 - modifier: Modifier = Modifier -) { - Column( - modifier = modifier - .clip(shape = RoundedCornerShape(size = 10.dp)) - .background(color = SoptTheme.colors.onSurface900) - .padding(start = 12.dp, end = 16.dp, top = 12.dp, bottom = 8.dp) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.Start), - verticalAlignment = Alignment.CenterVertically - ) { - Box( - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = ImageVector.vectorResource(id = R.drawable.ic_ranking_default), - contentDescription = null, - tint = Color.Unspecified - ) - Text( - text = "1", - style = SoptTheme.typography.heading16B, - color = SoptTheme.colors.onSurface100, - modifier = Modifier.padding(vertical = 2.dp, horizontal = 9.dp) - ) - } - - Text( - text = "노바", - color = White, - style = SoptTheme.typography.heading16B, - modifier = Modifier - .padding(vertical = 2.dp) - .padding(start = 5.dp) - ) - } - - Spacer(modifier = Modifier.height(height = 14.dp)) - - Text( - text = "총 3000점", - color = SoptTheme.colors.onSurface300, - style = SoptTheme.typography.title14SB, - modifier = Modifier.align(Alignment.End) - ) - - Text( - text = "+1000점", - color = White, - style = SoptTheme.typography.heading20B, - modifier = Modifier.align(Alignment.End) - ) +/* 업로드 시간 변경 함수 +서버 응답 형식 : 2025-10-31T00:00:56 +업로드 시간: 상대시간 노출 +10분 미만 => 방금 전 +11분 전 ~ 59분 전 => 그대로 표기 +1시간 ~ 24시간 전 => 그대로 표기 +25시간 이후 => 1일 전, 2일 전 ... + */ +private fun String?.toRelativeTime(): String { + if (this.isNullOrBlank()) return "" + + val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.KOREA) + dateFormat.timeZone = TimeZone.getTimeZone("Asia/Seoul") + + val date = dateFormat .parse(this) ?: return "" + val currentDate = Date() + + val diffMillis = currentDate.time - date.time + if (diffMillis < 0) return "방금 전" + + val minutes = TimeUnit.MILLISECONDS.toMinutes(diffMillis) + val hours = TimeUnit.MILLISECONDS.toHours(diffMillis) + val days = TimeUnit.MILLISECONDS.toDays(diffMillis) + + return when { + minutes < 10 -> "방금 전" + minutes in 10..59 -> "${minutes}분 전" + hours in 1..23 -> "${hours}시간 전" + else -> "${days}일 전" } } @Preview @Composable -private fun TopRankingTeamMissionPreview() { +private fun Top3RecentRankingMissionPreview() { SoptTheme { - TopRankingTeamMission() - } -} + val mockTop3RecentRankingUiModel = Top3RecentRankingUiModel( + stampId = 1L, + missionId = 44L, + userId = 1073L, + imageUrl = "", + createdAt = "2025-10-31T00:00:56", + userName = "노바고은비", + userProfileImage = null, + teamName = "노바", + teamNumber = "FOURTH" + ) -@Preview -@Composable -private fun TodayScoreRakingPreview() { - SoptTheme { - TodayScoreRaking() + Top3RecentRankingMission(top3RecentRanking = mockTop3RecentRankingUiModel) } } diff --git a/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/ranking/model/AppjamtampState.kt b/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/ranking/model/AppjamtampState.kt new file mode 100644 index 000000000..337b2e65e --- /dev/null +++ b/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/ranking/model/AppjamtampState.kt @@ -0,0 +1,12 @@ +package org.sopt.official.feature.appjamtamp.ranking.model + +sealed class AppjamtampRankingState { + data object Loading : AppjamtampRankingState() + + data class Success( + val top3RecentRankingListUiModel: Top3RecentRankingListUiModel, + val top10MissionScoreListUiModel: Top10MissionScoreListUiModel + ) : AppjamtampRankingState() + + data class Failure(val error: Throwable) : AppjamtampRankingState() +} diff --git a/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/ranking/model/Top3RecentRankingModel.kt b/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/ranking/model/Top3RecentRankingModel.kt new file mode 100644 index 000000000..ea949011f --- /dev/null +++ b/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/ranking/model/Top3RecentRankingModel.kt @@ -0,0 +1,39 @@ +package org.sopt.official.feature.appjamtamp.ranking.model + +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import org.sopt.official.domain.appjamtamp.entity.AppjamtampRecentMission + +data class Top3RecentRankingListUiModel( + val top3RecentRankingList: ImmutableList +) + +internal fun List.toUiModel(): Top3RecentRankingListUiModel = + Top3RecentRankingListUiModel( + top3RecentRankingList = this.map { it.toUiModel() }.toImmutableList() + ) + +data class Top3RecentRankingUiModel( + val stampId: Long, + val missionId: Long, + val userId: Long, + val imageUrl: String, + val createdAt: String?, + val userName: String, + val userProfileImage: String?, + val teamName: String, + val teamNumber: String +) + +internal fun AppjamtampRecentMission.toUiModel(): Top3RecentRankingUiModel = + Top3RecentRankingUiModel( + stampId = this.stampId, + missionId = this.missionId, + userId = this.userId, + imageUrl = this.imageUrl, + createdAt = this.createdAt, + userName = this.userName, + userProfileImage = this.userProfileImage, + teamName = this.teamName, + teamNumber = this.teamNumber + ) diff --git a/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/ranking/model/TopMissionScoreUiModel.kt b/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/ranking/model/TopMissionScoreUiModel.kt new file mode 100644 index 000000000..d0d2dd927 --- /dev/null +++ b/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/ranking/model/TopMissionScoreUiModel.kt @@ -0,0 +1,29 @@ +package org.sopt.official.feature.appjamtamp.ranking.model + +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import org.sopt.official.domain.appjamtamp.entity.AppjamtampMissionScore + +data class Top10MissionScoreListUiModel( + val top10MissionScoreList: ImmutableList +) + +internal fun List.toUiModel(): Top10MissionScoreListUiModel = + Top10MissionScoreListUiModel( + top10MissionScoreList = this.map { it.toUiModel() }.toImmutableList() + ) + +data class TopMissionScoreUiModel( + val rank: Int, + val teamName: String, + val todayPoints: Int, + val totalPoints: Int +) + +internal fun AppjamtampMissionScore.toUiModel(): TopMissionScoreUiModel = + TopMissionScoreUiModel( + rank = this.rank, + teamName = this.teamName, + todayPoints = this.todayPoints, + totalPoints = this.totalPoints + ) diff --git a/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/teammisisonlist/AppjamtampTeamMissionListScreen.kt b/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/teammisisonlist/AppjamtampTeamMissionListScreen.kt index 9b882386d..bdeebae2d 100644 --- a/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/teammisisonlist/AppjamtampTeamMissionListScreen.kt +++ b/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/teammisisonlist/AppjamtampTeamMissionListScreen.kt @@ -13,25 +13,51 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import kotlinx.collections.immutable.toImmutableList +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import org.sopt.official.designsystem.SoptTheme import org.sopt.official.domain.appjamtamp.entity.MissionLevel import org.sopt.official.feature.appjamtamp.component.BackButtonHeader import org.sopt.official.feature.appjamtamp.component.MissionsGridComponent import org.sopt.official.feature.appjamtamp.missionlist.model.AppjamtampMissionUiModel +import org.sopt.official.feature.appjamtamp.teammisisonlist.model.AppjamtampMissionListState @Composable -internal fun AppjamtampTeamMissionListRoute() { - AppjamtampTeamMissionListScreen() +internal fun AppjamtampTeamMissionListRoute( + viewModel: AppjamtampTeamMissionListViewModel = hiltViewModel() +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.fetchAppjamMissions( + teamNumber = "FIRST" // TODO - 이전 화면에서 네비게이션으로 teamNumber 전달 받아 넣기 + ) + } + + when(state) { + is AppjamtampMissionListState.Loading -> {} + is AppjamtampMissionListState.Success -> { + AppjamtampTeamMissionListScreen( + teamName = (state as AppjamtampMissionListState.Success).teamName, + teamMissionList = (state as AppjamtampMissionListState.Success).teamMissionList.missionList + ) + } + is AppjamtampMissionListState.Failure -> {} + } } @Composable internal fun AppjamtampTeamMissionListScreen( - missionList: List = emptyList() + teamName: String, + teamMissionList: ImmutableList ) { Scaffold( modifier = Modifier @@ -39,8 +65,10 @@ internal fun AppjamtampTeamMissionListScreen( .navigationBarsPadding(), topBar = { BackButtonHeader( - title = "노바", - onBackButtonClick = {}, + title = teamName, + onBackButtonClick = { + // Todo : 앱잼 팀 랭킹 화면으로 이동 (뒤로가기) + }, modifier = Modifier .padding(vertical = 12.dp) .padding(start = 16.dp) @@ -55,14 +83,17 @@ internal fun AppjamtampTeamMissionListScreen( .padding(horizontal = 16.dp) ) { DescriptionText( - description = "노바팀이 다같이 인증한 미션", // TODO - "${}팀이 다같이 인증한 미션" + description = "${teamName}팀이 다같이 인증한 미션", modifier = Modifier.padding(top = 12.dp) ) Spacer(modifier = Modifier.size(size = 16.dp)) MissionsGridComponent( - missionList = missionList.toImmutableList() + missionList = teamMissionList, + onMissionItemClick = { item -> + // Todo : 미션 상세화면으로 이동 + } ) } } @@ -91,7 +122,7 @@ private fun DescriptionText( @Preview @Composable private fun AppjamtampTeamMissionListScreenPreview() { - val mockMissionList = listOf( + val mockMissionList = persistentListOf( AppjamtampMissionUiModel( id = 1, title = "세미나 끝나고 뒷풀이 1시까지 달리기", @@ -131,6 +162,6 @@ private fun AppjamtampTeamMissionListScreenPreview() { ) SoptTheme { - AppjamtampTeamMissionListScreen(missionList = mockMissionList) + AppjamtampTeamMissionListScreen(teamName = "하이링구얼", teamMissionList = mockMissionList) } } diff --git a/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/teammisisonlist/AppjamtampTeamMissionListViewModel.kt b/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/teammisisonlist/AppjamtampTeamMissionListViewModel.kt new file mode 100644 index 000000000..db3144c13 --- /dev/null +++ b/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/teammisisonlist/AppjamtampTeamMissionListViewModel.kt @@ -0,0 +1,45 @@ +package org.sopt.official.feature.appjamtamp.teammisisonlist + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.sopt.official.domain.appjamtamp.repository.AppjamtampRepository +import org.sopt.official.feature.appjamtamp.missionlist.model.toUiModel +import org.sopt.official.feature.appjamtamp.teammisisonlist.model.AppjamtampMissionListState +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +internal class AppjamtampTeamMissionListViewModel @Inject constructor( + private val appjamtampRepository: AppjamtampRepository, +) : ViewModel() { + + private val _state: MutableStateFlow = MutableStateFlow(AppjamtampMissionListState.Loading) + val state: StateFlow = _state.asStateFlow() + + private val _teamName: MutableStateFlow = MutableStateFlow("") + val teamName: StateFlow = _teamName.asStateFlow() + + fun fetchAppjamMissions( + teamNumber: String, + isCompleted: Boolean? = null + ) { + viewModelScope.launch { + _state.value = AppjamtampMissionListState.Loading + + appjamtampRepository.getAppjamtampMissions( + teamNumber = teamNumber, + isCompleted = isCompleted + ).onSuccess { missions -> + _state.value = AppjamtampMissionListState.Success( + teamName = missions.teamName, + teamMissionList = missions.toUiModel() + ) + }.onFailure(Timber::e) + } + } +} diff --git a/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/teammisisonlist/model/AppjamtampMissionListState.kt b/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/teammisisonlist/model/AppjamtampMissionListState.kt new file mode 100644 index 000000000..9baa68527 --- /dev/null +++ b/feature/appjamtamp/src/main/java/org/sopt/official/feature/appjamtamp/teammisisonlist/model/AppjamtampMissionListState.kt @@ -0,0 +1,14 @@ +package org.sopt.official.feature.appjamtamp.teammisisonlist.model + +import org.sopt.official.feature.appjamtamp.missionlist.model.AppjamtampMissionListUiModel + +sealed class AppjamtampMissionListState { + data object Loading : AppjamtampMissionListState() + + data class Success( + val teamName: String, + val teamMissionList: AppjamtampMissionListUiModel, + ) : AppjamtampMissionListState() + + data class Failure(val error: Throwable) : AppjamtampMissionListState() +}