diff --git a/app/src/main/java/com/ssafy/tiggle/data/datasource/remote/DonationApiService.kt b/app/src/main/java/com/ssafy/tiggle/data/datasource/remote/DonationApiService.kt index aa6a99f..94ebff8 100644 --- a/app/src/main/java/com/ssafy/tiggle/data/datasource/remote/DonationApiService.kt +++ b/app/src/main/java/com/ssafy/tiggle/data/datasource/remote/DonationApiService.kt @@ -4,6 +4,7 @@ import com.ssafy.tiggle.data.model.BaseResponse import com.ssafy.tiggle.data.model.EmptyResponse import com.ssafy.tiggle.data.model.donation.DonationAccountDto import com.ssafy.tiggle.data.model.donation.DonationHistoryDto +import com.ssafy.tiggle.data.model.donation.DonationRankDto import com.ssafy.tiggle.data.model.donation.DonationRequestDto import com.ssafy.tiggle.data.model.donation.DonationStatusDto import com.ssafy.tiggle.data.model.donation.DonationSummaryDto @@ -33,4 +34,10 @@ interface DonationApiService { @POST("/api/donation") suspend fun createDonation(@Body request: DonationRequestDto): Response> + + @GET("/api/donation/rank/university") + suspend fun getUniversityRanking(): Response>> + + @GET("/api/donation/rank/department") + suspend fun getDepartmentRanking(): Response>> } diff --git a/app/src/main/java/com/ssafy/tiggle/data/model/donation/DonationRankDto.kt b/app/src/main/java/com/ssafy/tiggle/data/model/donation/DonationRankDto.kt new file mode 100644 index 0000000..68c303f --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/data/model/donation/DonationRankDto.kt @@ -0,0 +1,10 @@ +package com.ssafy.tiggle.data.model.donation + +import kotlinx.serialization.Serializable + +@Serializable +data class DonationRankDto( + val rank: Int, + val name: String, + val amount: Int +) diff --git a/app/src/main/java/com/ssafy/tiggle/data/repository/DonationRepositoryImpl.kt b/app/src/main/java/com/ssafy/tiggle/data/repository/DonationRepositoryImpl.kt index f2e9d9c..afbd299 100644 --- a/app/src/main/java/com/ssafy/tiggle/data/repository/DonationRepositoryImpl.kt +++ b/app/src/main/java/com/ssafy/tiggle/data/repository/DonationRepositoryImpl.kt @@ -5,6 +5,7 @@ import com.ssafy.tiggle.data.model.donation.DonationRequestDto import com.ssafy.tiggle.domain.entity.donation.DonationAccount import com.ssafy.tiggle.domain.entity.donation.DonationCategory import com.ssafy.tiggle.domain.entity.donation.DonationHistory +import com.ssafy.tiggle.domain.entity.donation.DonationRank import com.ssafy.tiggle.domain.entity.donation.DonationRequest import com.ssafy.tiggle.domain.entity.donation.DonationStatus import com.ssafy.tiggle.domain.entity.donation.DonationStatusType @@ -133,4 +134,44 @@ class DonationRepositoryImpl @Inject constructor( Result.failure(e) } } + + override suspend fun getUniversityRanking(): Result> { + return try { + val response = donationApiService.getUniversityRanking() + if (response.isSuccessful && response.body()?.result == true) { + val rankingList = response.body()?.data?.map { dto -> + DonationRank( + rank = dto.rank, + name = dto.name, + amount = dto.amount + ) + } ?: emptyList() + Result.success(rankingList) + } else { + Result.failure(Exception("Failed to fetch university ranking")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + override suspend fun getDepartmentRanking(): Result> { + return try { + val response = donationApiService.getDepartmentRanking() + if (response.isSuccessful && response.body()?.result == true) { + val rankingList = response.body()?.data?.map { dto -> + DonationRank( + rank = dto.rank, + amount = dto.amount, + name = dto.name + ) + } ?: emptyList() + Result.success(rankingList) + } else { + Result.failure(Exception("Failed to fetch department ranking")) + } + } catch (e: Exception) { + Result.failure(e) + } + } } diff --git a/app/src/main/java/com/ssafy/tiggle/domain/entity/donation/DonationRank.kt b/app/src/main/java/com/ssafy/tiggle/domain/entity/donation/DonationRank.kt new file mode 100644 index 0000000..c92fc91 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/domain/entity/donation/DonationRank.kt @@ -0,0 +1,7 @@ +package com.ssafy.tiggle.domain.entity.donation + +data class DonationRank( + val rank: Int, + val name: String, + val amount: Int +) diff --git a/app/src/main/java/com/ssafy/tiggle/domain/repository/DonationRepository.kt b/app/src/main/java/com/ssafy/tiggle/domain/repository/DonationRepository.kt index 51001d7..1e43069 100644 --- a/app/src/main/java/com/ssafy/tiggle/domain/repository/DonationRepository.kt +++ b/app/src/main/java/com/ssafy/tiggle/domain/repository/DonationRepository.kt @@ -2,6 +2,7 @@ package com.ssafy.tiggle.domain.repository import com.ssafy.tiggle.domain.entity.donation.DonationAccount import com.ssafy.tiggle.domain.entity.donation.DonationHistory +import com.ssafy.tiggle.domain.entity.donation.DonationRank import com.ssafy.tiggle.domain.entity.donation.DonationRequest import com.ssafy.tiggle.domain.entity.donation.DonationStatus import com.ssafy.tiggle.domain.entity.donation.DonationStatusType @@ -13,4 +14,6 @@ interface DonationRepository { suspend fun getDonationStatus(type: DonationStatusType): Result suspend fun getDonationAccount(): Result suspend fun createDonation(request: DonationRequest): Result + suspend fun getUniversityRanking(): Result> + suspend fun getDepartmentRanking(): Result> } diff --git a/app/src/main/java/com/ssafy/tiggle/domain/usecase/donation/GetDepartmentRankingUseCase.kt b/app/src/main/java/com/ssafy/tiggle/domain/usecase/donation/GetDepartmentRankingUseCase.kt new file mode 100644 index 0000000..d4f1c1b --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/domain/usecase/donation/GetDepartmentRankingUseCase.kt @@ -0,0 +1,13 @@ +package com.ssafy.tiggle.domain.usecase.donation + +import com.ssafy.tiggle.domain.entity.donation.DonationRank +import com.ssafy.tiggle.domain.repository.DonationRepository +import javax.inject.Inject + +class GetDepartmentRankingUseCase @Inject constructor( + private val donationRepository: DonationRepository +) { + suspend operator fun invoke(): Result> { + return donationRepository.getDepartmentRanking() + } +} diff --git a/app/src/main/java/com/ssafy/tiggle/domain/usecase/donation/GetUniversityRankingUseCase.kt b/app/src/main/java/com/ssafy/tiggle/domain/usecase/donation/GetUniversityRankingUseCase.kt new file mode 100644 index 0000000..37cafb7 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/domain/usecase/donation/GetUniversityRankingUseCase.kt @@ -0,0 +1,13 @@ +package com.ssafy.tiggle.domain.usecase.donation + +import com.ssafy.tiggle.domain.entity.donation.DonationRank +import com.ssafy.tiggle.domain.repository.DonationRepository +import javax.inject.Inject + +class GetUniversityRankingUseCase @Inject constructor( + private val donationRepository: DonationRepository +) { + suspend operator fun invoke(): Result> { + return donationRepository.getUniversityRanking() + } +} diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/navigation/NavigationGraph.kt b/app/src/main/java/com/ssafy/tiggle/presentation/navigation/NavigationGraph.kt index 5bece68..8991972 100644 --- a/app/src/main/java/com/ssafy/tiggle/presentation/navigation/NavigationGraph.kt +++ b/app/src/main/java/com/ssafy/tiggle/presentation/navigation/NavigationGraph.kt @@ -17,6 +17,7 @@ import androidx.navigation3.ui.rememberSceneSetupNavEntryDecorator import com.ssafy.tiggle.presentation.ui.auth.login.LoginScreen import com.ssafy.tiggle.presentation.ui.auth.signup.SignUpScreen import com.ssafy.tiggle.presentation.ui.donation.DonationHistoryScreen +import com.ssafy.tiggle.presentation.ui.donation.DonationRankingScreen import com.ssafy.tiggle.presentation.ui.donation.DonationStatusScreen import com.ssafy.tiggle.presentation.ui.dutchpay.CreateDutchPayScreen import com.ssafy.tiggle.presentation.ui.dutchpay.DutchpayRecieveScreen @@ -122,7 +123,7 @@ fun NavigationGraph( navBackStack.add(Screen.DonationStatus) }, onDonationRankingClick = { - // TODO: 기부 랭킹 화면 구현 시 추가 + navBackStack.add(Screen.DonationRanking) } ) } @@ -220,6 +221,12 @@ fun NavigationGraph( ) } + is Screen.DonationRanking -> NavEntry(key) { + DonationRankingScreen( + onNavigateBack = { navBackStack.removeLastOrNull() } + ) + } + is Screen.DutchPayStatus -> NavEntry(key) { DutchPayStatusScreen( onBackClick = { navBackStack.removeLastOrNull() }, diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/navigation/Screen.kt b/app/src/main/java/com/ssafy/tiggle/presentation/navigation/Screen.kt index ccd2739..750dbdf 100644 --- a/app/src/main/java/com/ssafy/tiggle/presentation/navigation/Screen.kt +++ b/app/src/main/java/com/ssafy/tiggle/presentation/navigation/Screen.kt @@ -49,6 +49,9 @@ sealed interface Screen : NavKey { @Serializable object DonationStatus : Screen + @Serializable + object DonationRanking : Screen + @Serializable data class DutchpayRecieve(val dutchPayId: Long) : Screen diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/ui/donation/DonationRankingScreen.kt b/app/src/main/java/com/ssafy/tiggle/presentation/ui/donation/DonationRankingScreen.kt new file mode 100644 index 0000000..225a1c2 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/presentation/ui/donation/DonationRankingScreen.kt @@ -0,0 +1,337 @@ +package com.ssafy.tiggle.presentation.ui.donation + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import com.ssafy.tiggle.R +import com.ssafy.tiggle.domain.entity.donation.DonationRank +import com.ssafy.tiggle.presentation.ui.components.TiggleButton +import com.ssafy.tiggle.presentation.ui.components.TiggleButtonVariant +import com.ssafy.tiggle.presentation.ui.components.TiggleScreenLayout +import com.ssafy.tiggle.presentation.ui.theme.TiggleBlue +import com.ssafy.tiggle.presentation.ui.theme.TiggleGrayLight +import com.ssafy.tiggle.presentation.ui.theme.TiggleGrayText + +@Composable +fun DonationRankingScreen( + onNavigateBack: () -> Unit, + viewModel: DonationRankingViewModel = hiltViewModel() +) { + val uiState by viewModel.uiState.collectAsState() + + TiggleScreenLayout( + title = "랭킹", + showBackButton = true, + onBackClick = onNavigateBack, + enableScroll = false, + contentPadding = PaddingValues(0.dp) + ) { + Column( + modifier = Modifier.fillMaxSize() + ) { + // 서브타이틀 + Text( + text = "티끌이 만든 태산을 확인해보세요", + fontSize = 14.sp, + color = TiggleGrayText, + fontWeight = FontWeight.Normal, + modifier = Modifier.padding(horizontal = 32.dp, vertical = 16.dp) + ) + + // 탭 네비게이션 + RankingTabNavigation( + selectedTab = uiState.selectedTab, + onTabSelected = viewModel::onTabSelected + ) + + // 랭킹 리스트 + when { + uiState.isLoading -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = TiggleBlue) + } + } + + uiState.error != null -> { + ErrorContent( + error = uiState.error!!, + onRetry = viewModel::retry + ) + } + + else -> { + val rankingList = when (uiState.selectedTab) { + RankingTab.UNIVERSITY -> uiState.universityRanking + RankingTab.DEPARTMENT -> uiState.departmentRanking + } + + RankingList( + rankingList = rankingList, + selectedTab = uiState.selectedTab + ) + } + } + } + } +} + +@Composable +fun RankingTabNavigation( + selectedTab: RankingTab, + onTabSelected: (RankingTab) -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp, vertical = 16.dp) + .clip(RoundedCornerShape(8.dp)) + .background(TiggleGrayLight) + .padding(4.dp) + ) { + RankingTab( + text = "학교 기부 랭킹", + isSelected = selectedTab == RankingTab.UNIVERSITY, + onClick = { onTabSelected(RankingTab.UNIVERSITY) }, + modifier = Modifier.weight(1f) + ) + RankingTab( + text = "학과 기부 랭킹", + isSelected = selectedTab == RankingTab.DEPARTMENT, + onClick = { onTabSelected(RankingTab.DEPARTMENT) }, + modifier = Modifier.weight(1f) + ) + } +} + +@Composable +fun RankingTab( + text: String, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .clip(RoundedCornerShape(6.dp)) + .background( + if (isSelected) Color.White else Color.Transparent + ) + .padding(vertical = 12.dp) + .clickable { onClick() }, + contentAlignment = Alignment.Center + ) { + Text( + text = text, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + color = if (isSelected) TiggleBlue else TiggleGrayText, + textAlign = TextAlign.Center + ) + } +} + +@Composable +fun RankingList( + rankingList: List, + selectedTab: RankingTab +) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 32.dp), + ) { + items(rankingList) { rank -> + RankingItem( + rank = rank, + modifier = Modifier.fillMaxWidth() + ) + } + } +} + +@Composable +fun RankingItem( + rank: DonationRank, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .padding(vertical = 0.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // 순위 표시 + RankingBadge(rank = rank.rank) + + Spacer(modifier = Modifier.width(16.dp)) + + // 이름 + Text( + text = rank.name, + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + color = Color.Black, + modifier = Modifier.weight(1f) + ) + + // 금액 + Text( + text = "${rank.amount.toString().reversed().chunked(3).joinToString(",").reversed()}원", + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + color = Color.Black + ) + } + + HorizontalDivider( + modifier = Modifier.padding(top = 16.dp, bottom = 16.dp), + thickness = 1.dp, + color = TiggleGrayLight + ) +} + +@Composable +fun RankingBadge(rank: Int) { + when (rank) { + 1 -> { + // 금메달 + Image( + painter = painterResource(id = R.drawable.gold_medal), + contentDescription = "금메달", + modifier = Modifier.size(40.dp) + ) + } + + 2 -> { + // 은메달 + Image( + painter = painterResource(id = R.drawable.silver_medal), + contentDescription = "은메달", + modifier = Modifier.size(40.dp) + ) + } + + 3 -> { + // 동메달 + Image( + painter = painterResource(id = R.drawable.bronze_medal), + contentDescription = "동메달", + modifier = Modifier.size(40.dp) + ) + } + + else -> { + // 일반 순위 + Box( + modifier = Modifier + .size(40.dp), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .size(32.dp) + .clip(CircleShape) + .background(Color(0xFFD9D9D9)), + contentAlignment = Alignment.Center + ) { + Text( + text = rank.toString(), + color = Color.White, + fontSize = 14.sp, + fontWeight = FontWeight.Medium + ) + } + } + + } + } +} + +@Composable +fun ErrorContent( + error: String, + onRetry: () -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = error, + fontSize = 16.sp, + color = TiggleGrayText, + textAlign = TextAlign.Center, + modifier = Modifier.padding(bottom = 16.dp) + ) + + TiggleButton( + text = "다시 시도", + onClick = onRetry, + variant = TiggleButtonVariant.Primary + ) + } +} + +@Preview(showBackground = true) +@Composable +fun DonationRankingScreenPreview() { + val previewRanking = listOf( + DonationRank(1, "헤이영 대학교", 239285290), + DonationRank(2, "싸피 대학교", 239285290), + DonationRank(3, "싸피 대학교", 239285290), + DonationRank(4, "구미 대학교", 239285290) + ) + + Column( + modifier = Modifier + .fillMaxSize() + .background(Color.White) + .padding(16.dp) + ) { + RankingTabNavigation( + selectedTab = RankingTab.UNIVERSITY, + onTabSelected = {} + ) + + RankingList( + rankingList = previewRanking, + selectedTab = RankingTab.UNIVERSITY + ) + } +} diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/ui/donation/DonationRankingUiState.kt b/app/src/main/java/com/ssafy/tiggle/presentation/ui/donation/DonationRankingUiState.kt new file mode 100644 index 0000000..108e939 --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/presentation/ui/donation/DonationRankingUiState.kt @@ -0,0 +1,16 @@ +package com.ssafy.tiggle.presentation.ui.donation + +import com.ssafy.tiggle.domain.entity.donation.DonationRank + +data class DonationRankingUiState( + val isLoading: Boolean = false, + val universityRanking: List = emptyList(), + val departmentRanking: List = emptyList(), + val selectedTab: RankingTab = RankingTab.UNIVERSITY, + val error: String? = null +) + +enum class RankingTab { + UNIVERSITY, + DEPARTMENT +} diff --git a/app/src/main/java/com/ssafy/tiggle/presentation/ui/donation/DonationRankingViewModel.kt b/app/src/main/java/com/ssafy/tiggle/presentation/ui/donation/DonationRankingViewModel.kt new file mode 100644 index 0000000..a5337ba --- /dev/null +++ b/app/src/main/java/com/ssafy/tiggle/presentation/ui/donation/DonationRankingViewModel.kt @@ -0,0 +1,101 @@ +package com.ssafy.tiggle.presentation.ui.donation + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.ssafy.tiggle.domain.usecase.donation.GetDepartmentRankingUseCase +import com.ssafy.tiggle.domain.usecase.donation.GetUniversityRankingUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class DonationRankingViewModel @Inject constructor( + private val getUniversityRankingUseCase: GetUniversityRankingUseCase, + private val getDepartmentRankingUseCase: GetDepartmentRankingUseCase +) : ViewModel() { + + private val _uiState = MutableStateFlow(DonationRankingUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadUniversityRanking() + } + + fun onTabSelected(tab: RankingTab) { + _uiState.update { it.copy(selectedTab = tab) } + + when (tab) { + RankingTab.UNIVERSITY -> { + if (_uiState.value.universityRanking.isEmpty()) { + loadUniversityRanking() + } + } + RankingTab.DEPARTMENT -> { + if (_uiState.value.departmentRanking.isEmpty()) { + loadDepartmentRanking() + } + } + } + } + + private fun loadUniversityRanking() { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, error = null) } + + getUniversityRankingUseCase().fold( + onSuccess = { ranking -> + _uiState.update { + it.copy( + isLoading = false, + universityRanking = ranking + ) + } + }, + onFailure = { exception -> + _uiState.update { + it.copy( + isLoading = false, + error = exception.message ?: "대학교 랭킹을 불러오는데 실패했습니다." + ) + } + } + ) + } + } + + private fun loadDepartmentRanking() { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, error = null) } + + getDepartmentRankingUseCase().fold( + onSuccess = { ranking -> + _uiState.update { + it.copy( + isLoading = false, + departmentRanking = ranking + ) + } + }, + onFailure = { exception -> + _uiState.update { + it.copy( + isLoading = false, + error = exception.message ?: "학과 랭킹을 불러오는데 실패했습니다." + ) + } + } + ) + } + } + + fun retry() { + when (_uiState.value.selectedTab) { + RankingTab.UNIVERSITY -> loadUniversityRanking() + RankingTab.DEPARTMENT -> loadDepartmentRanking() + } + } +} diff --git a/app/src/main/res/drawable/bronze_medal.webp b/app/src/main/res/drawable/bronze_medal.webp new file mode 100644 index 0000000..fdb02a8 Binary files /dev/null and b/app/src/main/res/drawable/bronze_medal.webp differ diff --git a/app/src/main/res/drawable/gold_medal.webp b/app/src/main/res/drawable/gold_medal.webp new file mode 100644 index 0000000..5b326ad Binary files /dev/null and b/app/src/main/res/drawable/gold_medal.webp differ diff --git a/app/src/main/res/drawable/silver_medal.webp b/app/src/main/res/drawable/silver_medal.webp new file mode 100644 index 0000000..1994cf2 Binary files /dev/null and b/app/src/main/res/drawable/silver_medal.webp differ