diff --git a/app/src/androidTest/java/com/runnect/runnect/presentation/login/LoginScreenTest.kt b/app/src/androidTest/java/com/runnect/runnect/presentation/login/LoginScreenTest.kt new file mode 100644 index 000000000..d46ba7d3d --- /dev/null +++ b/app/src/androidTest/java/com/runnect/runnect/presentation/login/LoginScreenTest.kt @@ -0,0 +1,76 @@ +package com.runnect.runnect.presentation.login + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.runnect.runnect.presentation.ui.theme.RunnectTheme +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +class LoginScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun 로그인_버튼과_방문자_모드가_노출된다() { + composeTestRule.setContent { + RunnectTheme { + LoginScreen( + state = LoginUiState(), + onGoogleLoginClick = {}, + onKakaoLoginClick = {}, + onVisitorModeClick = {}, + onErrorShown = {} + ) + } + } + + composeTestRule.onNodeWithText("구글로 로그인").assertIsDisplayed() + composeTestRule.onNodeWithText("카카오로 로그인").assertIsDisplayed() + composeTestRule.onNodeWithText("회원가입 없이 둘러보기").assertIsDisplayed() + } + + @Test + fun 각_버튼을_누르면_대응하는_콜백이_호출된다() { + val clicked = mutableListOf() + + composeTestRule.setContent { + RunnectTheme { + LoginScreen( + state = LoginUiState(), + onGoogleLoginClick = { clicked.add("google") }, + onKakaoLoginClick = { clicked.add("kakao") }, + onVisitorModeClick = { clicked.add("visitor") }, + onErrorShown = {} + ) + } + } + + composeTestRule.onNodeWithTag(LoginScreenTestTags.GOOGLE_LOGIN_BUTTON).performClick() + composeTestRule.onNodeWithTag(LoginScreenTestTags.KAKAO_LOGIN_BUTTON).performClick() + composeTestRule.onNodeWithTag(LoginScreenTestTags.VISITOR_MODE_BUTTON).performClick() + + assertEquals(listOf("google", "kakao", "visitor"), clicked) + } + + @Test + fun 로딩_상태면_인디케이터가_노출된다() { + composeTestRule.setContent { + RunnectTheme { + LoginScreen( + state = LoginUiState(isLoading = true), + onGoogleLoginClick = {}, + onKakaoLoginClick = {}, + onVisitorModeClick = {}, + onErrorShown = {} + ) + } + } + + composeTestRule.onNodeWithTag(LoginScreenTestTags.LOADING_INDICATOR).assertIsDisplayed() + } +} diff --git a/app/src/main/java/com/runnect/runnect/presentation/login/LoginActivity.kt b/app/src/main/java/com/runnect/runnect/presentation/login/LoginActivity.kt index 9c9a54b42..134725e87 100644 --- a/app/src/main/java/com/runnect/runnect/presentation/login/LoginActivity.kt +++ b/app/src/main/java/com/runnect/runnect/presentation/login/LoginActivity.kt @@ -1,23 +1,23 @@ package com.runnect.runnect.presentation.login import android.content.ContentValues -import android.content.Context import android.content.Intent import android.os.Bundle import android.widget.Toast +import androidx.activity.compose.setContent import androidx.activity.viewModels -import androidx.core.view.isVisible +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState import com.runnect.runnect.R -import com.runnect.runnect.binding.BindingActivity -import com.runnect.runnect.databinding.ActivityLoginBinding import com.runnect.runnect.presentation.MainActivity import com.runnect.runnect.presentation.state.UiState +import com.runnect.runnect.presentation.ui.theme.RunnectTheme import com.runnect.runnect.util.analytics.Analytics import com.runnect.runnect.util.analytics.EventName import com.runnect.runnect.util.analytics.EventName.EVENT_CLICK_VISITOR import com.runnect.runnect.util.analytics.EventName.EVENT_VIEW_SOCIAL_LOGIN import com.runnect.runnect.util.analytics.EventName.Param -import com.runnect.runnect.util.extension.showSnackbar import com.runnect.runnect.util.extension.showToast import com.runnect.runnect.util.preference.AuthUtil.getAccessToken import com.runnect.runnect.util.preference.AuthUtil.saveToken @@ -26,8 +26,7 @@ import dagger.hilt.android.AndroidEntryPoint import timber.log.Timber @AndroidEntryPoint -class LoginActivity : - BindingActivity(R.layout.activity_login) { +class LoginActivity : AppCompatActivity() { private lateinit var socialLogin: SocialLogin private lateinit var googleLogin: GoogleLogin private lateinit var kakaoLogin: KakaoLogin @@ -67,27 +66,31 @@ class LoginActivity : viewModel = viewModel ) addObserver() - addListener() - } - - private fun addListener() { - val ctx: Context = this - with(binding) { - cvGoogleLogin.setOnClickListener { - socialLogin = googleLogin - socialLogin.signIn() - } - cvKakaoLogin.setOnClickListener { - socialLogin = kakaoLogin - socialLogin.signIn() - } - btnVisitorMode.setOnClickListener { - Analytics.logClickedItemEvent(EVENT_CLICK_VISITOR) - ctx.saveToken( - accessToken = LoginStatus.VISITOR.value, - refreshToken = LoginStatus.VISITOR.value + setContent { + val loginState by viewModel.loginState.observeAsState(UiState.Empty) + val errorMessage by viewModel.errorMessage.observeAsState() + + RunnectTheme { + LoginScreen( + state = LoginUiState.from(loginState, errorMessage), + onGoogleLoginClick = { + socialLogin = googleLogin + socialLogin.signIn() + }, + onKakaoLoginClick = { + socialLogin = kakaoLogin + socialLogin.signIn() + }, + onVisitorModeClick = { + Analytics.logClickedItemEvent(EVENT_CLICK_VISITOR) + saveToken( + accessToken = LoginStatus.VISITOR.value, + refreshToken = LoginStatus.VISITOR.value + ) + moveToMain() + }, + onErrorShown = viewModel::clearErrorMessage ) - moveToMain() } } } @@ -96,7 +99,6 @@ class LoginActivity : private fun addObserver() { viewModel.loginState.observe(this) { state -> when (state) { - UiState.Loading -> binding.indeterminateBar.isVisible = true UiState.Success -> { when (viewModel.loginResult.value?.type) { "Login" -> handleSuccessfulLogin() @@ -104,17 +106,17 @@ class LoginActivity : } } - else -> binding.indeterminateBar.isVisible = false + else -> Unit } } viewModel.errorMessage.observe(this) { + if (it == null) return@observe val method = if (::socialLogin.isInitialized && socialLogin is GoogleLogin) "google" else "kakao" Analytics.logEvent( EventName.ACTION_LOGIN_FAIL, Param.METHOD to method, Param.ERROR_CODE to "LOGIN_FAIL" ) - showSnackbar(binding.root, it) Timber.tag(ContentValues.TAG).d("로그인 통신 실패: $it") } } @@ -129,7 +131,6 @@ class LoginActivity : saveSignTokenInfo() moveToMain() Toast.makeText(this@LoginActivity, MESSAGE_LOGIN_SUCCESS, Toast.LENGTH_SHORT).show() - binding.indeterminateBar.isVisible = false } private fun handleSuccessfulSignup() { diff --git a/app/src/main/java/com/runnect/runnect/presentation/login/LoginScreen.kt b/app/src/main/java/com/runnect/runnect/presentation/login/LoginScreen.kt new file mode 100644 index 000000000..655c08693 --- /dev/null +++ b/app/src/main/java/com/runnect/runnect/presentation/login/LoginScreen.kt @@ -0,0 +1,189 @@ +package com.runnect.runnect.presentation.login + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import com.runnect.runnect.R +import com.runnect.runnect.presentation.state.UiState +import com.runnect.runnect.presentation.ui.theme.G1 +import com.runnect.runnect.presentation.ui.theme.G3 +import com.runnect.runnect.presentation.ui.theme.RunnectTheme +import com.runnect.runnect.presentation.ui.theme.White +import androidx.compose.ui.graphics.Color + +object LoginScreenTestTags { + const val GOOGLE_LOGIN_BUTTON = "google_login_button" + const val KAKAO_LOGIN_BUTTON = "kakao_login_button" + const val VISITOR_MODE_BUTTON = "visitor_mode_button" + const val LOADING_INDICATOR = "login_loading_indicator" +} + +data class LoginUiState( + val isLoading: Boolean = false, + val errorMessage: String? = null +) { + companion object { + fun from( + loginState: UiState, + errorMessage: String?, + ) = LoginUiState( + isLoading = loginState is UiState.Loading, + errorMessage = errorMessage + ) + } +} + +@Composable +fun LoginScreen( + state: LoginUiState, + onGoogleLoginClick: () -> Unit, + onKakaoLoginClick: () -> Unit, + onVisitorModeClick: () -> Unit, + onErrorShown: () -> Unit, +) { + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(state.errorMessage) { + state.errorMessage?.let { + snackbarHostState.showSnackbar(it) + onErrorShown() + } + } + + Scaffold( + snackbarHost = { SnackbarHost(snackbarHostState) } + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + Image( + painter = painterResource(R.drawable.splash), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + LoginActions( + onGoogleLoginClick = onGoogleLoginClick, + onKakaoLoginClick = onKakaoLoginClick, + onVisitorModeClick = onVisitorModeClick, + modifier = Modifier.align(Alignment.BottomCenter) + ) + if (state.isLoading) { + CircularProgressIndicator( + color = G3, + modifier = Modifier + .align(Alignment.Center) + .testTag(LoginScreenTestTags.LOADING_INDICATOR) + ) + } + } + } +} + +@Composable +private fun LoginActions( + onGoogleLoginClick: () -> Unit, + onKakaoLoginClick: () -> Unit, + onVisitorModeClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(bottom = 39.dp) + ) { + SocialLoginButton( + text = "구글로 로그인", + iconRes = R.drawable.ic_google_login, + backgroundColor = White, + testTag = LoginScreenTestTags.GOOGLE_LOGIN_BUTTON, + onClick = onGoogleLoginClick + ) + Spacer(modifier = Modifier.height(8.dp)) + SocialLoginButton( + text = "카카오로 로그인", + iconRes = R.drawable.ic_kakao_login, + backgroundColor = Color(0xFFFEE500), + testTag = LoginScreenTestTags.KAKAO_LOGIN_BUTTON, + onClick = onKakaoLoginClick + ) + Spacer(modifier = Modifier.height(18.dp)) + Text( + text = stringResource(R.string.login_visitor_mode), + style = RunnectTheme.textStyle.medium15.copy(textDecoration = TextDecoration.Underline), + color = White, + modifier = Modifier + .testTag(LoginScreenTestTags.VISITOR_MODE_BUTTON) + .clickable(onClick = onVisitorModeClick) + ) + } +} + +@Composable +private fun SocialLoginButton( + text: String, + iconRes: Int, + backgroundColor: Color, + testTag: String, + onClick: () -> Unit, +) { + Box( + modifier = Modifier + .testTag(testTag) + .fillMaxWidth() + .height(48.dp) + .clip(RoundedCornerShape(8.dp)) + .background(backgroundColor) + .clickable(onClick = onClick) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .align(Alignment.CenterStart) + .padding(start = 16.dp) + ) { + Image( + painter = painterResource(iconRes), + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + } + Text( + text = text, + style = RunnectTheme.textStyle.medium15, + color = G1, + modifier = Modifier.align(Alignment.Center) + ) + } +} diff --git a/app/src/main/java/com/runnect/runnect/presentation/login/LoginViewModel.kt b/app/src/main/java/com/runnect/runnect/presentation/login/LoginViewModel.kt index 020672f21..fcfafc29b 100644 --- a/app/src/main/java/com/runnect/runnect/presentation/login/LoginViewModel.kt +++ b/app/src/main/java/com/runnect/runnect/presentation/login/LoginViewModel.kt @@ -19,7 +19,7 @@ class LoginViewModel @Inject constructor( ) : BaseViewModel() { val loginResult = MutableLiveData() - val errorMessage = MutableLiveData() + val errorMessage = MutableLiveData() private val _loginState = MutableLiveData(UiState.Empty) val loginState: LiveData @@ -46,4 +46,8 @@ class LoginViewModel @Inject constructor( } ) } -} \ No newline at end of file + + fun clearErrorMessage() { + errorMessage.value = null + } +} diff --git a/app/src/main/res/layout/activity_login.xml b/app/src/main/res/layout/activity_login.xml deleted file mode 100644 index 587a61053..000000000 --- a/app/src/main/res/layout/activity_login.xml +++ /dev/null @@ -1,141 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/test/java/com/runnect/runnect/presentation/login/LoginUiStateTest.kt b/app/src/test/java/com/runnect/runnect/presentation/login/LoginUiStateTest.kt new file mode 100644 index 000000000..82ec9d8c1 --- /dev/null +++ b/app/src/test/java/com/runnect/runnect/presentation/login/LoginUiStateTest.kt @@ -0,0 +1,40 @@ +package com.runnect.runnect.presentation.login + +import com.runnect.runnect.presentation.state.UiState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class LoginUiStateTest { + + @Test + fun `loginState가_Loading이면_isLoading은_true다`() { + val state = LoginUiState.from( + loginState = UiState.Loading, + errorMessage = null + ) + + assertTrue(state.isLoading) + } + + @Test + fun `loginState가_Loading이_아니면_isLoading은_false다`() { + val state = LoginUiState.from( + loginState = UiState.Success, + errorMessage = null + ) + + assertFalse(state.isLoading) + } + + @Test + fun `errorMessage는_그대로_전달된다`() { + val state = LoginUiState.from( + loginState = UiState.Failure, + errorMessage = "로그인 실패" + ) + + assertEquals("로그인 실패", state.errorMessage) + } +} diff --git a/app/src/test/java/com/runnect/runnect/presentation/login/LoginViewModelTest.kt b/app/src/test/java/com/runnect/runnect/presentation/login/LoginViewModelTest.kt new file mode 100644 index 000000000..5d73fd753 --- /dev/null +++ b/app/src/test/java/com/runnect/runnect/presentation/login/LoginViewModelTest.kt @@ -0,0 +1,98 @@ +package com.runnect.runnect.presentation.login + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.runnect.runnect.data.dto.LoginDTO +import com.runnect.runnect.data.dto.request.RequestPostLogin +import com.runnect.runnect.domain.repository.LoginRepository +import com.runnect.runnect.presentation.state.UiState +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class LoginViewModelTest { + + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + private val testDispatcher = StandardTestDispatcher() + + private lateinit var loginRepository: LoginRepository + private lateinit var viewModel: LoginViewModel + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + loginRepository = mockk() + viewModel = LoginViewModel(loginRepository) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `postLogin 성공 시 loginResult와 Success 상태로 갱신된다`() = runTest(testDispatcher) { + val request = RequestPostLogin(token = "social-token", provider = "GOOGLE") + val loginResult = LoginDTO( + accessToken = "access", + refreshToken = "refresh", + email = "test@runnect.com", + type = "Login" + ) + coEvery { loginRepository.postLogin(request) } returns flow { + delay(1) + emit(Result.success(loginResult)) + } + + val states = mutableListOf() + viewModel.loginState.observeForever { states.add(it) } + + viewModel.postLogin(request) + advanceUntilIdle() + + assertEquals(listOf(UiState.Empty, UiState.Loading, UiState.Success), states) + assertEquals(loginResult, viewModel.loginResult.value) + } + + @Test + fun `postLogin 실패 시 errorMessage와 Failure 상태로 갱신된다`() = runTest(testDispatcher) { + val request = RequestPostLogin(token = "social-token", provider = "KAKAO") + coEvery { loginRepository.postLogin(request) } returns flow { + delay(1) + emit(Result.failure(RuntimeException("로그인 실패"))) + } + + val states = mutableListOf() + viewModel.loginState.observeForever { states.add(it) } + + viewModel.postLogin(request) + advanceUntilIdle() + + assertEquals(listOf(UiState.Empty, UiState.Loading, UiState.Failure), states) + assertEquals("로그인 실패 (unknown)", viewModel.errorMessage.value) + } + + @Test + fun `clearErrorMessage는_errorMessage를_null로_초기화한다`() { + viewModel.errorMessage.value = "로그인 실패" + + viewModel.clearErrorMessage() + + assertEquals(null, viewModel.errorMessage.value) + } +}