Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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)
}
}
}

// 소셜 로그인
Expand All @@ -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"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,15 @@ 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
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
Expand All @@ -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
Expand Down Expand Up @@ -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
)
}
}
}
Expand All @@ -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 }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<SignUpSideEffect>() {

private val _signUpState: MutableStateFlow<SignUpState> = MutableStateFlow(SignUpState())
val signUpState: StateFlow<SignUpState> get() = _signUpState.asStateFlow()

private val _profileImage = MutableStateFlow("")
private val profileImage: StateFlow<String> = _profileImage

private val _signUpUiState = MutableStateFlow<UiState<Unit>>(UiState.Empty)
val signUpUiState: StateFlow<UiState<Unit>> = _signUpUiState.asStateFlow()

private val _showErrorDialog = MutableStateFlow(false)
val showErrorDialog: StateFlow<Boolean> get() = _showErrorDialog

fun onNicknameChanged(nickname: String) {
_signUpState.update { it.copy(nickname = nickname) }
validateNickname(nickname)
Expand All @@ -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())

Expand All @@ -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
}
}
}