diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 3d9c95a..f535598 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -42,8 +42,8 @@ jobs: - name: Run unit tests run: ./gradlew test env: -          BASE_URL: ${{ secrets.BASE_URL }} -          KAKAO_NATIVE_APP_KEY: ${{ secrets.KAKAO_NATIVE_APP_KEY }} + BASE_URL: ${{ secrets.BASE_URL }} + KAKAO_NATIVE_APP_KEY: ${{ secrets.KAKAO_NATIVE_APP_KEY }} - name: Upload test reports if: always() @@ -96,4 +96,4 @@ jobs: run: bundle exec fastlane distribute env: BASE_URL: ${{ secrets.BASE_URL }} - KAKAO_NATIVE_APP_KEY: ${{ secrets.KAKAO_NATIVE_APP_KEY }} + KAKAO_NATIVE_APP_KEY: ${{ secrets.KAKAO_NATIVE_APP_KEY }} \ No newline at end of file diff --git a/.idea/caches/deviceStreaming.xml b/.idea/caches/deviceStreaming.xml index 37bf1cf..39f0210 100644 --- a/.idea/caches/deviceStreaming.xml +++ b/.idea/caches/deviceStreaming.xml @@ -51,6 +51,18 @@ diff --git a/app/src/main/java/com/hsLink/hslink/data/dto/request/auth/LogoutRequestDto.kt b/app/src/main/java/com/hsLink/hslink/data/dto/request/auth/LogoutRequestDto.kt new file mode 100644 index 0000000..3e22059 --- /dev/null +++ b/app/src/main/java/com/hsLink/hslink/data/dto/request/auth/LogoutRequestDto.kt @@ -0,0 +1,9 @@ +// data/dto/request/auth/LogoutRequestDto.kt +package com.hsLink.hslink.data.dto.request.auth + +import kotlinx.serialization.Serializable + +@Serializable +data class LogoutRequestDto( + val refreshToken: String +) \ No newline at end of file diff --git a/app/src/main/java/com/hsLink/hslink/data/dto/request/auth/WithdrawRequestDto.kt b/app/src/main/java/com/hsLink/hslink/data/dto/request/auth/WithdrawRequestDto.kt new file mode 100644 index 0000000..83fa837 --- /dev/null +++ b/app/src/main/java/com/hsLink/hslink/data/dto/request/auth/WithdrawRequestDto.kt @@ -0,0 +1,9 @@ +// data/dto/request/auth/WithdrawRequestDto.kt +package com.hsLink.hslink.data.dto.request.auth + +import kotlinx.serialization.Serializable + +@Serializable +data class WithdrawRequestDto( + val refreshToken: String +) \ No newline at end of file diff --git a/app/src/main/java/com/hsLink/hslink/data/dto/response/auth/WithdrawResponseDto.kt b/app/src/main/java/com/hsLink/hslink/data/dto/response/auth/WithdrawResponseDto.kt new file mode 100644 index 0000000..90a567d --- /dev/null +++ b/app/src/main/java/com/hsLink/hslink/data/dto/response/auth/WithdrawResponseDto.kt @@ -0,0 +1,10 @@ +// data/dto/response/auth/WithdrawResponseDto.kt +package com.hsLink.hslink.data.dto.response.auth + +import kotlinx.serialization.Serializable + +@Serializable +data class WithdrawResponseDto( + val userId: Long, + val deletedAt: String +) \ No newline at end of file diff --git a/app/src/main/java/com/hsLink/hslink/data/repositoryimpl/AuthRepositoryImpl.kt b/app/src/main/java/com/hsLink/hslink/data/repositoryimpl/AuthRepositoryImpl.kt index 308347e..3cb6c31 100644 --- a/app/src/main/java/com/hsLink/hslink/data/repositoryimpl/AuthRepositoryImpl.kt +++ b/app/src/main/java/com/hsLink/hslink/data/repositoryimpl/AuthRepositoryImpl.kt @@ -1,10 +1,14 @@ package com.hsLink.hslink.data.repositoryimpl +import com.hsLink.hslink.data.dto.request.auth.LogoutRequestDto import com.hsLink.hslink.data.dto.request.auth.SocialLoginRequestDto +import com.hsLink.hslink.data.dto.request.auth.WithdrawRequestDto import com.hsLink.hslink.data.dto.response.auth.SocialLoginResponseDto +import com.hsLink.hslink.data.dto.response.auth.WithdrawResponseDto import com.hsLink.hslink.data.local.TokenDataStore import com.hsLink.hslink.data.service.login.AuthService import com.hsLink.hslink.domain.repository.AuthRepository +import kotlinx.coroutines.flow.first import javax.inject.Inject class AuthRepositoryImpl @Inject constructor( @@ -32,4 +36,47 @@ class AuthRepositoryImpl @Inject constructor( Result.failure(e) } } + + // ← 새로 추가: 로그아웃 + override suspend fun logout(): Result { + return try { + val refreshToken = tokenDataStore.refreshToken.first() + if (refreshToken.isNullOrEmpty()) { + return Result.failure(Exception("토큰이 존재하지 않습니다")) + } + + val request = LogoutRequestDto(refreshToken) + val response = authService.logout(request) // <- 이제 Response + + if (response.isSuccessful) { + tokenDataStore.clearTokens() + Result.success(Unit) + } else { + Result.failure(Exception("로그아웃에 실패했습니다: ${response.code()}")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + override suspend fun withdraw(): Result { + return try { + val refreshToken = tokenDataStore.refreshToken.first() // ← 수정 + if (refreshToken.isNullOrEmpty()) { + return Result.failure(Exception("토큰이 존재하지 않습니다")) + } + + val request = WithdrawRequestDto(refreshToken) + val response = authService.withdraw(request) + + if (response.isSuccessful && response.body()?.isSuccess == true) { + tokenDataStore.clearTokens() + Result.success(response.body()!!.result) + } else { + Result.failure(Exception(response.body()?.message ?: "계정 탈퇴에 실패했습니다")) + } + } catch (e: Exception) { + Result.failure(e) + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/hsLink/hslink/data/service/login/AuthService.kt b/app/src/main/java/com/hsLink/hslink/data/service/login/AuthService.kt index 84be672..9790696 100644 --- a/app/src/main/java/com/hsLink/hslink/data/service/login/AuthService.kt +++ b/app/src/main/java/com/hsLink/hslink/data/service/login/AuthService.kt @@ -1,12 +1,23 @@ package com.hsLink.hslink.data.service.login +import com.hsLink.hslink.core.network.BaseResponse +import com.hsLink.hslink.data.dto.request.auth.LogoutRequestDto import com.hsLink.hslink.data.dto.request.auth.SocialLoginRequestDto +import com.hsLink.hslink.data.dto.request.auth.WithdrawRequestDto import com.hsLink.hslink.data.dto.response.auth.SocialLoginResponseDto +import com.hsLink.hslink.data.dto.response.auth.WithdrawResponseDto import retrofit2.Response import retrofit2.http.Body +import retrofit2.http.DELETE import retrofit2.http.POST interface AuthService { @POST("auth/login") suspend fun socialLogin(@Body request: SocialLoginRequestDto): Response + + @POST("auth/logout") + suspend fun logout(@Body request: LogoutRequestDto): Response + + @DELETE("auth/withdraw") + suspend fun withdraw(@Body request: WithdrawRequestDto): Response> } \ No newline at end of file diff --git a/app/src/main/java/com/hsLink/hslink/domain/repository/AuthRepository.kt b/app/src/main/java/com/hsLink/hslink/domain/repository/AuthRepository.kt index b32597a..28a404d 100644 --- a/app/src/main/java/com/hsLink/hslink/domain/repository/AuthRepository.kt +++ b/app/src/main/java/com/hsLink/hslink/domain/repository/AuthRepository.kt @@ -1,10 +1,14 @@ package com.hsLink.hslink.domain.repository import com.hsLink.hslink.data.dto.response.auth.SocialLoginResponseDto +import com.hsLink.hslink.data.dto.response.auth.WithdrawResponseDto interface AuthRepository { suspend fun loginWithSocialToken( provider: String, accessToken: String ): Result + + suspend fun logout(): Result + suspend fun withdraw(): Result } \ No newline at end of file diff --git a/app/src/main/java/com/hsLink/hslink/presentation/mypage/component/career/ConfirmDialog.kt b/app/src/main/java/com/hsLink/hslink/presentation/mypage/component/career/ConfirmDialog.kt new file mode 100644 index 0000000..51e65e0 --- /dev/null +++ b/app/src/main/java/com/hsLink/hslink/presentation/mypage/component/career/ConfirmDialog.kt @@ -0,0 +1,106 @@ +// presentation/mypage/component/common/ConfirmDialog.kt (이름 변경) +package com.hsLink.hslink.presentation.mypage.component.career + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +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 com.hsLink.hslink.core.designsystem.component.HsLinkActionButton +import com.hsLink.hslink.core.designsystem.component.HsLinkActionButtonSize +import com.hsLink.hslink.core.designsystem.component.HsLinkButtonSize +import com.hsLink.hslink.core.designsystem.component.HsLinkSelectButton +import com.hsLink.hslink.core.designsystem.theme.HsLinkTheme + + +@Preview(showBackground = true) +@Composable +private fun ConfirmDialogLogoutPreview() { + HsLinkTheme { + ConfirmDialog( + title = "로그아웃을\n하시겠습니까?", + message = null, + cancelText = "취소", + confirmText = "확인", + onDismiss = {}, + onConfirm = {} + ) + } +} + +@Composable +fun ConfirmDialog( + modifier: Modifier = Modifier, + title: String, + message: String? = null, + cancelText: String = "취소", + confirmText: String = "확인", + onDismiss: () -> Unit, + onConfirm: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + containerColor = Color.White, // ← 완전 하얀색 배경 + title = { + Text( + text = title, + style = HsLinkTheme.typography.title_20Strong, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + }, + text = message?.let { + { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), // ← 전체 여백 + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = it, + style = HsLinkTheme.typography.body_14Normal, + color = HsLinkTheme.colors.Grey600, + textAlign = TextAlign.Center, + lineHeight = 20.sp, // ← 줄 간격 고정값 + modifier = Modifier.fillMaxWidth() + ) + } + } + }, + confirmButton = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + // ← 취소하기 (회색 배경) + HsLinkActionButton( + label = cancelText, // "취소하기" + onClick = onDismiss, + size = HsLinkActionButtonSize.Small, // ← 회색 배경 + modifier = Modifier.weight(1f) + ) + + // ← 확인 버튼 (파란색 배경) + HsLinkActionButton( + label = confirmText, // "로그아웃" 또는 "삭제하기" + onClick = onConfirm, + size = HsLinkActionButtonSize.Large, // ← 파란색 배경 + modifier = Modifier.weight(1f) + ) + } + }, + dismissButton = null, + modifier = modifier + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/hsLink/hslink/presentation/mypage/screen/main/MypageScreen.kt b/app/src/main/java/com/hsLink/hslink/presentation/mypage/screen/main/MypageScreen.kt index 4fa3729..9e45971 100644 --- a/app/src/main/java/com/hsLink/hslink/presentation/mypage/screen/main/MypageScreen.kt +++ b/app/src/main/java/com/hsLink/hslink/presentation/mypage/screen/main/MypageScreen.kt @@ -13,6 +13,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState 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.layout.ContentScale import androidx.compose.ui.res.painterResource @@ -20,6 +23,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController +import androidx.navigation.NavOptions import androidx.navigation.compose.currentBackStackEntryAsState import com.hsLink.hslink.R import com.hsLink.hslink.core.designsystem.component.HsLinkTopBar @@ -27,6 +31,8 @@ import com.hsLink.hslink.core.designsystem.theme.HsLinkTheme import com.hsLink.hslink.data.dto.response.mypage.MyPageUserProfileDto import com.hsLink.hslink.data.dto.response.mypage.MyPageUserSummaryDto import com.hsLink.hslink.data.dto.response.mypage.UserProfileDto +import com.hsLink.hslink.presentation.login.navigation.navigateToLogin +import com.hsLink.hslink.presentation.mypage.component.career.ConfirmDialog import com.hsLink.hslink.presentation.mypage.component.main.MyPageCardItemContainer import com.hsLink.hslink.presentation.mypage.component.main.MyPageDetailItemContent import com.hsLink.hslink.presentation.mypage.component.main.MyPageItemData @@ -61,7 +67,12 @@ fun MypageRoute( ) { val userSummary by viewModel.userSummary.collectAsState() // ← 변경 val isLoading by viewModel.isLoading.collectAsState() + val isAuthLoading by viewModel.isAuthLoading.collectAsState() val error by viewModel.error.collectAsState() + val logoutSuccess by viewModel.logoutSuccess.collectAsState() // ← 추가 + val withdrawSuccess by viewModel.withdrawSuccess.collectAsState() // ← 추가 + + LaunchedEffect(Unit) { viewModel.loadUserSummary() // 또는 loadMypage() @@ -76,14 +87,37 @@ fun MypageRoute( } } + // 로그아웃/탈퇴 성공 시 처리 + LaunchedEffect(error) { + error?.let { errorMessage -> + if (errorMessage.contains("성공")) { + // 로그인 화면으로 이동 (추후 구현) + // navController.navigateToLogin() + } + } + } + + + LaunchedEffect(logoutSuccess, withdrawSuccess) { + if (logoutSuccess || withdrawSuccess) { + navController.navigateToLogin( + navOptions = NavOptions.Builder() + .setPopUpTo(0, inclusive = true) // ← 모든 백스택 클리어 + .build() + ) + } + } MypageScreen( paddingValues = paddingValues, - userSummary = userSummary, // ← 변경 + userSummary = userSummary, isLoading = isLoading, + isAuthLoading = isAuthLoading, // ← 추가 error = error, onNavigateToProfile = { navController.navigateToProfileEdit() - } + }, + onLogout = viewModel::logout, // ← 추가 + onWithdraw = viewModel::withdraw // ← 추가 ) } @@ -94,12 +128,17 @@ fun MypageScreen( userSummary: MyPageUserSummaryDto? = null, // ← 변경 isLoading: Boolean = false, error: String? = null, + isAuthLoading: Boolean = false, // ← 추가 onNavigateToProfile: () -> Unit = {}, onNavigateToPosts: () -> Unit = {}, onNavigateToSettings: () -> Unit = {}, - onLogout: () -> Unit = {}, - onQuit: () -> Unit = {}, + onLogout: () -> Unit = {}, // ← 추가 + onWithdraw: () -> Unit = {}, // ← 추가 ) { + + // ← 다이얼로그 상태 관리 + var showLogoutDialog by remember { mutableStateOf(false) } + var showWithdrawDialog by remember { mutableStateOf(false) } LazyColumn( modifier = modifier .fillMaxSize() @@ -151,9 +190,42 @@ fun MypageScreen( MyPageItemData(id = "4", title = "탈퇴하기", route = "/quit") ), onItemClick = { item -> - // 클릭 처리 + when (item.id) { + "3" -> showLogoutDialog = true // ← 로그아웃 다이얼로그 표시 + "4" -> showWithdrawDialog = true // ← 탈퇴 다이얼로그 표시 + } } ) } } - } \ No newline at end of file + + // ← 로그아웃 확인 다이얼로그 + if (showLogoutDialog) { + ConfirmDialog( + title = "로그아웃을\n하시겠습니까?", + message = null, + cancelText = "취소", + confirmText = "확인", + onDismiss = { showLogoutDialog = false }, + onConfirm = { + showLogoutDialog = false + onLogout() // ← 실제 로그아웃 실행 + } + ) + } + + // ← 계정 삭제 확인 다이얼로그 + if (showWithdrawDialog) { + ConfirmDialog( + title = "계정을 삭제하시겠습니까?", + message = "데이터가 복구되지 않는데 괜찮으신가요?", + cancelText = "취소", + confirmText = "확인", + onDismiss = { showWithdrawDialog = false }, + onConfirm = { + showWithdrawDialog = false + onWithdraw() // ← 실제 계정 탈퇴 실행 + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/hsLink/hslink/presentation/mypage/viewmodel/MypageViewModel.kt b/app/src/main/java/com/hsLink/hslink/presentation/mypage/viewmodel/MypageViewModel.kt index fbef525..dd93e1c 100644 --- a/app/src/main/java/com/hsLink/hslink/presentation/mypage/viewmodel/MypageViewModel.kt +++ b/app/src/main/java/com/hsLink/hslink/presentation/mypage/viewmodel/MypageViewModel.kt @@ -9,6 +9,7 @@ import com.hsLink.hslink.data.dto.request.mypage.UpdateProfileRequestDto import com.hsLink.hslink.data.dto.response.mypage.MyPageUserProfileDto import com.hsLink.hslink.data.dto.response.mypage.MyPageUserSummaryDto import com.hsLink.hslink.data.dto.response.mypage.UserProfileDto +import com.hsLink.hslink.domain.repository.AuthRepository import com.hsLink.hslink.domain.repository.mypage.MypageRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -19,7 +20,8 @@ import javax.inject.Inject @HiltViewModel class MypageViewModel @Inject constructor( - private val mypageRepository: MypageRepository + private val mypageRepository: MypageRepository, + private val authRepository: AuthRepository ) : ViewModel() { private val _userProfile = MutableStateFlow(null) @@ -31,6 +33,19 @@ class MypageViewModel @Inject constructor( private val _error = MutableStateFlow(null) val error: StateFlow = _error.asStateFlow() + // ← 로그아웃 성공 이벤트 추가 + private val _logoutSuccess = MutableStateFlow(false) + val logoutSuccess: StateFlow = _logoutSuccess.asStateFlow() + + // ← 탈퇴 성공 이벤트 추가 + private val _withdrawSuccess = MutableStateFlow(false) + val withdrawSuccess: StateFlow = _withdrawSuccess.asStateFlow() + + + // ← 로그아웃/탈퇴 상태 추가 + private val _isAuthLoading = MutableStateFlow(false) + val isAuthLoading: StateFlow = _isAuthLoading.asStateFlow() + init { getUserSummary() // ← getUserProfile() 대신 변경 } @@ -106,4 +121,44 @@ class MypageViewModel @Inject constructor( } } } + // ← 새로 추가: 로그아웃 기능 + fun logout() { + viewModelScope.launch { + _isAuthLoading.value = true + Log.d("MypageViewModel", "로그아웃 시작") + + authRepository.logout().fold( + onSuccess = { + Log.d("MypageViewModel", "로그아웃 성공") + _error.value = null + _logoutSuccess.value = true // ← 성공 이벤트 발생 + }, + onFailure = { exception -> + Log.e("MypageViewModel", "로그아웃 실패: ${exception.message}") + _error.value = exception.message + } + ) + _isAuthLoading.value = false + } + } + + fun withdraw() { + viewModelScope.launch { + _isAuthLoading.value = true + Log.d("MypageViewModel", "계정 탈퇴 시작") + + authRepository.withdraw().fold( + onSuccess = { withdrawResponse -> + Log.d("MypageViewModel", "계정 탈퇴 성공: userId=${withdrawResponse.userId}") + _error.value = null + _withdrawSuccess.value = true // ← 성공 이벤트 발생 + }, + onFailure = { exception -> + Log.e("MypageViewModel", "계정 탈퇴 실패: ${exception.message}") + _error.value = exception.message + } + ) + _isAuthLoading.value = false + } + } } \ No newline at end of file