diff --git a/app/src/main/java/com/egobook/app/MainActivity.kt b/app/src/main/java/com/egobook/app/MainActivity.kt index 78780218..82b0ac6e 100644 --- a/app/src/main/java/com/egobook/app/MainActivity.kt +++ b/app/src/main/java/com/egobook/app/MainActivity.kt @@ -33,9 +33,10 @@ class MainActivity : AppCompatActivity(), BlurController, NotificationController // 목적지 변경 리스너 추가: 특정 프래그먼트에서 바텀바 숨기기 navController.addOnDestinationChangedListener { _, destination, _ -> when (destination.id) { - R.id.diaryWriteFragment, // 일기 작성 화면 + R.id.diaryWriteFragment, // 일기 작성 화면 R.id.calenderFragment, // 달력 화면 R.id.storeFragment, + R.id.accountFragment //계정 화면 -> { binding.bottomNavigation.visibility = View.GONE } diff --git a/app/src/main/java/com/egobook/app/data/api/AccountApiService.kt b/app/src/main/java/com/egobook/app/data/api/AccountApiService.kt new file mode 100644 index 00000000..bbf53215 --- /dev/null +++ b/app/src/main/java/com/egobook/app/data/api/AccountApiService.kt @@ -0,0 +1,27 @@ +package com.egobook.app.data.api + +import com.egobook.app.data.model.ApiResponse +import com.egobook.app.data.model.account.AccountResponse +import com.egobook.app.data.model.account.DeleteAccountResponse +import com.egobook.app.data.model.account.LinkRequest +import com.egobook.app.data.model.account.LinkResponse +import retrofit2.http.GET +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.POST + +interface AccountApiService { + //유저 id 불러오기 + @GET("/home/settings") + suspend fun getUserId(): ApiResponse + + @POST("/users/link/google") + suspend fun linkToGoogle( + @Body request: LinkRequest + ): ApiResponse + + @DELETE("/users/withdraw") + suspend fun deleteAccount(): ApiResponse + +} \ No newline at end of file diff --git a/app/src/main/java/com/egobook/app/data/api/AuthApiService.kt b/app/src/main/java/com/egobook/app/data/api/AuthApiService.kt index 538f9c0e..0f42a010 100644 --- a/app/src/main/java/com/egobook/app/data/api/AuthApiService.kt +++ b/app/src/main/java/com/egobook/app/data/api/AuthApiService.kt @@ -1,16 +1,13 @@ package com.egobook.app.data.api +import com.egobook.app.data.model.ApiResponse import com.egobook.app.data.model.auth.AccessTokenRequest -import com.egobook.app.data.model.auth.AccessTokenResponse -import com.egobook.app.data.model.auth.TokensRequestAgainByGuest +import com.egobook.app.data.model.auth.GuestTokenData +import com.egobook.app.data.model.auth.TokenData import com.egobook.app.data.model.auth.TokenRequestByGoogle import com.egobook.app.data.model.auth.TokenRequestByGuest -import com.egobook.app.data.model.auth.TokenResponseAgainByGuest -import com.egobook.app.data.model.auth.TokenResponseByGoogle -import com.egobook.app.data.model.auth.TokenResponseByGuest import com.egobook.app.data.model.auth.TokensRequest -import com.egobook.app.data.model.auth.TokensResponse -import retrofit2.Response +import com.egobook.app.data.model.auth.TokensRequestAgainByGuest import retrofit2.http.Body import retrofit2.http.POST @@ -20,30 +17,30 @@ interface AuthApiService { @POST("auth/google/join") suspend fun googleSignUp( @Body request: TokenRequestByGoogle - ): Response + ): ApiResponse //Guest 최초 둘러보기 @POST("auth/guest/join") suspend fun guestLogin( @Body request: TokenRequestByGuest - ): Response + ): ApiResponse //액세스토큰 재발급 @POST("auth/refresh") suspend fun getAccessToken( @Body request: AccessTokenRequest - ): Response + ): ApiResponse //Tokens 재발급 - refreshToken까지 만료시 @POST("auth/google/recertification") suspend fun reGetTokens( @Body request: TokensRequest - ): Response + ): ApiResponse //Guest로그인 상태에서 Tokens 재발급 @POST("auth/google/recertification") suspend fun reGetTokensByGuest( @Body request: TokensRequestAgainByGuest - ): Response + ): ApiResponse } \ No newline at end of file diff --git a/app/src/main/java/com/egobook/app/data/interceptor/TokenAuthenticator.kt b/app/src/main/java/com/egobook/app/data/interceptor/TokenAuthenticator.kt index 8dadce17..39117e9d 100644 --- a/app/src/main/java/com/egobook/app/data/interceptor/TokenAuthenticator.kt +++ b/app/src/main/java/com/egobook/app/data/interceptor/TokenAuthenticator.kt @@ -60,13 +60,17 @@ class TokenAuthenticator @Inject constructor( ) ) - Timber.d("토큰 갱신 API 응답: 코드=${tokenResponse.code()}, 성공=${tokenResponse.isSuccessful}") + Timber.d("토큰 갱신 API 응답: 코드=${tokenResponse.code}, 메시지=${tokenResponse.message}") - if (tokenResponse.isSuccessful && tokenResponse.body() != null) { - val newAccessToken = tokenResponse.body()!!.data.accessToken + if (tokenResponse.code == "SUCCESS") { + val newAccessToken = tokenResponse.data.accessToken + val newRefreshToken = tokenResponse.data.refreshToken - // 새 액세스 토큰을 DataStore에 저장 - userInfoStorage.saveAccessToken(newAccessToken) + // 새 토큰들을 DataStore에 저장 + userInfoStorage.saveAllTokens( + accessToken = newAccessToken, + refreshToken = newRefreshToken + ) Timber.d("액세스 토큰 갱신 성공") @@ -78,7 +82,7 @@ class TokenAuthenticator @Inject constructor( } else { // 리프레시 토큰 갱신 실패 -> 리프레시 토큰 만료로 판단 - Timber.e("리프레시 토큰 갱신 실패: ${tokenResponse.code()}, 로그아웃 처리") + Timber.e("리프레시 토큰 갱신 실패: ${tokenResponse.code}, 로그아웃 처리") handleLogout() null } @@ -91,17 +95,20 @@ class TokenAuthenticator @Inject constructor( } /** - * 로그아웃 처리: 로그인 화면으로 이동 + * 로그아웃 처리: 토큰 클리어 후 로그인 화면으로 이동 */ private fun handleLogout() { Timber.d("로그아웃 처리 시작") - - // 로그인 화면으로 이동 + + runBlocking { + userInfoStorage.clearAll() // 토큰, id, 이메일 등등 싹다 삭제 + } + val intent = Intent(context, LoginActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK } context.startActivity(intent) - + Timber.d("로그인 화면으로 이동") } diff --git a/app/src/main/java/com/egobook/app/data/local/UserInfoStorage.kt b/app/src/main/java/com/egobook/app/data/local/UserInfoStorage.kt index 45627162..c9003ff8 100644 --- a/app/src/main/java/com/egobook/app/data/local/UserInfoStorage.kt +++ b/app/src/main/java/com/egobook/app/data/local/UserInfoStorage.kt @@ -6,7 +6,6 @@ import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore -import com.egobook.app.domain.model.User import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import javax.inject.Inject @@ -23,7 +22,6 @@ class UserInfoStorage @Inject constructor( ) { private val dataStore = context.dataStore - /** * LoginType 저장 */ @@ -48,6 +46,43 @@ class UserInfoStorage @Inject constructor( } } + /** + * USER ID 저장 + */ + suspend fun saveUserId(id: String) { + dataStore.edit { preferences -> + preferences[USER_ID] = id + } + } + + /** + * USER ID 읽기 + */ + + fun getUserId(): Flow { + return dataStore.data.map { preferences -> + preferences[USER_ID] + } + } + + /** + * USER EMAIL 저장 + */ + suspend fun saveUserEmail(email: String) { + dataStore.edit { preferences -> + preferences[USER_EMAIL] = email + } + } + + /** + * USER EMAIL 읽기 + */ + fun getUserEmail(): Flow { + return dataStore.data.map { preferences -> + preferences[USER_EMAIL] + } + } + /** * Access Token 저장 */ @@ -126,7 +161,6 @@ class UserInfoStorage @Inject constructor( suspend fun saveAllTokens( accessToken: String, refreshToken: String, - idToken: String? = null, recoverToken: String? = null, ) { dataStore.edit { preferences -> @@ -137,7 +171,7 @@ class UserInfoStorage @Inject constructor( } /** - * 모든 데이터 삭제 (로그아웃 시 사용) + * 모든 데이터 삭제 (로그아웃 및 회원탈퇴 시 사용) */ suspend fun clearAll() { dataStore.edit { preferences -> @@ -145,6 +179,7 @@ class UserInfoStorage @Inject constructor( } } + // 로그인 타입 정의 enum class LoginType { GOOGLE, GUEST } @@ -153,11 +188,13 @@ class UserInfoStorage @Inject constructor( companion object { private val LOGIN_TYPE = stringPreferencesKey("login_type") + private val USER_ID = stringPreferencesKey("user_id") + + private val USER_EMAIL = stringPreferencesKey("email") private val KEY_ACCESS_TOKEN = stringPreferencesKey("access_token") private val KEY_REFRESH_TOKEN = stringPreferencesKey("refresh_token") private val KEY_RECOVER_TOKEN = stringPreferencesKey("recover_token") private val KEY_DEVICE_UID = stringPreferencesKey("device_uid") - } } diff --git a/app/src/main/java/com/egobook/app/data/model/account/AccountResponse.kt b/app/src/main/java/com/egobook/app/data/model/account/AccountResponse.kt new file mode 100644 index 00000000..a92ffc78 --- /dev/null +++ b/app/src/main/java/com/egobook/app/data/model/account/AccountResponse.kt @@ -0,0 +1,10 @@ +package com.egobook.app.data.model.account + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class AccountResponse( + @SerialName("accountCode") + val accountCode: String, +) diff --git a/app/src/main/java/com/egobook/app/data/model/account/DeleteAccountResponse.kt b/app/src/main/java/com/egobook/app/data/model/account/DeleteAccountResponse.kt new file mode 100644 index 00000000..8e59ccaf --- /dev/null +++ b/app/src/main/java/com/egobook/app/data/model/account/DeleteAccountResponse.kt @@ -0,0 +1,10 @@ +package com.egobook.app.data.model.account + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class DeleteAccountResponse( + @SerialName("data") + val data: String, +) diff --git a/app/src/main/java/com/egobook/app/data/model/account/LinkRequest.kt b/app/src/main/java/com/egobook/app/data/model/account/LinkRequest.kt new file mode 100644 index 00000000..efc680bf --- /dev/null +++ b/app/src/main/java/com/egobook/app/data/model/account/LinkRequest.kt @@ -0,0 +1,10 @@ +package com.egobook.app.data.model.account + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class LinkRequest( + @SerialName("idToken") + val idToken: String +) diff --git a/app/src/main/java/com/egobook/app/data/model/account/LinkResponse.kt b/app/src/main/java/com/egobook/app/data/model/account/LinkResponse.kt new file mode 100644 index 00000000..fa909b34 --- /dev/null +++ b/app/src/main/java/com/egobook/app/data/model/account/LinkResponse.kt @@ -0,0 +1,14 @@ +package com.egobook.app.data.model.account + +import kotlinx.serialization.SerialName + +data class LinkResponse( + @SerialName("accessToken") + val accessToken: String, + + @SerialName("refreshToken") + val refreshToken: String, + + @SerialName("email") + val email: String, +) diff --git a/app/src/main/java/com/egobook/app/data/model/auth/AuthResponse.kt b/app/src/main/java/com/egobook/app/data/model/auth/AuthResponse.kt index a1a40bdf..71b8c486 100644 --- a/app/src/main/java/com/egobook/app/data/model/auth/AuthResponse.kt +++ b/app/src/main/java/com/egobook/app/data/model/auth/AuthResponse.kt @@ -3,99 +3,26 @@ package com.egobook.app.data.model.auth import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -//Google 최초 회원가입 -@Serializable -data class TokenResponseByGoogle( - @SerialName("status") - val status: Int, - - @SerialName("code") - val code: String, - - @SerialName("message") - val message: String, - - @SerialName("data") - val data: TokenData -) - -//Guest 최초 둘러보기 -@Serializable -data class TokenResponseByGuest( - @SerialName("status") - val status: Int, - - @SerialName("code") - val code: String, - - @SerialName("message") - val message: String, - - @SerialName("data") - val data: GuestTokenData -) - -//액세스토큰 재발급 -@Serializable -data class AccessTokenResponse( - @SerialName("status") - val status: Int, - - @SerialName("code") - val code: String, - - @SerialName("message") - val message: String, - - @SerialName("data") - val data: TokenData -) - -//Tokens 재발급 - refreshToken까지 만료시 -@Serializable -data class TokensResponse( - @SerialName("status") - val status: Int, - - @SerialName("code") - val code: String, - - @SerialName("message") - val message: String, - - @SerialName("data") - val data: TokenData -) - -//Guest로그인 상태에서 Token 재발급 -@Serializable -data class TokenResponseAgainByGuest( - @SerialName("status") - val status: Int, - - @SerialName("code") - val code: String, - - @SerialName("message") - val message: String, - - @SerialName("data") - val data: GuestTokenData - -) - - -//=============================================================== - +/** + * 표준 Google 로그인/회원가입 토큰 응답 데이터 + * ApiResponse 형태로 사용됨 + */ @Serializable data class TokenData( @SerialName("accessToken") val accessToken: String, @SerialName("refreshToken") - val refreshToken: String + val refreshToken: String, + + @SerialName("email") + val email: String, ) +/** + * Guest 로그인 토큰 응답 데이터 (recoverToken 포함) + * ApiResponse 형태로 사용됨 + */ @Serializable data class GuestTokenData( @SerialName("accessToken") diff --git a/app/src/main/java/com/egobook/app/data/repository/account/AccountRepositoryImpl.kt b/app/src/main/java/com/egobook/app/data/repository/account/AccountRepositoryImpl.kt new file mode 100644 index 00000000..beeb7843 --- /dev/null +++ b/app/src/main/java/com/egobook/app/data/repository/account/AccountRepositoryImpl.kt @@ -0,0 +1,116 @@ +package com.egobook.app.data.repository.account + +import com.egobook.app.data.api.AccountApiService +import com.egobook.app.data.local.UserInfoStorage +import com.egobook.app.data.model.account.LinkRequest +import com.egobook.app.data.util.safeApiCall +import com.egobook.app.data.util.safeApiCallWithSuspendTransform +import com.egobook.app.domain.repository.account.AccountRepository +import com.egobook.app.domain.repository.account.LinkedAccountInfo +import javax.inject.Inject +import kotlinx.coroutines.flow.firstOrNull +import timber.log.Timber + +class AccountRepositoryImpl @Inject constructor( + private val apiService: AccountApiService, + private val userInfoStorage: UserInfoStorage +) : AccountRepository { + + override suspend fun getUserId(forceRefresh: Boolean): Result { + + //datastore에 데이터가 있는지 먼저 체크 + val localUserId = userInfoStorage.getUserId().firstOrNull() + + //datastore에 데이터가 있고 갱신을 강제하지 않는다면 바로 리턴 + if (!localUserId.isNullOrBlank() && !forceRefresh) { + return Result.success(localUserId) + } + + //datastore에 데이터가 없다면 api호출 + val result = safeApiCall( + apiCall = { apiService.getUserId() }, + transform = { it.accountCode } + ) + + //응답 값을 캐싱 + result.onSuccess { userId -> + userInfoStorage.saveUserId(userId) + } + + return result + } + + override suspend fun linkToGoogle(idToken: String): Result { + // 현재 로그인 타입이 GUEST인지 체크 + val currentLoginType = userInfoStorage.getLoginType().firstOrNull() + + // GUEST가 아니면 에러 반환 + if (currentLoginType != UserInfoStorage.LoginType.GUEST) { + return Result.failure(Exception("게스트 계정만 구글 연동이 가능합니다.")) + } + + // idToken 전송 (API 호출) 및 토큰 저장 + return safeApiCallWithSuspendTransform( + apiCall = { + apiService.linkToGoogle(LinkRequest(idToken = idToken)) + }, + transform = { tokenData -> + // Access, Refresh Token 저장 (Recover Token은 null로 설정하여 저장하지 않음) + userInfoStorage.saveAllTokens( + accessToken = tokenData.accessToken, + refreshToken = tokenData.refreshToken, + recoverToken = null + ) + + // 로그인 타입을 GOOGLE로 변경 + val loginType = UserInfoStorage.LoginType.GOOGLE + userInfoStorage.saveLoginType(loginType) + + // 이메일 저장 + userInfoStorage.saveUserEmail(tokenData.email) + Timber.d("구글 로그인 성공, loginType=$loginType, email=${tokenData.email}") + + Unit + } + ) + } + + override suspend fun getLinkedAccountInfo(): Result { + val loginType = userInfoStorage.getLoginType().firstOrNull() + val isGoogleLinked = loginType == UserInfoStorage.LoginType.GOOGLE + val email = userInfoStorage.getUserEmail().firstOrNull() + + return Result.success( + LinkedAccountInfo( + email = email, + isGoogleLinked = isGoogleLinked + ) + ) + } + + override suspend fun deleteAccount(): Result { + return try { + val result = safeApiCall( + apiCall = { apiService.deleteAccount() }, + transform = { Unit } + ) + + result.onSuccess { + try { + //datastore의 모든 데이터 삭제 + userInfoStorage.clearAll() + Timber.d("회원 탈퇴 성공: UserInfoStorage 초기화 완료") + } catch (e: Exception) { + Timber.e(e, "회원 탈퇴 후 UserInfoStorage 초기화 실패") + } + } + + result + } catch (e: Exception) { + Timber.e(e, "회원 탈퇴 처리 중 예외 발생") + Result.failure(e) + } + } + + +} \ No newline at end of file diff --git a/app/src/main/java/com/egobook/app/data/repository/auth/AuthRepositoryImpl.kt b/app/src/main/java/com/egobook/app/data/repository/auth/AuthRepositoryImpl.kt index 40deaaeb..f92dcce2 100644 --- a/app/src/main/java/com/egobook/app/data/repository/auth/AuthRepositoryImpl.kt +++ b/app/src/main/java/com/egobook/app/data/repository/auth/AuthRepositoryImpl.kt @@ -7,6 +7,7 @@ import com.egobook.app.data.model.auth.TokensRequest import com.egobook.app.data.model.auth.TokenRequestByGoogle import com.egobook.app.data.model.auth.TokenRequestByGuest import com.egobook.app.data.model.auth.TokensRequestAgainByGuest +import com.egobook.app.data.util.safeApiCallWithSuspendTransform import com.egobook.app.domain.repository.auth.AuthRepository import timber.log.Timber import javax.inject.Inject @@ -19,46 +20,41 @@ class AuthRepositoryImpl @Inject constructor( ) : AuthRepository { override suspend fun googleSignUp(idToken: String): Result { - return try { - val response = apiService.googleSignUp( - TokenRequestByGoogle(idToken = idToken) - ) - - if (response.isSuccessful && response.body() != null) { - val tokenData = response.body()!!.data - + return safeApiCallWithSuspendTransform( + apiCall = { + apiService.googleSignUp( + TokenRequestByGoogle(idToken = idToken) + ) + }, + transform = { tokenData -> userInfoStorage.saveAllTokens( accessToken = tokenData.accessToken, refreshToken = tokenData.refreshToken ) val loginType = UserInfoStorage.LoginType.GOOGLE userInfoStorage.saveLoginType(loginType) - Timber.d("구글 회원가입 성공, loginType=$loginType") - Result.success(Unit) - } else { - Result.failure(Exception("회원가입 요청 실패: ${response.code()}")) - } - } catch (e: Exception) { - Result.failure(e) - } + //유저 이메일 저장 + userInfoStorage.saveUserEmail(tokenData.email) + Timber.d("구글 로그인 성공, loginType=$loginType, email=${tokenData.email}") + Unit + } + ) } override suspend fun guestLogin(): Result { - return try { - // 앱 설치 인스턴스 고유 UUID - val deviceUid = UUID.randomUUID().toString() - - Timber.d("게스트 로그인 시도: deviceUid=$deviceUid") - - // Guest 로그인 API 요청 - val response = apiService.guestLogin( - TokenRequestByGuest(deviceUid = deviceUid) - ) - - if (response.isSuccessful && response.body() != null) { - val tokenData = response.body()!!.data - + // 앱 설치 인스턴스 고유 UUID + val deviceUid = UUID.randomUUID().toString() + + Timber.d("게스트 로그인 시도: deviceUid=$deviceUid") + + return safeApiCallWithSuspendTransform( + apiCall = { + apiService.guestLogin( + TokenRequestByGuest(deviceUid = deviceUid) + ) + }, + transform = { tokenData -> // UUID 저장 userInfoStorage.saveDeviceUid(deviceUid) @@ -73,127 +69,115 @@ class AuthRepositoryImpl @Inject constructor( val loginType = UserInfoStorage.LoginType.GUEST userInfoStorage.saveLoginType(loginType) Timber.d("게스트 로그인 성공, loginType=$loginType") - Result.success(Unit) - } else { - Timber.e("게스트 로그인 실패: ${response.code()}") - Result.failure(Exception("게스트 로그인 실패: ${response.code()}")) + Unit + } + ).also { result -> + if (result.isFailure) { + Timber.e(result.exceptionOrNull(), "게스트 로그인 중 오류") } - } catch (e: Exception) { - Timber.e(e, "게스트 로그인 중 오류") - Result.failure(e) } } override suspend fun refreshAccessToken(): Result { - return try { - // Access, Refresh Token 읽기 - val accessToken = userInfoStorage.getAccessToken().first() - ?: return Result.failure(Exception("액세스 토큰을 찾을 수 없습니다.")) - - val refreshToken = userInfoStorage.getRefreshToken().first() - ?: return Result.failure(Exception("리프레시 토큰을 찾을 수 없습니다.")) - - Timber.d("액세스 토큰 재발급 시도 시작") - - // API 요청 - val response = apiService.getAccessToken( - AccessTokenRequest( - accessToken = accessToken, - refreshToken = refreshToken + // Access, Refresh Token 읽기 + val accessToken = userInfoStorage.getAccessToken().first() + ?: return Result.failure(Exception("액세스 토큰을 찾을 수 없습니다.")) + + val refreshToken = userInfoStorage.getRefreshToken().first() + ?: return Result.failure(Exception("리프레시 토큰을 찾을 수 없습니다.")) + + Timber.d("액세스 토큰 재발급 시도 시작") + + return safeApiCallWithSuspendTransform( + apiCall = { + apiService.getAccessToken( + AccessTokenRequest( + accessToken = accessToken, + refreshToken = refreshToken + ) ) - ) - - Timber.d("응답 코드: ${response.code()}") - Timber.d("응답 성공 여부: ${response.isSuccessful}") - - // 응답 성공시 - if (response.isSuccessful && response.body() != null) { - val tokenData = response.body()!!.data + }, + transform = { tokenData -> userInfoStorage.saveAllTokens( accessToken = tokenData.accessToken, refreshToken = tokenData.refreshToken ) - Result.success(Unit) - } else { - Result.failure(Exception("액세스 토큰 재발급 실패: ${response.code()}")) + Timber.d("액세스 토큰 재발급 성공") + Unit + } + ).also { result -> + if (result.isFailure) { + Timber.e(result.exceptionOrNull(), "액세스 토큰 재발급 중 오류") } - } catch (e: Exception) { - Timber.e(e, "액세스 토큰 재발급 중 오류") - Result.failure(e) } } + //구글 로그인 시 사용 override suspend fun refreshTokens(idToken: String): Result { - return try { - // 액세스 토큰 가져오기 (없으면 null) - val accessToken = userInfoStorage.getAccessToken().first() - - val response = apiService.reGetTokens( - TokensRequest( - idToken = idToken, - accessToken = accessToken + // 액세스 토큰 가져오기 (없으면 null) + val accessToken = userInfoStorage.getAccessToken().first() + + return safeApiCallWithSuspendTransform( + apiCall = { + apiService.reGetTokens( + TokensRequest( + idToken = idToken, + accessToken = accessToken + ) ) - ) - - if (response.isSuccessful && response.body() != null) { - val tokenData = response.body()!!.data - + }, + transform = { tokenData -> userInfoStorage.saveAllTokens( - accessToken = tokenData.accessToken, + accessToken = tokenData.accessToken, refreshToken = tokenData.refreshToken ) - - Result.success(Unit) - } else { - Result.failure(Exception("토큰 갱신 요청 실패: ${response.code()}")) + //로그인 타입 저장 + val loginType = UserInfoStorage.LoginType.GOOGLE + userInfoStorage.saveLoginType(loginType) + //유저 이메일 저장 + userInfoStorage.saveUserEmail(tokenData.email) + Timber.d("구글 로그인 성공, loginType=$loginType, email=${tokenData.email}") + Unit } - - } catch (e: Exception) { - Result.failure(e) - } + ) } override suspend fun refreshGuestTokens(): Result { - return try { - // Device UID 및 Access Token, Recover Token 읽기 - val deviceUid = userInfoStorage.getDeviceUid().first() - ?: return Result.failure(Exception("디바이스 UID를 찾을 수 없습니다.")) - - val accessToken = userInfoStorage.getAccessToken().first() - ?: return Result.failure(Exception("accessToken을 찾을 수 없습니다.")) - - val recoverToken = userInfoStorage.getRecoverToken().first() - ?: return Result.failure(Exception("recoverToken을 찾을 수 없습니다.")) - - Timber.d("게스트 토큰 재발급 시도") - - // API 요청 - val response = apiService.reGetTokensByGuest( - TokensRequestAgainByGuest( - deviceUid = deviceUid, - accessToken = accessToken, - recoverToken = recoverToken + // Device UID 및 Access Token, Recover Token 읽기 + val deviceUid = userInfoStorage.getDeviceUid().first() + ?: return Result.failure(Exception("디바이스 UID를 찾을 수 없습니다.")) + + val accessToken = userInfoStorage.getAccessToken().first() + ?: return Result.failure(Exception("accessToken을 찾을 수 없습니다.")) + + val recoverToken = userInfoStorage.getRecoverToken().first() + ?: return Result.failure(Exception("recoverToken을 찾을 수 없습니다.")) + + Timber.d("게스트 토큰 재발급 시도") + + return safeApiCallWithSuspendTransform( + apiCall = { + apiService.reGetTokensByGuest( + TokensRequestAgainByGuest( + deviceUid = deviceUid, + accessToken = accessToken, + recoverToken = recoverToken + ) ) - ) - - Timber.d("응답 코드: ${response.code()}") - Timber.d("응답 성공 여부: ${response.isSuccessful}") - - // 응답 성공시 - if (response.isSuccessful && response.body() != null) { - val tokenData = response.body()!!.data + }, + transform = { tokenData -> userInfoStorage.saveAllTokens( accessToken = tokenData.accessToken, refreshToken = tokenData.refreshToken, recoverToken = tokenData.recoverToken ) - Result.success(Unit) - } else { - Result.failure(Exception("게스트 토큰 재발급 실패: ${response.code()}")) + Timber.d("게스트 토큰 재발급 성공") + Unit + } + ).also { result -> + if (result.isFailure) { + Timber.e(result.exceptionOrNull(), "게스트 토큰 재발급 중 오류") } - } catch (e: Exception) { - Timber.e(e, "게스트 토큰 재발급 중 오류") - Result.failure(e) } } diff --git a/app/src/main/java/com/egobook/app/data/util/ApiResponseExt.kt b/app/src/main/java/com/egobook/app/data/util/ApiResponseExt.kt index 28d47cef..7d215d6a 100644 --- a/app/src/main/java/com/egobook/app/data/util/ApiResponseExt.kt +++ b/app/src/main/java/com/egobook/app/data/util/ApiResponseExt.kt @@ -56,6 +56,26 @@ suspend inline fun safeApiCall( } } +/** + * API 호출을 안전하게 실행하고 변환하는 헬퍼 함수 (suspend transform 지원) + * transform 내부에서 suspend 함수를 호출할 수 있도록 지원 + */ +suspend inline fun safeApiCallWithSuspendTransform( + crossinline apiCall: suspend () -> ApiResponse, + crossinline transform: suspend (T) -> R +): Result { + return try { + val response = apiCall() + if (response.code == "SUCCESS") { + Result.success(transform(response.data)) + } else { + Result.failure(Exception(response.message)) + } + } catch (e: Exception) { + Result.failure(e) + } +} + /** * 의미있는 데이터를 반환하지 않는 API 응답을 처리하는 전용 확장 함수 */ diff --git a/app/src/main/java/com/egobook/app/di/RepositoryModule.kt b/app/src/main/java/com/egobook/app/di/RepositoryModule.kt index b0470a7a..0bba0d3f 100644 --- a/app/src/main/java/com/egobook/app/di/RepositoryModule.kt +++ b/app/src/main/java/com/egobook/app/di/RepositoryModule.kt @@ -6,6 +6,7 @@ import com.egobook.app.data.repository.NotificationRepositoryImpl import com.egobook.app.domain.repository.CounselingRepository import com.egobook.app.data.repository.auth.AuthRepositoryImpl import com.egobook.app.data.repository.QuestionRepositoryImpl +import com.egobook.app.data.repository.account.AccountRepositoryImpl import com.egobook.app.data.repository.diary.DiaryRepositoryImpl import com.egobook.app.domain.repository.FriendsRepository import com.egobook.app.domain.repository.NotificationRepository @@ -14,6 +15,7 @@ import com.egobook.app.ui.shop.NetworkStoreRepository import com.egobook.app.ui.shop.StoreRepository import dagger.Binds import com.egobook.app.domain.repository.QuestionRepository +import com.egobook.app.domain.repository.account.AccountRepository import com.egobook.app.domain.repository.diary.DiaryRepository import com.egobook.app.ui.home.repository.NetworkTendencyLevelService import com.egobook.app.ui.home.repository.NetworkUserRepository @@ -50,8 +52,11 @@ abstract class RepositoryModule { @Binds @Singleton - abstract fun bindDiaryRepository(impl: DiaryRepositoryImpl): DiaryRepository + abstract fun bindAccountRepository(impl: AccountRepositoryImpl): AccountRepository + @Binds + @Singleton + abstract fun bindDiaryRepository(impl: DiaryRepositoryImpl): DiaryRepository @Binds @Singleton diff --git a/app/src/main/java/com/egobook/app/di/ServiceModule.kt b/app/src/main/java/com/egobook/app/di/ServiceModule.kt index 0e826898..b33ec34a 100644 --- a/app/src/main/java/com/egobook/app/di/ServiceModule.kt +++ b/app/src/main/java/com/egobook/app/di/ServiceModule.kt @@ -1,5 +1,6 @@ package com.egobook.app.di +import com.egobook.app.data.api.AccountApiService import com.egobook.app.data.api.AuthApiService import com.egobook.app.data.api.CounselingApiService import com.egobook.app.data.api.DiaryApiService @@ -45,6 +46,12 @@ object ServiceModule { fun provideAuthService(retrofit: Retrofit): AuthApiService = retrofit.create(AuthApiService::class.java) + @Provides + @Singleton + fun provideAccountService(retrofit: Retrofit): AccountApiService = + retrofit.create(AccountApiService::class.java) + + @Provides @Singleton fun provideDiaryService(retrofit: Retrofit): DiaryApiService = diff --git a/app/src/main/java/com/egobook/app/domain/model/User.kt b/app/src/main/java/com/egobook/app/domain/model/User.kt deleted file mode 100644 index f57ad699..00000000 --- a/app/src/main/java/com/egobook/app/domain/model/User.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.egobook.app.domain.model - -data class User( - val id: String -) diff --git a/app/src/main/java/com/egobook/app/domain/repository/account/AccountRepository.kt b/app/src/main/java/com/egobook/app/domain/repository/account/AccountRepository.kt new file mode 100644 index 00000000..fa5ccbff --- /dev/null +++ b/app/src/main/java/com/egobook/app/domain/repository/account/AccountRepository.kt @@ -0,0 +1,30 @@ +package com.egobook.app.domain.repository.account + +interface AccountRepository { + + /** + * 유저 id 조회 + */ + suspend fun getUserId(forceRefresh: Boolean = false): Result + + /** + * 구글 계정 연동 + */ + suspend fun linkToGoogle(idToken: String): Result + + /** + * 로컬에서 게스트타입과 GOOGLE이면 email을 읽어오는 로직 + */ + suspend fun getLinkedAccountInfo(): Result + + /** + * 계정 탈퇴 + */ + suspend fun deleteAccount(): Result + +} + +data class LinkedAccountInfo( + val email: String?, + val isGoogleLinked: Boolean // loginType == GOOGLE +) \ No newline at end of file diff --git a/app/src/main/java/com/egobook/app/ui/account/.gitkeep b/app/src/main/java/com/egobook/app/ui/account/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/app/src/main/java/com/egobook/app/ui/account/AccountBottomSheetFragment.kt b/app/src/main/java/com/egobook/app/ui/account/AccountBottomSheetFragment.kt deleted file mode 100644 index 911179d9..00000000 --- a/app/src/main/java/com/egobook/app/ui/account/AccountBottomSheetFragment.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.egobook.app.ui.account - -import android.content.DialogInterface -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import com.egobook.app.databinding.FragmentAccountBottomSheetBinding -import com.google.android.material.bottomsheet.BottomSheetDialogFragment - -class AccountBottomSheetFragment : BottomSheetDialogFragment() { - - private var _binding: FragmentAccountBottomSheetBinding? = null - private val binding get() = _binding!! - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentAccountBottomSheetBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - } - - override fun onDismiss(dialog: DialogInterface) { - super.onDismiss(dialog) - (parentFragment as? AccountFragment)?.clearBlur() - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } - - companion object { - const val TAG = "AccountBottomSheet" - } -} diff --git a/app/src/main/java/com/egobook/app/ui/account/AccountFragment.kt b/app/src/main/java/com/egobook/app/ui/account/AccountFragment.kt deleted file mode 100644 index e2faf752..00000000 --- a/app/src/main/java/com/egobook/app/ui/account/AccountFragment.kt +++ /dev/null @@ -1,66 +0,0 @@ -package com.egobook.app.ui.account - -import android.graphics.Color -import android.os.Build -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.view.WindowInsetsController -import androidx.core.content.ContextCompat -import androidx.fragment.app.Fragment -import com.egobook.app.R -import androidx.navigation.fragment.findNavController -import com.egobook.app.databinding.FragmentAccountBinding - -class AccountFragment : Fragment() { - - private var _binding: FragmentAccountBinding? = null - private val binding get() = _binding!! - - private val blurRadius = 5f - - override fun onCreateView( - inflater: LayoutInflater, container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentAccountBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - setClickListeners() - setupBlur() - } - - private fun setupBlur() { - binding.blurView.setupWith(binding.blurTarget) - .setBlurRadius(blurRadius) - .setBlurAutoUpdate(true) - } - fun clearBlur() { - binding.blurView.visibility = View.GONE - } - - - private fun setClickListeners() { - binding.apply { - btnBack.setOnClickListener { - findNavController().navigate(R.id.action_accountFragment_to_homeFragment) - } - - btnIntegrate.setOnClickListener { - binding.blurView.visibility = View.VISIBLE - AccountBottomSheetFragment() - .show(childFragmentManager, AccountBottomSheetFragment.TAG) - } - } - - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } -} diff --git a/app/src/main/java/com/egobook/app/ui/account/view/AccountBottomSheetFragment.kt b/app/src/main/java/com/egobook/app/ui/account/view/AccountBottomSheetFragment.kt new file mode 100644 index 00000000..70d4940d --- /dev/null +++ b/app/src/main/java/com/egobook/app/ui/account/view/AccountBottomSheetFragment.kt @@ -0,0 +1,101 @@ +package com.egobook.app.ui.account.view + +import android.content.DialogInterface +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.egobook.app.databinding.FragmentAccountBottomSheetBinding +import com.egobook.app.ui.account.viewmodel.AccountViewModel +import com.egobook.app.util.UiState +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch + +class AccountBottomSheetFragment : BottomSheetDialogFragment() { + + private var _binding: FragmentAccountBottomSheetBinding? = null + private val binding get() = _binding!! + + //부모 프래그먼트의 뷰모델 공유 + private val viewModel: AccountViewModel by viewModels({ requireParentFragment() }) + + + //연동 확인 콜백 인터페이스 + interface OnLinkConfirmListener { + fun onLinkConfirmed() + } + + private var linkConfirmListener: OnLinkConfirmListener? = null + + fun setOnLinkConfirmListener(listener: OnLinkConfirmListener) { + linkConfirmListener = listener + } + + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentAccountBottomSheetBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + setClickListener() + observeLinkState() + } + + private fun observeLinkState() { + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + + // 연동 상태 및 이메일 관찰 + launch { + viewModel.linkState.collect { state -> + val isLinked = state is UiState.Success + if (isLinked) { + binding.tvAccountEmail.apply { + visibility = View.VISIBLE + text = viewModel.userEmail.firstOrNull() ?: "" + } + binding.btnBottomGoogleLogin.apply { + // 이메일이 있으면 표시, 없으면 기본 메시지 + text = "Google계정으로 연동되었습니다" + isEnabled = false + } + } + } + } + } + } + } + + private fun setClickListener() { + binding.btnBottomGoogleLogin.setOnClickListener { + // linkState가 Success가 아닐 때만 클릭 가능 + if (viewModel.linkState.value !is UiState.Success) { + linkConfirmListener?.onLinkConfirmed() + } + } + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + (parentFragment as? AccountFragment)?.clearBlur() + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + companion object { + const val TAG = "AccountBottomSheet" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/egobook/app/ui/account/view/AccountDeleteDialog1Fragment.kt b/app/src/main/java/com/egobook/app/ui/account/view/AccountDeleteDialog1Fragment.kt new file mode 100644 index 00000000..96997ab8 --- /dev/null +++ b/app/src/main/java/com/egobook/app/ui/account/view/AccountDeleteDialog1Fragment.kt @@ -0,0 +1,83 @@ +package com.egobook.app.ui.account.view + +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.egobook.app.databinding.FragmentAccountDeleteDialog1Binding +import com.egobook.app.removeScreenBlur +import com.egobook.app.ui.account.viewmodel.AccountViewModel +import com.egobook.app.util.UiState +import kotlinx.coroutines.launch + +class AccountDeleteDialog1Fragment : DialogFragment() { + + private var _binding: FragmentAccountDeleteDialog1Binding? = null + private val binding get() = _binding!! + + //부모 프래그먼트의 뷰모델 공유 + private val viewModel: AccountViewModel by viewModels({ requireParentFragment() }) + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + _binding = FragmentAccountDeleteDialog1Binding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + setClickListener() + observeDeleteAccountState() + } + + private fun setClickListener() { + binding.btnBack.setOnClickListener { + removeScreenBlur() + dismiss() + } + binding.btnRealDelete.setOnClickListener { + viewModel.deleteAccount() + } + } + + private fun observeDeleteAccountState() { + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.deleteAccountState.collect { state -> + when (state) { + is UiState.Success -> { + navigateToDeleteDialog2() + } + is UiState.Failure -> { + // TODO: 실패 처리 (토스트 메시지 등) + } + else -> {} + } + } + } + } + } + + private fun navigateToDeleteDialog2() { + val accountDeleteDialog2Fragment = AccountDeleteDialog2Fragment() + accountDeleteDialog2Fragment.isCancelable = true + accountDeleteDialog2Fragment.show(parentFragmentManager, "AccountDeleteDialog2Fragment") + dismiss() + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/com/egobook/app/ui/account/view/AccountDeleteDialog2Fragment.kt b/app/src/main/java/com/egobook/app/ui/account/view/AccountDeleteDialog2Fragment.kt new file mode 100644 index 00000000..a7e6cfab --- /dev/null +++ b/app/src/main/java/com/egobook/app/ui/account/view/AccountDeleteDialog2Fragment.kt @@ -0,0 +1,65 @@ +package com.egobook.app.ui.account.view + +import android.content.DialogInterface +import android.content.Intent +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import com.egobook.app.databinding.FragmentAccountDeleteDialog2Binding +import com.egobook.app.removeScreenBlur +import com.egobook.app.ui.account.viewmodel.AccountViewModel +import com.egobook.app.ui.login.view.LoginActivity +import timber.log.Timber +import kotlin.getValue + +class AccountDeleteDialog2Fragment : DialogFragment() { + + private var _binding: FragmentAccountDeleteDialog2Binding? = null + private val binding get() = _binding!! + + //부모 프래그먼트의 뷰모델 공유 + private val viewModel: AccountViewModel by viewModels({ requireParentFragment() }) + + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + dialog?.window?.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT)) + _binding = FragmentAccountDeleteDialog2Binding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + // TODO: 뷰 초기화 로직 추가 + } + + + override fun onCancel(dialog: DialogInterface) { + super.onCancel(dialog) + + //백스택 제거 후 로그인 화면으로 이동 + val intent = Intent(context, LoginActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + startActivity(intent) + Timber.d("로그인 화면으로 이동") + + removeScreenBlur() + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + +} diff --git a/app/src/main/java/com/egobook/app/ui/account/view/AccountFragment.kt b/app/src/main/java/com/egobook/app/ui/account/view/AccountFragment.kt new file mode 100644 index 00000000..e7613770 --- /dev/null +++ b/app/src/main/java/com/egobook/app/ui/account/view/AccountFragment.kt @@ -0,0 +1,233 @@ +package com.egobook.app.ui.account.view + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.core.content.ContextCompat +import androidx.credentials.CredentialManager +import androidx.credentials.CustomCredential +import androidx.credentials.GetCredentialRequest +import androidx.credentials.GetCredentialResponse +import androidx.credentials.exceptions.GetCredentialException +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.fragment.findNavController +import com.egobook.app.BuildConfig +import com.egobook.app.R +import com.egobook.app.databinding.FragmentAccountBinding +import com.egobook.app.ui.account.viewmodel.AccountViewModel +import com.egobook.app.util.UiState +import com.google.android.libraries.identity.googleid.GetGoogleIdOption +import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import timber.log.Timber +import android.util.Base64 +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import com.egobook.app.BlurLevel +import com.egobook.app.applyScreenBlur +import kotlinx.coroutines.flow.MutableSharedFlow +import org.json.JSONObject + +@AndroidEntryPoint +class AccountFragment : Fragment() { + private var _binding: FragmentAccountBinding? = null + private val binding get() = _binding!! + private val viewModel: AccountViewModel by viewModels() + private lateinit var request: GetCredentialRequest + private val credentialManager by lazy { + CredentialManager.create(requireContext()) + } + + private val blurRadius = 5f + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentAccountBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, systemBars.bottom) + insets + } + + setClickListeners() + setupBlur() + observeUserIdState() + observeLinkState() + observeLinkToastEvent() + } + + private fun observeUserIdState() { + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.userIdState.collect { state -> + when (state) { + + is UiState.Idle -> Unit + + is UiState.Loading -> { + //추후 로딩뷰를 삽입하자 + } + + is UiState.Success -> { + binding.tvRealAccountId.text = state.data + } + + is UiState.Failure -> { + Toast.makeText(requireContext(), "유저 id를 가져올 수 없습니다.", Toast.LENGTH_SHORT).show() + } + } + } + } + } + } + + private val Int.dp: Int + get() = (this * resources.displayMetrics.density).toInt() + + private fun observeLinkState() { + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.linkState.collect { state -> + when (state) { + is UiState.Idle -> Unit + + is UiState.Loading -> { + //추후 로딩뷰를 삽입하자 + } + is UiState.Success -> { + binding.btnIntegrate.apply { + layoutParams = layoutParams.apply { + width = 111.dp // 111dp + height = 34.dp // 34dp + } + + icon = ContextCompat.getDrawable(context, R.drawable.ic_google_logo) + text = "연동완료" + //isEnabled = false + } + } + is UiState.Failure -> { + + } + } + } + } + } + } + + private fun observeLinkToastEvent() { + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.linkToastEvent.collect { message -> + Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show() + } + } + } + } + + private fun setClickListeners() { + binding.apply { + btnBack.setOnClickListener { + findNavController().navigate(R.id.action_accountFragment_to_homeFragment) + } + + btnIntegrate.setOnClickListener { + binding.blurView.visibility = View.VISIBLE + + val accountBottomSheetFragment = AccountBottomSheetFragment() + + accountBottomSheetFragment.setOnLinkConfirmListener(object: AccountBottomSheetFragment.OnLinkConfirmListener { + override fun onLinkConfirmed() { + request = getGoogleRequest() + + lifecycleScope.launch{ + try { + val result = credentialManager.getCredential( + request = request, + context = requireContext() + ) + handleGoogleResult(result) + } catch (e: GetCredentialException) { + Timber.d("계정연동 실패: ${e.message}") + } + } + } + }) + accountBottomSheetFragment.show(childFragmentManager, AccountBottomSheetFragment.TAG) + } + + tvDeleteAccount.setOnClickListener { + applyScreenBlur(BlurLevel.BASE) + + val accountDeleteDialog1Fragment = AccountDeleteDialog1Fragment() + accountDeleteDialog1Fragment.isCancelable = false + accountDeleteDialog1Fragment.show(childFragmentManager, "AccountDeleteDialog1Fragment") + } + } + + } + private fun getGoogleRequest(): GetCredentialRequest { + val googleIdOption = GetGoogleIdOption.Builder() + .setFilterByAuthorizedAccounts(false) // 모든 구글 계정 표시 + .setServerClientId(BuildConfig.GOOGLE_WEB_CLIENT_ID) + .setAutoSelectEnabled(false) // 사용자가 직접 선택 + .build() + + return GetCredentialRequest.Builder() + .addCredentialOption(googleIdOption) + .build() + } + + private fun handleGoogleResult(result: GetCredentialResponse) { + val credential = result.credential + + if (credential is CustomCredential && + credential.type == GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL + ) { + try { + val googleIdTokenCredential = GoogleIdTokenCredential.createFrom(credential.data) + val idToken = googleIdTokenCredential.idToken + Timber.d("Google ID Token 받음") + + //뷰모델의 linkToGoogle 메서드 호출 + viewModel.linkToGoogle(idToken) + } catch (e: GetCredentialException) { + Timber.e(e, "구글 토큰 파싱 실패") + } + } else { + Timber.e("구글 로그인 credential 아님") + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + //=============다이알로그 출력용 블러뷰 세팅==================== + + private fun setupBlur() { + binding.blurView.setupWith(binding.blurTarget) + .setBlurRadius(blurRadius) + .setBlurAutoUpdate(true) + } + fun clearBlur() { + binding.blurView.visibility = View.GONE + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/egobook/app/ui/account/viewmodel/AccountViewModel.kt b/app/src/main/java/com/egobook/app/ui/account/viewmodel/AccountViewModel.kt new file mode 100644 index 00000000..05e9937a --- /dev/null +++ b/app/src/main/java/com/egobook/app/ui/account/viewmodel/AccountViewModel.kt @@ -0,0 +1,109 @@ +package com.egobook.app.ui.account.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.egobook.app.domain.repository.account.AccountRepository +import com.egobook.app.util.UiState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class AccountViewModel @Inject constructor( + private val accountRepository: AccountRepository +) : ViewModel() { + private val _userIdState = MutableStateFlow>(UiState.Idle) + val userIdState = _userIdState.asStateFlow() + + private val _linkState = MutableStateFlow>(UiState.Idle) + val linkState = _linkState.asStateFlow() + + private val _linkToastEvent = MutableSharedFlow(replay = 1) + val linkToastEvent = _linkToastEvent.asSharedFlow() + + private val _userEmail = MutableStateFlow(null) + val userEmail = _userEmail.asStateFlow() + + private val _deleteAccountState = MutableStateFlow>(UiState.Idle) + val deleteAccountState = _deleteAccountState.asStateFlow() + + + init { + loadLinkedAccountInfo() + getUserId() + } + + private fun loadLinkedAccountInfo() { + viewModelScope.launch { + accountRepository.getLinkedAccountInfo() + .onSuccess { info -> + if (info.isGoogleLinked) { + _userEmail.value = info.email + _linkState.value = UiState.Success(Unit) + } + } + } + } + fun getUserId() { + viewModelScope.launch { + _userIdState.value = UiState.Loading + + accountRepository.getUserId() + .onSuccess { id -> + _userIdState.value = UiState.Success(id) + } + .onFailure { e -> + _userIdState.value = + UiState.Failure(e.message ?: "사용자 ID를 불러오지 못했습니다") + } + } + } + + fun linkToGoogle(idToken: String) { + viewModelScope.launch { + _linkState.value = UiState.Loading + + accountRepository.linkToGoogle(idToken) + .onSuccess { + _linkState.value = UiState.Success(Unit) + _linkToastEvent.emit("Google 계정 연동이 완료되었습니다!") + + // 연동 성공 후 userId 갱신 + accountRepository.getUserId(forceRefresh = true) + .onSuccess { id -> + _userIdState.value = UiState.Success(id) + } + .onFailure { e -> + _userIdState.value = + UiState.Failure(e.message ?: "사용자 ID를 새로 불러오지 못했습니다") + _linkToastEvent.emit(e.message ?: "구글 계정 연동 실패") + } + } + .onFailure { e -> + _linkState.value = + UiState.Failure(e.message ?: "구글 계정 연동에 실패했습니다") + _linkToastEvent.emit("구글 계정 연동에 실패했습니다.") + } + } + } + + fun deleteAccount() { + viewModelScope.launch { + _deleteAccountState.value = UiState.Loading + + accountRepository.deleteAccount() + .onSuccess { + _deleteAccountState.value = UiState.Success(Unit) + } + .onFailure { e -> + _deleteAccountState.value = + UiState.Failure(e.message ?: "회원 탈퇴에 실패했습니다") + } + } + } + +} diff --git a/app/src/main/java/com/egobook/app/ui/diary/view/DiaryFragment.kt b/app/src/main/java/com/egobook/app/ui/diary/view/DiaryFragment.kt index 5b03458c..4dd4ea70 100644 --- a/app/src/main/java/com/egobook/app/ui/diary/view/DiaryFragment.kt +++ b/app/src/main/java/com/egobook/app/ui/diary/view/DiaryFragment.kt @@ -214,8 +214,6 @@ snackBar.show() } - - override fun onDestroyView() { super.onDestroyView() _binding = null diff --git a/app/src/main/java/com/egobook/app/ui/login/viewmodel/LoginViewModel.kt b/app/src/main/java/com/egobook/app/ui/login/viewmodel/LoginViewModel.kt index 7a0afb3f..0f4fc102 100644 --- a/app/src/main/java/com/egobook/app/ui/login/viewmodel/LoginViewModel.kt +++ b/app/src/main/java/com/egobook/app/ui/login/viewmodel/LoginViewModel.kt @@ -55,14 +55,22 @@ class LoginViewModel @Inject constructor( ) } } - //구글 로그인 시도 -> 토큰 재발급 + //구글 로그인 시도 -> 토큰 재발급 & 로그인 타입 저장 is LoginEvent.TryLoginByGoogle -> { viewModelScope.launch { _loginState.value = LoginState.Loading + val result = authUseCases.googleLogin(event.idToken) + result.fold( - onSuccess = { _loginState.value = LoginState.Success }, - onFailure = { error -> _loginState.value = LoginState.Error(error.message ?: "알 수 없는 오류") } + onSuccess = { + _loginState.value = LoginState.Success + + }, + onFailure = { error -> + _loginState.value = + LoginState.Error(error.message ?: "알 수 없는 오류") + } ) } } diff --git a/app/src/main/res/drawable/bg_delete_dialog.xml b/app/src/main/res/drawable/bg_delete_dialog.xml new file mode 100644 index 00000000..03835e9c --- /dev/null +++ b/app/src/main/res/drawable/bg_delete_dialog.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_account.xml b/app/src/main/res/layout/fragment_account.xml index 3f70055c..ce48ed7e 100644 --- a/app/src/main/res/layout/fragment_account.xml +++ b/app/src/main/res/layout/fragment_account.xml @@ -4,7 +4,7 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" - tools:context=".ui.account.AccountFragment"> + tools:context=".ui.account.view.AccountFragment"> + android:background="#F4F7EE"> - + - + + diff --git a/app/src/main/res/layout/fragment_account_delete_dialog1.xml b/app/src/main/res/layout/fragment_account_delete_dialog1.xml new file mode 100644 index 00000000..836ea0a9 --- /dev/null +++ b/app/src/main/res/layout/fragment_account_delete_dialog1.xml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_account_delete_dialog_2.xml b/app/src/main/res/layout/fragment_account_delete_dialog_2.xml new file mode 100644 index 00000000..7d57bb91 --- /dev/null +++ b/app/src/main/res/layout/fragment_account_delete_dialog_2.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/navigation/bottom_navigation.xml b/app/src/main/res/navigation/bottom_navigation.xml index d21e3587..4ac63428 100644 --- a/app/src/main/res/navigation/bottom_navigation.xml +++ b/app/src/main/res/navigation/bottom_navigation.xml @@ -193,7 +193,7 @@ + android:name="com.egobook.app.ui.account.view.AccountFragment">