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
@@ -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<String>()

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()
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -26,8 +26,7 @@ import dagger.hilt.android.AndroidEntryPoint
import timber.log.Timber

@AndroidEntryPoint
class LoginActivity :
BindingActivity<ActivityLoginBinding>(R.layout.activity_login) {
class LoginActivity : AppCompatActivity() {
private lateinit var socialLogin: SocialLogin
private lateinit var googleLogin: GoogleLogin
private lateinit var kakaoLogin: KakaoLogin
Expand Down Expand Up @@ -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()
}
}
}
Expand All @@ -96,25 +99,24 @@ 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()
"Signup" -> handleSuccessfulSignup()
}
}

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")
}
}
Expand All @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
)
}
}
Loading
Loading