From 1c45f13abfb0941de24f0761dd5680c0384a5df6 Mon Sep 17 00:00:00 2001 From: youjin09222 Date: Tue, 29 Apr 2025 14:47:21 +0900 Subject: [PATCH 1/6] =?UTF-8?q?#7=20[FIX]=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=97=85=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/signup/SignUpViewModel.kt | 95 ++++++++++--------- 1 file changed, 49 insertions(+), 46 deletions(-) diff --git a/presentation/src/main/java/com/sopt/presentation/auth/signup/SignUpViewModel.kt b/presentation/src/main/java/com/sopt/presentation/auth/signup/SignUpViewModel.kt index c1c50fcc..3c28b75a 100644 --- a/presentation/src/main/java/com/sopt/presentation/auth/signup/SignUpViewModel.kt +++ b/presentation/src/main/java/com/sopt/presentation/auth/signup/SignUpViewModel.kt @@ -1,5 +1,7 @@ package com.sopt.presentation.auth.signup +import android.app.Application +import android.net.Uri import androidx.lifecycle.viewModelScope import com.sopt.core.util.BaseViewModel import com.sopt.domain.entity.AuthEntity @@ -9,7 +11,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import okhttp3.MediaType.Companion.toMediaTypeOrNull @@ -23,12 +24,16 @@ import javax.inject.Inject @HiltViewModel class SignUpViewModel @Inject constructor( private val userInfoRepository: UserInfoRepository, - private val postSignUpUseCase: PostSignUpUseCase + private val postSignUpUseCase: PostSignUpUseCase, + private val application: Application ) : BaseViewModel() { private val _signUpState: MutableStateFlow = MutableStateFlow(SignUpState()) val signUpState: StateFlow get() = _signUpState.asStateFlow() + private val _profileImage = MutableStateFlow("") + private val profileImage: StateFlow = _profileImage + fun onNicknameChanged(nickname: String) { _signUpState.update { it.copy(nickname = nickname) } validateNickname(nickname) @@ -52,51 +57,22 @@ class SignUpViewModel @Inject constructor( } } - fun isGalleryPermissionGranted(): Boolean { - return _signUpState.value.isPermissionGranted - } + fun isGalleryPermissionGranted(): Boolean = _signUpState.value.isPermissionGranted fun updateProfileImage(imageUri: String?) { - executeInScope { + viewModelScope.launch { _signUpState.update { it.copy(profileImageUri = imageUri) } + imageUri?.let { _profileImage.value = it } imageUri?.let { userInfoRepository.saveProfileImage(it) } } } - private fun navigateToCheckInvite() { - val nickname = _signUpState.value.nickname - if (nickname.isNotEmpty()) { - saveUserNickname(nickname) - emitSideEffect(SignUpSideEffect.NavigateToCheckInvite(nickname)) - } - - viewModelScope.launch { - userInfoRepository.saveIsAutoLogin(true) - } - } - - private fun saveUserNickname(nickname: String) { - executeInScope { - userInfoRepository.saveNickname(nickname) - } - } - - private fun executeInScope(block: suspend () -> Unit) { - viewModelScope.launch { block() } - } - - fun postSignUp( - accessToken: String, - socialType: String - ) { + fun postSignUp(accessToken: String, socialType: String) { viewModelScope.launch { val memberName = _signUpState.value.nickname - val profileImageUri = userInfoRepository.getProfileImage().firstOrNull() - - val memberProfileImage = profileImageUri?.let { uri -> - uriToMultipartBody(uri) - } + val profileImageUri = _signUpState.value.profileImageUri + val memberProfileImage = profileImageUri?.let { uriToMultipartBody(it) } val nameRequestBody = memberName.toRequestBody("text/plain".toMediaTypeOrNull()) val authTypeRequestBody = socialType.toRequestBody("text/plain".toMediaTypeOrNull()) @@ -107,7 +83,7 @@ class SignUpViewModel @Inject constructor( authType = authTypeRequestBody ).fold( onSuccess = { authEntity -> - saveUserInfo(authEntity) + saveUserInfo(authEntity, profileImageUri) navigateToCheckInvite() }, onFailure = { error -> @@ -117,22 +93,49 @@ class SignUpViewModel @Inject constructor( } } - private fun uriToMultipartBody(filePath: String): MultipartBody.Part? { - val file = File(filePath) - return if (file.exists()) { - val requestBody = file.asRequestBody("image/*".toMediaTypeOrNull()) - MultipartBody.Part.createFormData("memberProfileImage", file.name, requestBody) - } else { - null + private fun navigateToCheckInvite() { + val nickname = _signUpState.value.nickname + if (nickname.isNotEmpty()) { + saveUserNickname(nickname) + emitSideEffect(SignUpSideEffect.NavigateToCheckInvite(nickname)) + } + + viewModelScope.launch { + userInfoRepository.saveIsAutoLogin(true) + } + } + + private fun saveUserNickname(nickname: String) { + viewModelScope.launch { + userInfoRepository.saveNickname(nickname) } } - private fun saveUserInfo(authEntity: AuthEntity) { + private fun saveUserInfo(authEntity: AuthEntity, profileImageUri: String?) { viewModelScope.launch { userInfoRepository.saveAccessToken("Bearer ${authEntity.accessToken}") userInfoRepository.saveRefreshToken("Bearer ${authEntity.refreshToken}") userInfoRepository.saveMemberId(authEntity.memberId) userInfoRepository.saveIsAutoLogin(true) + profileImageUri?.let { userInfoRepository.saveProfileImage(it) } + } + } + + private fun uriToMultipartBody(uriString: String): MultipartBody.Part? { + return try { + val contentResolver = application.contentResolver + val uri = Uri.parse(uriString) + val inputStream = contentResolver.openInputStream(uri) ?: return null + + val tempFile = File.createTempFile("upload", ".jpg", application.cacheDir) + tempFile.outputStream().use { output -> + inputStream.copyTo(output) + } + + val requestBody = tempFile.asRequestBody("image/jpeg".toMediaTypeOrNull()) + MultipartBody.Part.createFormData("memberProfileImage", tempFile.name, requestBody) + } catch (e: Exception) { + null } } } From ac1653c201413b9f584c85c8c2e0cb535bb9d2fb Mon Sep 17 00:00:00 2001 From: youjin09222 Date: Tue, 29 Apr 2025 14:51:11 +0900 Subject: [PATCH 2/6] =?UTF-8?q?#7=20[ADD]=20SignUpSideEffect=EC=97=90=20Sh?= =?UTF-8?q?owErrorDialog=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/sopt/presentation/auth/signup/SignUpSideEffect.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/presentation/src/main/java/com/sopt/presentation/auth/signup/SignUpSideEffect.kt b/presentation/src/main/java/com/sopt/presentation/auth/signup/SignUpSideEffect.kt index f189b3af..685e6dce 100644 --- a/presentation/src/main/java/com/sopt/presentation/auth/signup/SignUpSideEffect.kt +++ b/presentation/src/main/java/com/sopt/presentation/auth/signup/SignUpSideEffect.kt @@ -4,4 +4,5 @@ sealed interface SignUpSideEffect { data class NavigateToCheckInvite(val name: String) : SignUpSideEffect data object RequestImagePicker : SignUpSideEffect data object ShowSnackBar : SignUpSideEffect + data object ShowErrorDialog : SignUpSideEffect } From 80c10f3517dc87adfde04a1ef6887678c065dad5 Mon Sep 17 00:00:00 2001 From: youjin09222 Date: Tue, 29 Apr 2025 14:54:04 +0900 Subject: [PATCH 3/6] =?UTF-8?q?#7=20[FEAT]=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EB=B0=9C=EC=83=9D=20=EC=8B=9C=20=EB=8B=A4?= =?UTF-8?q?=EC=9D=B4=EC=96=BC=EB=A1=9C=EA=B7=B8=20=EB=9D=84=EC=9A=B0?= =?UTF-8?q?=EB=8A=94=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/auth/signup/SignUpRoute.kt | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/presentation/src/main/java/com/sopt/presentation/auth/signup/SignUpRoute.kt b/presentation/src/main/java/com/sopt/presentation/auth/signup/SignUpRoute.kt index 81d21cc7..6585a793 100644 --- a/presentation/src/main/java/com/sopt/presentation/auth/signup/SignUpRoute.kt +++ b/presentation/src/main/java/com/sopt/presentation/auth/signup/SignUpRoute.kt @@ -39,6 +39,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.flowWithLifecycle import com.sopt.core.designsystem.component.button.NoostakBottomButton +import com.sopt.core.designsystem.component.dialog.NoostakDialog import com.sopt.core.designsystem.component.image.ProfileImagePicker import com.sopt.core.designsystem.component.snackbar.NoostakSnackBar import com.sopt.core.designsystem.component.snackbar.SNACK_BAR_DURATION @@ -46,6 +47,7 @@ import com.sopt.core.designsystem.component.textfield.NoostakTextField import com.sopt.core.designsystem.theme.NoostakAndroidTheme import com.sopt.core.designsystem.theme.NoostakTheme import com.sopt.core.extension.launchImagePicker +import com.sopt.core.type.DialogType import com.sopt.core.type.ImagePickerType import com.sopt.core.type.TextFieldType import com.sopt.core.util.permission.ImagePickerLaunchers @@ -66,15 +68,16 @@ fun SignUpRoute( val signUpState by signUpViewModel.signUpState.collectAsStateWithLifecycle() var isGalleryPermission by remember { mutableStateOf(false) } + val showErrorDialog by signUpViewModel.showErrorDialog.collectAsStateWithLifecycle() val snackBarHostState = remember { SnackbarHostState() } val coroutineScope = rememberCoroutineScope() var snackBarVisible by remember { mutableStateOf(false) } - val onShowPermissionGallerySnackBar: (message: String) -> Unit = { + val onShowPermissionGallerySnackBar: (String) -> Unit = { message -> coroutineScope.launch { snackBarVisible = true - val job = launch { snackBarHostState.showSnackbar(message = it) } + val job = launch { snackBarHostState.showSnackbar(message) } delay(SNACK_BAR_DURATION) job.cancel() snackBarVisible = false @@ -108,15 +111,14 @@ fun SignUpRoute( .collect { sideEffect -> when (sideEffect) { is SignUpSideEffect.NavigateToCheckInvite -> navigateToCheckInvite(sideEffect.name) - - is SignUpSideEffect.ShowSnackBar -> - isGalleryPermission = - true - + is SignUpSideEffect.ShowSnackBar -> isGalleryPermission = true is SignUpSideEffect.RequestImagePicker -> context.launchImagePicker( galleryLauncher, photoPickerLauncher ) + is SignUpSideEffect.ShowErrorDialog -> signUpViewModel.showErrorDialog( + true + ) } } } @@ -126,6 +128,16 @@ fun SignUpRoute( isGalleryPermission = false } + if (showErrorDialog) { + NoostakDialog( + dialogType = DialogType.NETWORK_FAILURE, + onClick = { + signUpViewModel.postSignUp(accessToken, socialType) + }, + onDismissRequest = { signUpViewModel.showErrorDialog(false) } + ) + } + AnimatedVisibility( visible = snackBarVisible, enter = slideInVertically(initialOffsetY = { it }), From 3fd33c9cc79d3924ddf6d24d9a98ab48efe4fe0f Mon Sep 17 00:00:00 2001 From: youjin09222 Date: Tue, 29 Apr 2025 14:54:23 +0900 Subject: [PATCH 4/6] =?UTF-8?q?#7=20[FEAT]=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20=EB=8B=A4=EC=9D=B4?= =?UTF-8?q?=EC=96=BC=EB=A1=9C=EA=B7=B8=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/signup/SignUpViewModel.kt | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/presentation/src/main/java/com/sopt/presentation/auth/signup/SignUpViewModel.kt b/presentation/src/main/java/com/sopt/presentation/auth/signup/SignUpViewModel.kt index 3c28b75a..cd903c20 100644 --- a/presentation/src/main/java/com/sopt/presentation/auth/signup/SignUpViewModel.kt +++ b/presentation/src/main/java/com/sopt/presentation/auth/signup/SignUpViewModel.kt @@ -3,6 +3,7 @@ package com.sopt.presentation.auth.signup import android.app.Application import android.net.Uri import androidx.lifecycle.viewModelScope +import com.sopt.core.state.UiState import com.sopt.core.util.BaseViewModel import com.sopt.domain.entity.AuthEntity import com.sopt.domain.repository.UserInfoRepository @@ -17,7 +18,6 @@ import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody import okhttp3.RequestBody.Companion.asRequestBody import okhttp3.RequestBody.Companion.toRequestBody -import timber.log.Timber import java.io.File import javax.inject.Inject @@ -34,6 +34,12 @@ class SignUpViewModel @Inject constructor( private val _profileImage = MutableStateFlow("") private val profileImage: StateFlow = _profileImage + private val _signUpUiState = MutableStateFlow>(UiState.Empty) + val signUpUiState: StateFlow> = _signUpUiState.asStateFlow() + + private val _showErrorDialog = MutableStateFlow(false) + val showErrorDialog: StateFlow get() = _showErrorDialog + fun onNicknameChanged(nickname: String) { _signUpState.update { it.copy(nickname = nickname) } validateNickname(nickname) @@ -69,6 +75,8 @@ class SignUpViewModel @Inject constructor( fun postSignUp(accessToken: String, socialType: String) { viewModelScope.launch { + _signUpUiState.emit(UiState.Loading) + val memberName = _signUpState.value.nickname val profileImageUri = _signUpState.value.profileImageUri @@ -84,15 +92,21 @@ class SignUpViewModel @Inject constructor( ).fold( onSuccess = { authEntity -> saveUserInfo(authEntity, profileImageUri) + _signUpUiState.emit(UiState.Success(Unit)) navigateToCheckInvite() }, - onFailure = { error -> - Timber.e("postSignUp Failed: ${error.message}") + onFailure = { + _signUpUiState.emit(UiState.Failure(it.message.orEmpty())) + emitSideEffect(SignUpSideEffect.ShowErrorDialog) } ) } } + fun showErrorDialog(show: Boolean) { + _showErrorDialog.update { show } + } + private fun navigateToCheckInvite() { val nickname = _signUpState.value.nickname if (nickname.isNotEmpty()) { From 2c08bd51bb314d03c41b40e6e48b16c1122b8ea7 Mon Sep 17 00:00:00 2001 From: youjin09222 Date: Tue, 29 Apr 2025 14:58:10 +0900 Subject: [PATCH 5/6] =?UTF-8?q?#7=20[DEL]=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=84=B1=EA=B3=B5/=EC=8B=A4=ED=8C=A8=20=ED=86=A0=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/auth/login/LoginViewModel.kt | 35 ------------------- 1 file changed, 35 deletions(-) diff --git a/presentation/src/main/java/com/sopt/presentation/auth/login/LoginViewModel.kt b/presentation/src/main/java/com/sopt/presentation/auth/login/LoginViewModel.kt index 38a436ed..1da361d5 100644 --- a/presentation/src/main/java/com/sopt/presentation/auth/login/LoginViewModel.kt +++ b/presentation/src/main/java/com/sopt/presentation/auth/login/LoginViewModel.kt @@ -2,21 +2,17 @@ package com.sopt.presentation.auth.login import android.content.Context import android.content.Intent -import androidx.annotation.StringRes import androidx.lifecycle.viewModelScope import com.google.android.gms.auth.api.signin.GoogleSignIn import com.google.android.gms.auth.api.signin.GoogleSignInClient import com.google.android.gms.auth.api.signin.GoogleSignInOptions import com.kakao.sdk.auth.model.OAuthToken -import com.kakao.sdk.common.model.ClientError -import com.kakao.sdk.common.model.ClientErrorCause import com.kakao.sdk.user.UserApiClient import com.sopt.core.type.DialogType import com.sopt.core.util.BaseViewModel import com.sopt.domain.entity.AuthTypeEntity import com.sopt.domain.repository.UserInfoRepository import com.sopt.domain.usecase.PostSocialLoginUseCase -import com.sopt.presentation.R import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -82,9 +78,7 @@ class LoginViewModel @Inject constructor( BEARER + it.accessToken, KAKAO ) - showToast(R.string.toast_kakao_login_success) } ?: run { - handleError(error, R.string.toast_kakao_login_failed) showDialog(DialogType.NETWORK_LOGIN_KAKAO_FAILURE, true) } } @@ -128,25 +122,6 @@ class LoginViewModel @Inject constructor( // Google Login private fun onGoogleAccessTokenReceived(accessToken: String) { postSocialLogin(BEARER + accessToken, GOOGLE) - showToast(R.string.toast_google_login_success) - } - - private fun handleError(error: Throwable?, @StringRes errorMessageResId: Int) { - when { - // 카카오 로그인 취소 - error is ClientError && error.reason == ClientErrorCause.Cancelled -> { - showToast(R.string.toast_login_cancelled) - } - // 구글 로그인 취소 - error?.message?.contains(CANCELLED, ignoreCase = true) == true -> { - showToast(R.string.toast_login_cancelled) - } - - else -> { - val errorMessage = error?.localizedMessage.orEmpty() - showToast(errorMessageResId, errorMessage) - } - } } // 소셜 로그인 @@ -173,18 +148,8 @@ class LoginViewModel @Inject constructor( } } - private fun showToast(@StringRes messageResId: Int, vararg formatArgs: String) { - emitSideEffect( - LoginSideEffect.ShowToast( - message = messageResId, - args = formatArgs.joinToString(separator = ", ") - ) - ) - } - companion object { private const val BEARER = "Bearer " - private const val CANCELLED = "CANCELLED" private const val KAKAO = "KAKAO" private const val GOOGLE = "GOOGLE" } From 3111aabd290f2e3581b8379cd2bc8aebb8e27dcb Mon Sep 17 00:00:00 2001 From: youjin09222 Date: Tue, 29 Apr 2025 14:58:48 +0900 Subject: [PATCH 6/6] =?UTF-8?q?#7=20[DEL]=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20error=20=ED=8C=8C=EB=9D=BC?= =?UTF-8?q?=EB=AF=B8=ED=84=B0=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/sopt/presentation/auth/login/LoginViewModel.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/presentation/src/main/java/com/sopt/presentation/auth/login/LoginViewModel.kt b/presentation/src/main/java/com/sopt/presentation/auth/login/LoginViewModel.kt index 1da361d5..96501bef 100644 --- a/presentation/src/main/java/com/sopt/presentation/auth/login/LoginViewModel.kt +++ b/presentation/src/main/java/com/sopt/presentation/auth/login/LoginViewModel.kt @@ -60,18 +60,18 @@ class LoginViewModel @Inject constructor( // Kakao Login fun kakaoLogin(context: Context) { - val loginCallback: (OAuthToken?, Throwable?) -> Unit = this::handleKakaoLoginResult + val loginCallback: (OAuthToken?) -> Unit = this::handleKakaoLoginResult with(UserApiClient.instance) { if (isKakaoTalkLoginAvailable(context)) { - loginWithKakaoTalk(context, callback = loginCallback) + loginWithKakaoTalk(context) { token, _ -> loginCallback(token) } } else { - loginWithKakaoAccount(context, callback = loginCallback) + loginWithKakaoAccount(context) { token, _ -> loginCallback(token) } } } } - private fun handleKakaoLoginResult(token: OAuthToken?, error: Throwable?) { + private fun handleKakaoLoginResult(token: OAuthToken?) { viewModelScope.launch { token?.let { postSocialLogin(