diff --git a/composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/request/LogoutRequestDto.kt b/composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/request/LogoutRequestDto.kt new file mode 100644 index 0000000..66880c9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/request/LogoutRequestDto.kt @@ -0,0 +1,10 @@ +package org.whosin.client.data.dto.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class LogoutRequestDto( + @SerialName("refreshToken") + val refreshToken: String +) diff --git a/composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/response/LogoutResponseDto.kt b/composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/response/LogoutResponseDto.kt new file mode 100644 index 0000000..10065d3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/data/dto/response/LogoutResponseDto.kt @@ -0,0 +1,16 @@ +package org.whosin.client.data.dto.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class LogoutResponseDto( + @SerialName("success") + val success: Boolean, + @SerialName("status") + val status: Int, + @SerialName("message") + val message: String, + @SerialName("data") + val data: String? = null +) diff --git a/composeApp/src/commonMain/kotlin/org/whosin/client/data/remote/RemoteAuthDataSource.kt b/composeApp/src/commonMain/kotlin/org/whosin/client/data/remote/RemoteAuthDataSource.kt index a72ae98..54c07e9 100644 --- a/composeApp/src/commonMain/kotlin/org/whosin/client/data/remote/RemoteAuthDataSource.kt +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/data/remote/RemoteAuthDataSource.kt @@ -11,11 +11,13 @@ import org.whosin.client.data.dto.request.EmailValidationRequestDto import org.whosin.client.data.dto.request.EmailVerificationRequestDto import org.whosin.client.data.dto.request.FindPasswordRequestDto import org.whosin.client.data.dto.request.LoginRequestDto +import org.whosin.client.data.dto.request.LogoutRequestDto import org.whosin.client.data.dto.request.SignupRequestDto import org.whosin.client.data.dto.response.EmailVerificationResponseDto import org.whosin.client.data.dto.response.ErrorResponseDto import org.whosin.client.data.dto.response.FindPasswordResponseDto import org.whosin.client.data.dto.response.LoginResponseDto +import org.whosin.client.data.dto.response.LogoutResponseDto import org.whosin.client.data.dto.response.SignupResponseDto class RemoteAuthDataSource( @@ -161,4 +163,42 @@ class RemoteAuthDataSource( ApiResult.Error(message = t.message, cause = t) } } + + suspend fun logout(refreshToken: String): ApiResult { + return try { + val response: HttpResponse = client + .post("auth/logout") { + setBody( + LogoutRequestDto( + refreshToken = refreshToken + ) + ) + } + if (response.status.isSuccess()) { + ApiResult.Success( + data = response.body(), + statusCode = response.status.value + ) + } else { + // 에러 응답 파싱 시도 + try { + val errorResponse: ErrorResponseDto = response.body() + ApiResult.Error( + code = response.status.value, + message = errorResponse.message + ) + } catch (e: Exception) { + // 파싱 실패 시 기본 에러 메시지 + ApiResult.Error( + code = response.status.value, + message = "HTTP Error: ${response.status.value}" + ) + } + } + } catch (t: Throwable) { + ApiResult.Error(message = t.message, cause = t) + } + } + + // TODO: 회원탈퇴 } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/whosin/client/data/repository/AuthRepository.kt b/composeApp/src/commonMain/kotlin/org/whosin/client/data/repository/AuthRepository.kt index 0d8c97f..e124553 100644 --- a/composeApp/src/commonMain/kotlin/org/whosin/client/data/repository/AuthRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/data/repository/AuthRepository.kt @@ -6,6 +6,7 @@ import org.whosin.client.data.dto.response.LoginResponseDto import org.whosin.client.data.dto.response.EmailVerificationResponseDto import org.whosin.client.data.dto.response.SignupResponseDto import org.whosin.client.data.dto.response.FindPasswordResponseDto +import org.whosin.client.data.dto.response.LogoutResponseDto class AuthRepository( private val dataSource: RemoteAuthDataSource @@ -37,4 +38,7 @@ class AuthRepository( suspend fun sendPasswordResetEmail(email: String): ApiResult = dataSource.sendPasswordResetEmail(email) + suspend fun logout(refreshToken: String): ApiResult = + dataSource.logout(refreshToken) + } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/mypage/MyPageScreen.kt b/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/mypage/MyPageScreen.kt index ae8be84..aaad255 100644 --- a/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/mypage/MyPageScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/mypage/MyPageScreen.kt @@ -1,20 +1,30 @@ package org.whosin.client.presentation.mypage import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +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.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Alignment +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextStyle @@ -25,6 +35,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.ui.tooling.preview.Preview import org.koin.compose.viewmodel.koinViewModel +import org.whosin.client.data.dto.response.ClubData import org.whosin.client.presentation.component.CommonBackHandler import org.whosin.client.presentation.mypage.component.MyClubComponent import org.whosin.client.presentation.mypage.component.MyPageButton @@ -43,6 +54,8 @@ fun MyPageScreen( ) { val viewModel: MyPageViewModel = koinViewModel() val uiState = viewModel.uiState.collectAsStateWithLifecycle().value + + var showDeleteDialog by remember { mutableStateOf(false) } // MyPage로 돌아올 때마다 수정 모드 해제 및 데이터 새로고침 LaunchedEffect(Unit) { @@ -61,14 +74,17 @@ fun MyPageScreen( } } - Box( + Column( modifier = modifier .fillMaxSize() .background(Color.White) .padding(top = 16.dp, start = 16.dp, end = 16.dp) ) { Column( - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() + .weight(1f) // MyPageButton 위 공간만 사용 + .verticalScroll(rememberScrollState()) ) { MyPageTopAppBar(onNavigateBack) Spacer(modifier = Modifier.size(16.dp)) @@ -113,9 +129,7 @@ fun MyPageScreen( // 내 동아리 / 학과 목록 MyClubComponent( modifier = Modifier - .fillMaxWidth() - .weight(1f) - .padding(bottom = 72.dp), + .fillMaxWidth(), isEditable = uiState.isEditable, myClubs = uiState.clubs, onDeleteClub = { clubId -> @@ -123,9 +137,49 @@ fun MyPageScreen( }, onNavigateToAddClub = onNavigateToAddClub ) + Spacer(modifier = Modifier.size(24.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 32.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ){ + Button( + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(10.dp), + onClick = { + viewModel.logout() + }, + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFFF5F5F5), + contentColor = Color.Black + ) + ){ + Text( + text = "로그아웃", + fontWeight = FontWeight.Medium + ) + } + Button( + modifier = Modifier.weight(1f), + shape = RoundedCornerShape(10.dp), + onClick = { + showDeleteDialog = true + }, + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFFFF3636), + contentColor = Color.White + ) + ){ + Text( + text = "회원 탈퇴", + fontWeight = FontWeight.Medium + ) + } + } } - - // 내 정보 수정 버튼 - 하단에 고정 + + // 내 정보 수정 버튼 MyPageButton( onClick = { if (uiState.isEditable) { @@ -139,10 +193,63 @@ fun MyPageScreen( ), enabled = uiState.nickname.isNotEmpty(), modifier = Modifier - .align(Alignment.BottomCenter) + .fillMaxWidth() .padding(bottom = 52.dp) ) } + + // 회원 탈퇴 확인 다이얼로그 + if (showDeleteDialog) { + AlertDialog( + containerColor = Color(0xFFFFFFFF), + onDismissRequest = { showDeleteDialog = false }, + title = { + Text( + text = "회원 탈퇴", + fontWeight = FontWeight.SemiBold, + fontSize = 18.sp + ) + }, + text = { + Text( + text = "탈퇴 후에는 모든 데이터가 삭제되며 복구할 수 없습니다.", + fontSize = 16.sp + ) + }, + confirmButton = { + Button( + shape = RoundedCornerShape(10.dp), + onClick = { + showDeleteDialog = false + // TODO: 회원 탈퇴 api 연결 + }, + colors = ButtonDefaults.buttonColors( + containerColor = Color(0xFFFF3636), + contentColor = Color.White + ) + ) { + Text( + text = "확인", + fontWeight = FontWeight.Medium + ) + } + }, + dismissButton = { + OutlinedButton( + shape = RoundedCornerShape(10.dp), + onClick = { showDeleteDialog = false }, + colors = ButtonDefaults.outlinedButtonColors( + contentColor = Color.Black + ) + ) { + Text( + text = "취소", + fontWeight = FontWeight.Medium + ) + } + } + ) + } } diff --git a/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/mypage/MyPageViewModel.kt b/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/mypage/MyPageViewModel.kt index fe98639..85845f7 100644 --- a/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/mypage/MyPageViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/mypage/MyPageViewModel.kt @@ -7,8 +7,11 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import org.whosin.client.core.auth.TokenExpiredManager +import org.whosin.client.core.datastore.TokenManager import org.whosin.client.core.network.ApiResult import org.whosin.client.data.dto.response.ClubData +import org.whosin.client.data.repository.AuthRepository import org.whosin.client.data.repository.MemberRepository data class MyPageUiState( @@ -20,7 +23,9 @@ data class MyPageUiState( ) class MyPageViewModel( - private val repository: MemberRepository + private val memberRepository: MemberRepository, + private val authRepository: AuthRepository, + private val tokenManager: TokenManager ): ViewModel() { private val _uiState = MutableStateFlow(MyPageUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -43,7 +48,7 @@ class MyPageViewModel( fun getMyInfo() { viewModelScope.launch { _uiState.update{ it.copy(isLoading = true) } - when (val result = repository.getMyInfo()) { + when (val result = memberRepository.getMyInfo()) { is ApiResult.Success -> { val response = result.data.data _uiState.update { it -> @@ -79,7 +84,7 @@ class MyPageViewModel( val newClubs = clubList?.map { it.clubId } - when (val result = repository.updateMyInfo(newNickName = newNickName, clubList = newClubs)) { + when (val result = memberRepository.updateMyInfo(newNickName = newNickName, clubList = newClubs)) { is ApiResult.Success -> { _uiState.update { it.copy(isEditable = false) @@ -108,4 +113,33 @@ class MyPageViewModel( println("MyPageViewModel: 클럽 삭제 - clubId: $clubId") } + // 로그아웃 + fun logout(){ + viewModelScope.launch { + val refreshToken = tokenManager.getRefreshToken() + if (refreshToken.isNullOrEmpty()) { + // 리프레시 토큰이 없으면 바로 토큰 삭제 및 로그인 화면으로 이동 + tokenManager.clearToken() + TokenExpiredManager.setTokenExpired() + return@launch + } + + when (val result = authRepository.logout(refreshToken)) { + is ApiResult.Success -> { + println("MyPageViewModel: 로그아웃 성공") + // 토큰 삭제 및 로그인 화면으로 이동 + tokenManager.clearToken() + TokenExpiredManager.setTokenExpired() + } + is ApiResult.Error -> { + _uiState.value = _uiState.value.copy( + errorMessage = result.message ?: "로그아웃에 실패했습니다." + ) + println("MyPageViewModel: 로그아웃 실패 - ${result.message}") + } + } + } + } + + // TODO: 회원 탈퇴 } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/mypage/component/MyClubComponent.kt b/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/mypage/component/MyClubComponent.kt index 3d9577a..6ff3625 100644 --- a/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/mypage/component/MyClubComponent.kt +++ b/composeApp/src/commonMain/kotlin/org/whosin/client/presentation/mypage/component/MyClubComponent.kt @@ -4,16 +4,14 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border 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.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -67,25 +65,30 @@ fun MyClubComponent( } } Spacer(modifier = Modifier.size(20.dp)) - LazyColumn( + Box( modifier = Modifier .fillMaxWidth() .border( width = 1.dp, color = Color(0xFFE5E5E5), shape = RoundedCornerShape(20.dp) - ), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(24.dp) + ) ) { - items(myClubs) { clubData -> - MyClubItem( - modifier = Modifier, - clubName = clubData.clubName, - isEditable = isEditable, - ) { - println("clicked clubId : ${clubData.clubId}") - onDeleteClub(clubData.clubId) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + myClubs.forEach { clubData -> + MyClubItem( + modifier = Modifier, + clubName = clubData.clubName, + isEditable = isEditable, + ) { + println("clicked clubId : ${clubData.clubId}") + onDeleteClub(clubData.clubId) + } } } }