diff --git a/app/src/main/java/com/apptive/japkor/data/api/MatchingService.kt b/app/src/main/java/com/apptive/japkor/data/api/MatchingService.kt index a3c4c4a..207bfea 100644 --- a/app/src/main/java/com/apptive/japkor/data/api/MatchingService.kt +++ b/app/src/main/java/com/apptive/japkor/data/api/MatchingService.kt @@ -1,6 +1,7 @@ package com.apptive.japkor.data.api import com.apptive.japkor.data.model.AiSummaryResponse +import com.apptive.japkor.data.model.MalePendingMatchingResponse import com.apptive.japkor.data.model.MatchingResponse import retrofit2.Call import retrofit2.http.GET @@ -14,6 +15,15 @@ interface MatchingService { @GET("members/matchings/female") fun getFemaleMatchings(): Call> + @GET("members/matchings/male/pendingMatching") + fun getMalePendingMatchings(): Call> + @POST("members/matchings/{matchingId}/select") fun femaleSelectMatching(@Path("matchingId") matchingId: Long): Call + + @POST("members/matchings/{matchingId}/accept") + fun maleAcceptMatching(@Path("matchingId") matchingId: Long): Call + + @POST("members/matchings/{matchingId}/reject") + fun maleRejectMatching(@Path("matchingId") matchingId: Long): Call } diff --git a/app/src/main/java/com/apptive/japkor/data/model/MalePendingMatchingResponse.kt b/app/src/main/java/com/apptive/japkor/data/model/MalePendingMatchingResponse.kt new file mode 100644 index 0000000..c6805ab --- /dev/null +++ b/app/src/main/java/com/apptive/japkor/data/model/MalePendingMatchingResponse.kt @@ -0,0 +1,19 @@ +package com.apptive.japkor.data.model + +import com.google.gson.annotations.SerializedName + +data class MalePendingMatchingResponse( + val matchingId: Long, + val femaleMemberId: Long, + val femaleName: String, + val femaleEmail: String, + val height: Int?, + val weight: Int?, + val residenceArea: String?, + val aiSummary: String?, + val status: String, + val createdAt: String, + val thumbnailImageUrl: String?, + @SerializedName(value = "profileImageUrls", alternate = ["profileImageUrl"]) + val profileImageUrls: List? +) diff --git a/app/src/main/java/com/apptive/japkor/ui/main/MainRouteScreen.kt b/app/src/main/java/com/apptive/japkor/ui/main/MainRouteScreen.kt index 7932387..27c8177 100644 --- a/app/src/main/java/com/apptive/japkor/ui/main/MainRouteScreen.kt +++ b/app/src/main/java/com/apptive/japkor/ui/main/MainRouteScreen.kt @@ -132,6 +132,10 @@ fun MainRouteScreen( } } + LaunchedEffect(gender) { + viewModel.fetchMatchingsForGender(gender) + } + LaunchedEffect(matchings.size) { if (matchings.isNotEmpty() && pagerState.currentPage >= matchings.size) { pagerState.scrollToPage(0) @@ -239,6 +243,8 @@ fun MainRouteScreen( onShowDetails = { viewModel.showDetails(it) }, onNoMatch = { viewModel.noMatchSelected() }, onConfirm = { viewModel.selectMatching(it) }, + onAccept = { viewModel.acceptMatching(it) }, + onReject = { viewModel.rejectMatching(it) }, modifier = Modifier.fillMaxSize() ) } diff --git a/app/src/main/java/com/apptive/japkor/ui/main/home/HomeMatching.kt b/app/src/main/java/com/apptive/japkor/ui/main/home/HomeMatching.kt new file mode 100644 index 0000000..8cd297d --- /dev/null +++ b/app/src/main/java/com/apptive/japkor/ui/main/home/HomeMatching.kt @@ -0,0 +1,13 @@ +package com.apptive.japkor.ui.main.home + +data class HomeMatching( + val matchingId: Long, + val memberId: Long, + val name: String, + val email: String, + val height: Int?, + val weight: Int?, + val residenceArea: String?, + val matchingOrder: Int?, + val status: String +) diff --git a/app/src/main/java/com/apptive/japkor/ui/main/home/HomeScreen.kt b/app/src/main/java/com/apptive/japkor/ui/main/home/HomeScreen.kt index ce36f83..bdf92d5 100644 --- a/app/src/main/java/com/apptive/japkor/ui/main/home/HomeScreen.kt +++ b/app/src/main/java/com/apptive/japkor/ui/main/home/HomeScreen.kt @@ -34,7 +34,6 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.util.lerp import com.apptive.japkor.R -import com.apptive.japkor.data.model.MatchingResponse import com.apptive.japkor.ui.components.CustomText import com.apptive.japkor.ui.components.CustomTextType import com.apptive.japkor.ui.theme.CustomColor @@ -45,13 +44,17 @@ fun HomeScreen( uiState: HomeUiState, pagerState: PagerState, canSelectMatching: Boolean, - onShowDetails: (MatchingResponse) -> Unit, + onShowDetails: (HomeMatching) -> Unit, onNoMatch: () -> Unit, onConfirm: (Long) -> Unit, + onAccept: (Long) -> Unit, + onReject: (Long) -> Unit, modifier: Modifier = Modifier ) { val matchings = uiState.matchings val selectedMatching = uiState.selectedMatching + val counterpartLabel = if (canSelectMatching) "남성" else "여성" + val matchingTitle = "매칭된 $counterpartLabel" Box( modifier = modifier @@ -65,6 +68,16 @@ fun HomeScreen( if (canSelectMatching) { onConfirm(selectedMatching.matchingId) } + }, + onAccept = { + if (!canSelectMatching) { + onAccept(selectedMatching.matchingId) + } + }, + onReject = { + if (!canSelectMatching) { + onReject(selectedMatching.matchingId) + } } ) } @@ -82,7 +95,9 @@ fun HomeScreen( matchings = matchings, pagerState = pagerState, onShowDetails = onShowDetails, - onNoMatch = onNoMatch + onNoMatch = onNoMatch, + title = matchingTitle, + showNoMatchButton = canSelectMatching ) } } @@ -184,10 +199,12 @@ private fun WaitingContent( @Composable @OptIn(ExperimentalFoundationApi::class) private fun MatchingCarouselContent( - matchings: List, + matchings: List, pagerState: PagerState, - onShowDetails: (MatchingResponse) -> Unit, - onNoMatch: () -> Unit + onShowDetails: (HomeMatching) -> Unit, + onNoMatch: () -> Unit, + title: String, + showNoMatchButton: Boolean ) { val currentMatching = matchings.getOrNull(pagerState.currentPage) @@ -198,7 +215,7 @@ private fun MatchingCarouselContent( horizontalAlignment = Alignment.CenterHorizontally ) { CustomText( - text = "매칭된 남성", + text = title, type = CustomTextType.title, color = CustomColor.black ) @@ -254,29 +271,31 @@ private fun MatchingCarouselContent( color = Color.White ) } - Spacer(modifier = Modifier.height(12.dp)) - Button( - onClick = onNoMatch, - modifier = Modifier - .fillMaxWidth() - .height(50.dp), - colors = ButtonDefaults.buttonColors( - containerColor = CustomColor.gray100 - ), - shape = RoundedCornerShape(16.dp) - ) { - CustomText( - text = "마음에 드는 상대가 없어요", - type = CustomTextType.body, - color = CustomColor.black - ) + if (showNoMatchButton) { + Spacer(modifier = Modifier.height(12.dp)) + Button( + onClick = onNoMatch, + modifier = Modifier + .fillMaxWidth() + .height(50.dp), + colors = ButtonDefaults.buttonColors( + containerColor = CustomColor.gray100 + ), + shape = RoundedCornerShape(16.dp) + ) { + CustomText( + text = "마음에 드는 상대가 없어요", + type = CustomTextType.body, + color = CustomColor.black + ) + } } } } @Composable private fun MatchingCard( - matching: MatchingResponse, + matching: HomeMatching, modifier: Modifier = Modifier ) { Card( @@ -306,7 +325,7 @@ private fun MatchingCard( } Spacer(modifier = Modifier.height(24.dp)) CustomText( - text = matching.maleName, + text = matching.name, type = CustomTextType.headline, color = CustomColor.black, textAlign = TextAlign.Center @@ -343,9 +362,11 @@ private fun PagerIndicator(total: Int, current: Int) { @Composable private fun MatchingDetailContent( - matching: MatchingResponse, + matching: HomeMatching, canSelectMatching: Boolean, - onConfirm: () -> Unit + onConfirm: () -> Unit, + onAccept: () -> Unit, + onReject: () -> Unit ) { Column( modifier = Modifier @@ -361,32 +382,71 @@ private fun MatchingDetailContent( ProfileHeader(matching = matching) } item { - DetailCard(matching = matching) + DetailCard( + matching = matching, + counterpartLabel = if (canSelectMatching) "남성" else "여성" + ) } } - Button( - onClick = onConfirm, - enabled = canSelectMatching, - modifier = Modifier - .fillMaxWidth() - .height(50.dp), - colors = ButtonDefaults.buttonColors( - containerColor = CustomColor.primary600, - disabledContainerColor = CustomColor.primary300 - ), - shape = RoundedCornerShape(16.dp) - ) { - CustomText( - text = "마음에 들어요 매칭해주세요", - type = CustomTextType.body, - color = Color.White - ) + if (canSelectMatching) { + Button( + onClick = onConfirm, + modifier = Modifier + .fillMaxWidth() + .height(50.dp), + colors = ButtonDefaults.buttonColors( + containerColor = CustomColor.primary600, + disabledContainerColor = CustomColor.primary300 + ), + shape = RoundedCornerShape(16.dp) + ) { + CustomText( + text = "마음에 들어요 매칭해주세요", + type = CustomTextType.body, + color = Color.White + ) + } + } else { + Button( + onClick = onAccept, + modifier = Modifier + .fillMaxWidth() + .height(50.dp), + colors = ButtonDefaults.buttonColors( + containerColor = CustomColor.primary600, + disabledContainerColor = CustomColor.primary300 + ), + shape = RoundedCornerShape(16.dp) + ) { + CustomText( + text = "수락할래요", + type = CustomTextType.body, + color = Color.White + ) + } + Spacer(modifier = Modifier.height(12.dp)) + Button( + onClick = onReject, + modifier = Modifier + .fillMaxWidth() + .height(50.dp), + colors = ButtonDefaults.buttonColors( + containerColor = CustomColor.gray100 + ), + shape = RoundedCornerShape(16.dp) + ) { + CustomText( + text = "거절할래요", + type = CustomTextType.body, + color = CustomColor.black + ) + } } } } @Composable -private fun ProfileHeader(matching: MatchingResponse) { +private fun ProfileHeader(matching: HomeMatching) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically @@ -407,12 +467,12 @@ private fun ProfileHeader(matching: MatchingResponse) { Spacer(modifier = Modifier.width(16.dp)) Column { CustomText( - text = matching.maleName, + text = matching.name, type = CustomTextType.headline, color = CustomColor.black ) CustomText( - text = matching.maleEmail, + text = matching.email, type = CustomTextType.body, color = CustomColor.gray400 ) @@ -421,7 +481,10 @@ private fun ProfileHeader(matching: MatchingResponse) { } @Composable -private fun DetailCard(matching: MatchingResponse) { +private fun DetailCard( + matching: HomeMatching, + counterpartLabel: String +) { Card( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(20.dp), @@ -439,13 +502,15 @@ private fun DetailCard(matching: MatchingResponse) { color = CustomColor.black ) DetailItem(label = "매칭 ID", value = matching.matchingId.toString()) - DetailItem(label = "남성 회원 ID", value = matching.maleMemberId.toString()) - DetailItem(label = "이름", value = matching.maleName) - DetailItem(label = "이메일", value = matching.maleEmail) + DetailItem(label = "${counterpartLabel} 회원 ID", value = matching.memberId.toString()) + DetailItem(label = "이름", value = matching.name) + DetailItem(label = "이메일", value = matching.email) DetailItem(label = "키", value = formatHeight(matching.height)) DetailItem(label = "몸무게", value = formatWeight(matching.weight)) DetailItem(label = "거주지역", value = formatText(matching.residenceArea)) - DetailItem(label = "매칭 순서", value = matching.matchingOrder.toString()) + matching.matchingOrder?.let { + DetailItem(label = "매칭 순서", value = it.toString()) + } DetailItem(label = "상태", value = matching.status) } } diff --git a/app/src/main/java/com/apptive/japkor/ui/main/home/HomeViewModel.kt b/app/src/main/java/com/apptive/japkor/ui/main/home/HomeViewModel.kt index 5f37e02..95be84a 100644 --- a/app/src/main/java/com/apptive/japkor/ui/main/home/HomeViewModel.kt +++ b/app/src/main/java/com/apptive/japkor/ui/main/home/HomeViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.apptive.japkor.data.api.MatchingService import com.apptive.japkor.data.api.ServiceFactory +import com.apptive.japkor.data.model.MalePendingMatchingResponse import com.apptive.japkor.data.model.MatchingResponse import com.apptive.japkor.ui.components.ToastType import kotlinx.coroutines.flow.MutableSharedFlow @@ -23,8 +24,8 @@ sealed class HomeUiEvent { data class HomeUiState( val isLoading: Boolean = false, - val matchings: List = emptyList(), - val selectedMatching: MatchingResponse? = null, + val matchings: List = emptyList(), + val selectedMatching: HomeMatching? = null, val isWaiting: Boolean = false, val aiSummaryKo: String? = null, val aiSummaryJa: String? = null, @@ -43,11 +44,18 @@ class HomeViewModel( val events: SharedFlow = _events.asSharedFlow() init { - fetchFemaleMatchings() fetchAiSummary() } - fun fetchFemaleMatchings() { + fun fetchMatchingsForGender(gender: String) { + when { + gender.isBlank() -> return + gender == FEMALE_GENDER -> fetchFemaleMatchings() + gender == MALE_GENDER -> fetchMalePendingMatchings() + } + } + + private fun fetchFemaleMatchings() { if (_uiState.value.isWaiting) return viewModelScope.launch { @@ -57,7 +65,7 @@ class HomeViewModel( }.onSuccess { response -> Log.d(TAG, "getFemaleMatchings success=${response.isSuccessful} code=${response.code()}") if (response.isSuccessful) { - val data = response.body().orEmpty() + val data = response.body().orEmpty().map { it.toHomeMatching() } if (data.isEmpty()) { _uiState.update { it.copy( @@ -101,7 +109,66 @@ class HomeViewModel( } } - fun showDetails(matching: MatchingResponse) { + private fun fetchMalePendingMatchings() { + if (_uiState.value.isWaiting) return + + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + runCatching { + matchingService.getMalePendingMatchings().awaitResponse() + }.onSuccess { response -> + Log.d( + TAG, + "getMalePendingMatchings success=${response.isSuccessful} code=${response.code()}" + ) + if (response.isSuccessful) { + val body = response.body().orEmpty() + Log.d(TAG, "getMalePendingMatchings body=$body") + val data = body.map { it.toHomeMatching() } + if (data.isEmpty()) { + _uiState.update { + it.copy( + isLoading = false, + matchings = emptyList(), + selectedMatching = null, + isWaiting = true + ) + } + } else { + _uiState.update { + it.copy( + isLoading = false, + matchings = data, + selectedMatching = null, + isWaiting = false + ) + } + } + } else { + _uiState.update { + it.copy( + isLoading = false, + matchings = emptyList(), + selectedMatching = null, + isWaiting = true + ) + } + } + }.onFailure { throwable -> + Log.e(TAG, "getMalePendingMatchings failed", throwable) + _uiState.update { + it.copy( + isLoading = false, + matchings = emptyList(), + selectedMatching = null, + isWaiting = true + ) + } + } + } + } + + fun showDetails(matching: HomeMatching) { _uiState.update { it.copy(selectedMatching = matching) } } @@ -137,6 +204,48 @@ class HomeViewModel( } } + fun acceptMatching(matchingId: Long) { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + runCatching { + matchingService.maleAcceptMatching(matchingId).awaitResponse() + }.onSuccess { response -> + Log.d(TAG, "maleAcceptMatching success=${response.isSuccessful} code=${response.code()}") + if (response.isSuccessful) { + updateAfterMaleAction(matchingId) + } else { + _uiState.update { it.copy(isLoading = false) } + _events.tryEmit(HomeUiEvent.ShowToast("매칭 수락에 실패했습니다.")) + } + }.onFailure { throwable -> + Log.e(TAG, "maleAcceptMatching failed", throwable) + _uiState.update { it.copy(isLoading = false) } + _events.tryEmit(HomeUiEvent.ShowToast("네트워크 오류로 매칭을 수락할 수 없습니다.")) + } + } + } + + fun rejectMatching(matchingId: Long) { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + runCatching { + matchingService.maleRejectMatching(matchingId).awaitResponse() + }.onSuccess { response -> + Log.d(TAG, "maleRejectMatching success=${response.isSuccessful} code=${response.code()}") + if (response.isSuccessful) { + updateAfterMaleAction(matchingId) + } else { + _uiState.update { it.copy(isLoading = false) } + _events.tryEmit(HomeUiEvent.ShowToast("매칭 거절에 실패했습니다.")) + } + }.onFailure { throwable -> + Log.e(TAG, "maleRejectMatching failed", throwable) + _uiState.update { it.copy(isLoading = false) } + _events.tryEmit(HomeUiEvent.ShowToast("네트워크 오류로 매칭을 거절할 수 없습니다.")) + } + } + } + fun noMatchSelected() { _uiState.update { it.copy( @@ -148,6 +257,18 @@ class HomeViewModel( } } + private fun updateAfterMaleAction(matchingId: Long) { + _uiState.update { current -> + val remaining = current.matchings.filterNot { it.matchingId == matchingId } + current.copy( + isLoading = false, + matchings = remaining, + selectedMatching = null, + isWaiting = remaining.isEmpty() + ) + } + } + private fun fetchAiSummary() { viewModelScope.launch { _uiState.update { it.copy(isAiSummaryLoading = true, aiSummaryError = null) } @@ -209,5 +330,35 @@ class HomeViewModel( companion object { private const val TAG = "HomeViewModel" + private const val FEMALE_GENDER = "JAPANESE_FEMALE" + private const val MALE_GENDER = "KOREAN_MALE" } } + +private fun MatchingResponse.toHomeMatching(): HomeMatching { + return HomeMatching( + matchingId = matchingId, + memberId = maleMemberId, + name = maleName, + email = maleEmail, + height = height, + weight = weight, + residenceArea = residenceArea, + matchingOrder = matchingOrder, + status = status + ) +} + +private fun MalePendingMatchingResponse.toHomeMatching(): HomeMatching { + return HomeMatching( + matchingId = matchingId, + memberId = femaleMemberId, + name = femaleName, + email = femaleEmail, + height = height, + weight = weight, + residenceArea = residenceArea, + matchingOrder = null, + status = status + ) +}