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..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 @@ -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 @@ -64,27 +60,25 @@ 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( 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" } 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 }), 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 } 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..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 @@ -1,6 +1,9 @@ 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 @@ -9,26 +12,34 @@ 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 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 @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 + + 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) @@ -52,51 +63,24 @@ 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)) - } - + fun postSignUp(accessToken: String, socialType: String) { viewModelScope.launch { - userInfoRepository.saveIsAutoLogin(true) - } - } - - private fun saveUserNickname(nickname: String) { - executeInScope { - userInfoRepository.saveNickname(nickname) - } - } + _signUpUiState.emit(UiState.Loading) - private fun executeInScope(block: suspend () -> Unit) { - viewModelScope.launch { block() } - } - - 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,32 +91,65 @@ class SignUpViewModel @Inject constructor( authType = authTypeRequestBody ).fold( onSuccess = { authEntity -> - saveUserInfo(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) } ) } } - 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 + fun showErrorDialog(show: Boolean) { + _showErrorDialog.update { show } + } + + private fun navigateToCheckInvite() { + val nickname = _signUpState.value.nickname + if (nickname.isNotEmpty()) { + saveUserNickname(nickname) + emitSideEffect(SignUpSideEffect.NavigateToCheckInvite(nickname)) + } + + viewModelScope.launch { + userInfoRepository.saveIsAutoLogin(true) } } - private fun saveUserInfo(authEntity: AuthEntity) { + private fun saveUserNickname(nickname: String) { + viewModelScope.launch { + userInfoRepository.saveNickname(nickname) + } + } + + 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 } } }