Skip to content

Commit fc6a3bc

Browse files
authored
Merge pull request WhosInRoom#34 from casper-jr/feat/logout
[UI, FEAT] 로그아웃 기능 구현
2 parents 6a1b9d4 + a275ff8 commit fc6a3bc

File tree

7 files changed

+242
-28
lines changed

7 files changed

+242
-28
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package org.whosin.client.data.dto.request
2+
3+
import kotlinx.serialization.SerialName
4+
import kotlinx.serialization.Serializable
5+
6+
@Serializable
7+
data class LogoutRequestDto(
8+
@SerialName("refreshToken")
9+
val refreshToken: String
10+
)
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package org.whosin.client.data.dto.response
2+
3+
import kotlinx.serialization.SerialName
4+
import kotlinx.serialization.Serializable
5+
6+
@Serializable
7+
data class LogoutResponseDto(
8+
@SerialName("success")
9+
val success: Boolean,
10+
@SerialName("status")
11+
val status: Int,
12+
@SerialName("message")
13+
val message: String,
14+
@SerialName("data")
15+
val data: String? = null
16+
)

composeApp/src/commonMain/kotlin/org/whosin/client/data/remote/RemoteAuthDataSource.kt

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@ import org.whosin.client.data.dto.request.EmailValidationRequestDto
1111
import org.whosin.client.data.dto.request.EmailVerificationRequestDto
1212
import org.whosin.client.data.dto.request.FindPasswordRequestDto
1313
import org.whosin.client.data.dto.request.LoginRequestDto
14+
import org.whosin.client.data.dto.request.LogoutRequestDto
1415
import org.whosin.client.data.dto.request.SignupRequestDto
1516
import org.whosin.client.data.dto.response.EmailVerificationResponseDto
1617
import org.whosin.client.data.dto.response.ErrorResponseDto
1718
import org.whosin.client.data.dto.response.FindPasswordResponseDto
1819
import org.whosin.client.data.dto.response.LoginResponseDto
20+
import org.whosin.client.data.dto.response.LogoutResponseDto
1921
import org.whosin.client.data.dto.response.SignupResponseDto
2022

2123
class RemoteAuthDataSource(
@@ -161,4 +163,42 @@ class RemoteAuthDataSource(
161163
ApiResult.Error(message = t.message, cause = t)
162164
}
163165
}
166+
167+
suspend fun logout(refreshToken: String): ApiResult<LogoutResponseDto> {
168+
return try {
169+
val response: HttpResponse = client
170+
.post("auth/logout") {
171+
setBody(
172+
LogoutRequestDto(
173+
refreshToken = refreshToken
174+
)
175+
)
176+
}
177+
if (response.status.isSuccess()) {
178+
ApiResult.Success(
179+
data = response.body(),
180+
statusCode = response.status.value
181+
)
182+
} else {
183+
// 에러 응답 파싱 시도
184+
try {
185+
val errorResponse: ErrorResponseDto = response.body()
186+
ApiResult.Error(
187+
code = response.status.value,
188+
message = errorResponse.message
189+
)
190+
} catch (e: Exception) {
191+
// 파싱 실패 시 기본 에러 메시지
192+
ApiResult.Error(
193+
code = response.status.value,
194+
message = "HTTP Error: ${response.status.value}"
195+
)
196+
}
197+
}
198+
} catch (t: Throwable) {
199+
ApiResult.Error(message = t.message, cause = t)
200+
}
201+
}
202+
203+
// TODO: 회원탈퇴
164204
}

composeApp/src/commonMain/kotlin/org/whosin/client/data/repository/AuthRepository.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import org.whosin.client.data.dto.response.LoginResponseDto
66
import org.whosin.client.data.dto.response.EmailVerificationResponseDto
77
import org.whosin.client.data.dto.response.SignupResponseDto
88
import org.whosin.client.data.dto.response.FindPasswordResponseDto
9+
import org.whosin.client.data.dto.response.LogoutResponseDto
910

1011
class AuthRepository(
1112
private val dataSource: RemoteAuthDataSource
@@ -37,4 +38,7 @@ class AuthRepository(
3738
suspend fun sendPasswordResetEmail(email: String): ApiResult<FindPasswordResponseDto> =
3839
dataSource.sendPasswordResetEmail(email)
3940

41+
suspend fun logout(refreshToken: String): ApiResult<LogoutResponseDto> =
42+
dataSource.logout(refreshToken)
43+
4044
}

composeApp/src/commonMain/kotlin/org/whosin/client/presentation/mypage/MyPageScreen.kt

Lines changed: 117 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,30 @@
11
package org.whosin.client.presentation.mypage
22

33
import androidx.compose.foundation.background
4-
import androidx.compose.foundation.layout.Box
4+
import androidx.compose.foundation.layout.Arrangement
55
import androidx.compose.foundation.layout.Column
6+
import androidx.compose.foundation.layout.Row
67
import androidx.compose.foundation.layout.Spacer
78
import androidx.compose.foundation.layout.fillMaxSize
89
import androidx.compose.foundation.layout.fillMaxWidth
910
import androidx.compose.foundation.layout.padding
1011
import androidx.compose.foundation.layout.size
12+
import androidx.compose.foundation.rememberScrollState
1113
import androidx.compose.foundation.shape.RoundedCornerShape
14+
import androidx.compose.foundation.verticalScroll
15+
import androidx.compose.material3.AlertDialog
16+
import androidx.compose.material3.Button
17+
import androidx.compose.material3.ButtonDefaults
18+
import androidx.compose.material3.OutlinedButton
1219
import androidx.compose.material3.OutlinedTextField
1320
import androidx.compose.material3.OutlinedTextFieldDefaults
1421
import androidx.compose.material3.Text
1522
import androidx.compose.runtime.Composable
1623
import androidx.compose.runtime.LaunchedEffect
17-
import androidx.compose.ui.Alignment
24+
import androidx.compose.runtime.getValue
25+
import androidx.compose.runtime.mutableStateOf
26+
import androidx.compose.runtime.remember
27+
import androidx.compose.runtime.setValue
1828
import androidx.compose.ui.Modifier
1929
import androidx.compose.ui.graphics.Color
2030
import androidx.compose.ui.text.TextStyle
@@ -25,6 +35,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
2535
import org.jetbrains.compose.resources.stringResource
2636
import org.jetbrains.compose.ui.tooling.preview.Preview
2737
import org.koin.compose.viewmodel.koinViewModel
38+
import org.whosin.client.data.dto.response.ClubData
2839
import org.whosin.client.presentation.component.CommonBackHandler
2940
import org.whosin.client.presentation.mypage.component.MyClubComponent
3041
import org.whosin.client.presentation.mypage.component.MyPageButton
@@ -43,6 +54,8 @@ fun MyPageScreen(
4354
) {
4455
val viewModel: MyPageViewModel = koinViewModel()
4556
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
57+
58+
var showDeleteDialog by remember { mutableStateOf(false) }
4659

4760
// MyPage로 돌아올 때마다 수정 모드 해제 및 데이터 새로고침
4861
LaunchedEffect(Unit) {
@@ -61,14 +74,17 @@ fun MyPageScreen(
6174
}
6275
}
6376

64-
Box(
77+
Column(
6578
modifier = modifier
6679
.fillMaxSize()
6780
.background(Color.White)
6881
.padding(top = 16.dp, start = 16.dp, end = 16.dp)
6982
) {
7083
Column(
71-
modifier = Modifier.fillMaxWidth()
84+
modifier = Modifier
85+
.fillMaxWidth()
86+
.weight(1f) // MyPageButton 위 공간만 사용
87+
.verticalScroll(rememberScrollState())
7288
) {
7389
MyPageTopAppBar(onNavigateBack)
7490
Spacer(modifier = Modifier.size(16.dp))
@@ -113,19 +129,57 @@ fun MyPageScreen(
113129
// 내 동아리 / 학과 목록
114130
MyClubComponent(
115131
modifier = Modifier
116-
.fillMaxWidth()
117-
.weight(1f)
118-
.padding(bottom = 72.dp),
132+
.fillMaxWidth(),
119133
isEditable = uiState.isEditable,
120134
myClubs = uiState.clubs,
121135
onDeleteClub = { clubId ->
122136
viewModel.deleteClub(clubId)
123137
},
124138
onNavigateToAddClub = onNavigateToAddClub
125139
)
140+
Spacer(modifier = Modifier.size(24.dp))
141+
Row(
142+
modifier = Modifier
143+
.fillMaxWidth()
144+
.padding(bottom = 32.dp),
145+
horizontalArrangement = Arrangement.spacedBy(12.dp)
146+
){
147+
Button(
148+
modifier = Modifier.weight(1f),
149+
shape = RoundedCornerShape(10.dp),
150+
onClick = {
151+
viewModel.logout()
152+
},
153+
colors = ButtonDefaults.buttonColors(
154+
containerColor = Color(0xFFF5F5F5),
155+
contentColor = Color.Black
156+
)
157+
){
158+
Text(
159+
text = "로그아웃",
160+
fontWeight = FontWeight.Medium
161+
)
162+
}
163+
Button(
164+
modifier = Modifier.weight(1f),
165+
shape = RoundedCornerShape(10.dp),
166+
onClick = {
167+
showDeleteDialog = true
168+
},
169+
colors = ButtonDefaults.buttonColors(
170+
containerColor = Color(0xFFFF3636),
171+
contentColor = Color.White
172+
)
173+
){
174+
Text(
175+
text = "회원 탈퇴",
176+
fontWeight = FontWeight.Medium
177+
)
178+
}
179+
}
126180
}
127-
128-
// 내 정보 수정 버튼 - 하단에 고정
181+
182+
// 내 정보 수정 버튼
129183
MyPageButton(
130184
onClick = {
131185
if (uiState.isEditable) {
@@ -139,10 +193,63 @@ fun MyPageScreen(
139193
),
140194
enabled = uiState.nickname.isNotEmpty(),
141195
modifier = Modifier
142-
.align(Alignment.BottomCenter)
196+
.fillMaxWidth()
143197
.padding(bottom = 52.dp)
144198
)
145199
}
200+
201+
// 회원 탈퇴 확인 다이얼로그
202+
if (showDeleteDialog) {
203+
AlertDialog(
204+
containerColor = Color(0xFFFFFFFF),
205+
onDismissRequest = { showDeleteDialog = false },
206+
title = {
207+
Text(
208+
text = "회원 탈퇴",
209+
fontWeight = FontWeight.SemiBold,
210+
fontSize = 18.sp
211+
)
212+
},
213+
text = {
214+
Text(
215+
text = "탈퇴 후에는 모든 데이터가 삭제되며 복구할 수 없습니다.",
216+
fontSize = 16.sp
217+
)
218+
},
219+
confirmButton = {
220+
Button(
221+
shape = RoundedCornerShape(10.dp),
222+
onClick = {
223+
showDeleteDialog = false
224+
// TODO: 회원 탈퇴 api 연결
225+
},
226+
colors = ButtonDefaults.buttonColors(
227+
containerColor = Color(0xFFFF3636),
228+
contentColor = Color.White
229+
)
230+
) {
231+
Text(
232+
text = "확인",
233+
fontWeight = FontWeight.Medium
234+
)
235+
}
236+
},
237+
dismissButton = {
238+
OutlinedButton(
239+
shape = RoundedCornerShape(10.dp),
240+
onClick = { showDeleteDialog = false },
241+
colors = ButtonDefaults.outlinedButtonColors(
242+
contentColor = Color.Black
243+
)
244+
) {
245+
Text(
246+
text = "취소",
247+
fontWeight = FontWeight.Medium
248+
)
249+
}
250+
}
251+
)
252+
}
146253

147254
}
148255

composeApp/src/commonMain/kotlin/org/whosin/client/presentation/mypage/MyPageViewModel.kt

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,11 @@ import kotlinx.coroutines.flow.StateFlow
77
import kotlinx.coroutines.flow.asStateFlow
88
import kotlinx.coroutines.flow.update
99
import kotlinx.coroutines.launch
10+
import org.whosin.client.core.auth.TokenExpiredManager
11+
import org.whosin.client.core.datastore.TokenManager
1012
import org.whosin.client.core.network.ApiResult
1113
import org.whosin.client.data.dto.response.ClubData
14+
import org.whosin.client.data.repository.AuthRepository
1215
import org.whosin.client.data.repository.MemberRepository
1316

1417
data class MyPageUiState(
@@ -20,7 +23,9 @@ data class MyPageUiState(
2023
)
2124

2225
class MyPageViewModel(
23-
private val repository: MemberRepository
26+
private val memberRepository: MemberRepository,
27+
private val authRepository: AuthRepository,
28+
private val tokenManager: TokenManager
2429
): ViewModel() {
2530
private val _uiState = MutableStateFlow(MyPageUiState())
2631
val uiState: StateFlow<MyPageUiState> = _uiState.asStateFlow()
@@ -43,7 +48,7 @@ class MyPageViewModel(
4348
fun getMyInfo() {
4449
viewModelScope.launch {
4550
_uiState.update{ it.copy(isLoading = true) }
46-
when (val result = repository.getMyInfo()) {
51+
when (val result = memberRepository.getMyInfo()) {
4752
is ApiResult.Success -> {
4853
val response = result.data.data
4954
_uiState.update { it ->
@@ -79,7 +84,7 @@ class MyPageViewModel(
7984
val newClubs = clubList?.map {
8085
it.clubId
8186
}
82-
when (val result = repository.updateMyInfo(newNickName = newNickName, clubList = newClubs)) {
87+
when (val result = memberRepository.updateMyInfo(newNickName = newNickName, clubList = newClubs)) {
8388
is ApiResult.Success -> {
8489
_uiState.update {
8590
it.copy(isEditable = false)
@@ -108,4 +113,33 @@ class MyPageViewModel(
108113
println("MyPageViewModel: 클럽 삭제 - clubId: $clubId")
109114
}
110115

116+
// 로그아웃
117+
fun logout(){
118+
viewModelScope.launch {
119+
val refreshToken = tokenManager.getRefreshToken()
120+
if (refreshToken.isNullOrEmpty()) {
121+
// 리프레시 토큰이 없으면 바로 토큰 삭제 및 로그인 화면으로 이동
122+
tokenManager.clearToken()
123+
TokenExpiredManager.setTokenExpired()
124+
return@launch
125+
}
126+
127+
when (val result = authRepository.logout(refreshToken)) {
128+
is ApiResult.Success -> {
129+
println("MyPageViewModel: 로그아웃 성공")
130+
// 토큰 삭제 및 로그인 화면으로 이동
131+
tokenManager.clearToken()
132+
TokenExpiredManager.setTokenExpired()
133+
}
134+
is ApiResult.Error -> {
135+
_uiState.value = _uiState.value.copy(
136+
errorMessage = result.message ?: "로그아웃에 실패했습니다."
137+
)
138+
println("MyPageViewModel: 로그아웃 실패 - ${result.message}")
139+
}
140+
}
141+
}
142+
}
143+
144+
// TODO: 회원 탈퇴
111145
}

0 commit comments

Comments
 (0)