From 20d473fa4f5ee4ca39a3a8905fbb9ab50d2e1535 Mon Sep 17 00:00:00 2001 From: Hyoeun Date: Tue, 17 Dec 2024 20:52:21 +0900 Subject: [PATCH 1/9] =?UTF-8?q?#11=20[Feat]=20UiEvent,=20UiSideEffect,=20U?= =?UTF-8?q?iState=20interface=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/java/org/sopt/and/core/component/UiEvent.kt | 3 +++ app/src/main/java/org/sopt/and/core/component/UiSideEffect.kt | 3 +++ app/src/main/java/org/sopt/and/core/component/UiState.kt | 3 +++ 3 files changed, 9 insertions(+) create mode 100644 app/src/main/java/org/sopt/and/core/component/UiEvent.kt create mode 100644 app/src/main/java/org/sopt/and/core/component/UiSideEffect.kt create mode 100644 app/src/main/java/org/sopt/and/core/component/UiState.kt diff --git a/app/src/main/java/org/sopt/and/core/component/UiEvent.kt b/app/src/main/java/org/sopt/and/core/component/UiEvent.kt new file mode 100644 index 0000000..e6b4afa --- /dev/null +++ b/app/src/main/java/org/sopt/and/core/component/UiEvent.kt @@ -0,0 +1,3 @@ +package org.sopt.and.core.component + +interface UiEvent diff --git a/app/src/main/java/org/sopt/and/core/component/UiSideEffect.kt b/app/src/main/java/org/sopt/and/core/component/UiSideEffect.kt new file mode 100644 index 0000000..687a232 --- /dev/null +++ b/app/src/main/java/org/sopt/and/core/component/UiSideEffect.kt @@ -0,0 +1,3 @@ +package org.sopt.and.core.component + +interface UiSideEffect diff --git a/app/src/main/java/org/sopt/and/core/component/UiState.kt b/app/src/main/java/org/sopt/and/core/component/UiState.kt new file mode 100644 index 0000000..251fc25 --- /dev/null +++ b/app/src/main/java/org/sopt/and/core/component/UiState.kt @@ -0,0 +1,3 @@ +package org.sopt.and.core.component + +interface UiState From a6506b12f4178fb4196115efac5950e1ab21e64a Mon Sep 17 00:00:00 2001 From: Hyoeun Date: Tue, 17 Dec 2024 20:52:34 +0900 Subject: [PATCH 2/9] =?UTF-8?q?#11=20[Feat]=20SignUpContract=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feature/signup/model/SignUpContract.kt | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 app/src/main/java/org/sopt/and/feature/signup/model/SignUpContract.kt diff --git a/app/src/main/java/org/sopt/and/feature/signup/model/SignUpContract.kt b/app/src/main/java/org/sopt/and/feature/signup/model/SignUpContract.kt new file mode 100644 index 0000000..4174913 --- /dev/null +++ b/app/src/main/java/org/sopt/and/feature/signup/model/SignUpContract.kt @@ -0,0 +1,27 @@ +package org.sopt.and.feature.signup.model + +import org.sopt.and.core.component.UiEvent +import org.sopt.and.core.component.UiSideEffect +import org.sopt.and.core.component.UiState + +class SignUpContract { + data class SignUpState( + val email: String = "", + val password: String = "", + val hobby: String = "", + val isLoading: Boolean = false, + val errorMessage: String? = null + ) : UiState + + sealed class SignUpEvent : UiEvent { + data class UpdateEmail(val email: String) : SignUpEvent() + data class UpdatePassword(val password: String) : SignUpEvent() + data class UpdateHobby(val hobby: String) : SignUpEvent() + data object SubmitSignUp : SignUpEvent() + } + + sealed class SignUpSideEffect:UiSideEffect { + data object NavigateToLogin : SignUpSideEffect() + data class ShowSnackbar(val message: String) : SignUpSideEffect() + } +} From 8d1106562da8e49e6a0f2bf2035f154c80837d29 Mon Sep 17 00:00:00 2001 From: Hyoeun Date: Tue, 17 Dec 2024 20:52:54 +0900 Subject: [PATCH 3/9] =?UTF-8?q?#11=20[Mod]=20SignUpScreen,SignUpViewModel?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sopt/and/feature/signup/SignUpScreen.kt | 102 +++++++++--------- .../and/feature/signup/SignUpViewModel.kt | 67 +++++------- 2 files changed, 77 insertions(+), 92 deletions(-) diff --git a/app/src/main/java/org/sopt/and/feature/signup/SignUpScreen.kt b/app/src/main/java/org/sopt/and/feature/signup/SignUpScreen.kt index 8435014..20871c3 100644 --- a/app/src/main/java/org/sopt/and/feature/signup/SignUpScreen.kt +++ b/app/src/main/java/org/sopt/and/feature/signup/SignUpScreen.kt @@ -1,6 +1,6 @@ package org.sopt.and.feature.signup -import android.util.Log +import android.widget.Toast import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.PaddingValues 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.text.BasicText import androidx.compose.material3.ButtonColors @@ -33,33 +34,45 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController +import kotlinx.coroutines.flow.collectLatest import org.sopt.and.R -import org.sopt.and.UiState import org.sopt.and.core.component.DescriptionText import org.sopt.and.core.component.DividerWithText import org.sopt.and.core.component.textfield.CustomEmailTextField import org.sopt.and.core.component.textfield.CustomPwTextField -import org.sopt.and.core.showToast -import org.sopt.and.domain.entity.UserInfo +import org.sopt.and.feature.signup.model.SignUpContract +import org.sopt.and.feature.signup.viewmodel.SignUpViewModel import org.sopt.and.ui.theme.ANDANDROIDTheme import org.sopt.and.util.extenstion.applyColorSpan -@Composable -fun SignUpScreen(navController: NavController) { - val viewModel: SignUpViewModel = hiltViewModel() - val signUpEmail by viewModel.email.collectAsState() - val signUpPassword by viewModel.password.collectAsState() - var passwordVisible by remember { mutableStateOf(false) } - val signUpHobby by viewModel.hobby.collectAsState() +@Composable +fun SignUpScreen(navController: NavController, viewModel: SignUpViewModel = hiltViewModel()) { + val uiState by viewModel.uiState.collectAsState() val context = LocalContext.current + var passwordVisible by remember { mutableStateOf(false) } - val signUpState by viewModel.signUpState.collectAsState() + LaunchedEffect(Unit) { + viewModel.sideEffect.collectLatest { sideEffect -> + when (sideEffect) { + is SignUpContract.SignUpSideEffect.NavigateToLogin -> { + navController.navigate("login") { + popUpTo("signup") { inclusive = true } + } + } + + is SignUpContract.SignUpSideEffect.ShowSnackbar -> { + Toast.makeText(context, sideEffect.message, Toast.LENGTH_SHORT).show() + } + } + } + } Column( modifier = Modifier .fillMaxSize() .background(color = Color(0xFF1B1B1B)) + .padding(horizontal = 10.dp) ) { Column( modifier = Modifier @@ -67,7 +80,7 @@ fun SignUpScreen(navController: NavController) { .padding(horizontal = 10.dp) ) { SignUpTopBar() - Spacer(modifier = Modifier.padding(top = 10.dp)) + Spacer(modifier = Modifier.height(10.dp)) BasicText( text = stringResource(R.string.signup_join_with_email_password).applyColorSpan( @@ -83,26 +96,31 @@ fun SignUpScreen(navController: NavController) { color = Color.White, ) ) - Spacer(modifier = Modifier.padding(top = 20.dp)) + Spacer(modifier = Modifier.height(20.dp)) CustomEmailTextField( - value = signUpEmail, - onValueChange = { viewModel.updateEmail(it) }, + value = uiState.email, + onValueChange = { viewModel.setEvent(SignUpContract.SignUpEvent.UpdateEmail(it)) }, placeholder = "wavve@example.com" ) DescriptionText(stringResource(R.string.signup_id_description)) - Spacer(modifier = Modifier.padding(top = 20.dp)) + Spacer(modifier = Modifier.height(20.dp)) Box( modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd ) { CustomPwTextField( - value = signUpPassword, - onValueChange = { viewModel.updatePassword(it) }, - placeholder = stringResource(R.string.login_setting_password), -// passwordVisible = passwordVisible + value = uiState.password, + onValueChange = { + viewModel.setEvent( + SignUpContract.SignUpEvent.UpdatePassword( + it + ) + ) + }, + placeholder = stringResource(R.string.login_setting_password) ) Text( text = if (passwordVisible) "hide" else "show", @@ -113,58 +131,34 @@ fun SignUpScreen(navController: NavController) { ) } DescriptionText(stringResource(R.string.signup_password_description)) + CustomEmailTextField( - value = signUpHobby, - onValueChange = { viewModel.updateHobby(it) }, + value = uiState.hobby, + onValueChange = { viewModel.setEvent(SignUpContract.SignUpEvent.UpdateHobby(it)) }, placeholder = "취미를 적어주세요" ) DescriptionText("취미는 8자 이하여야 합니다") - Spacer(modifier = Modifier.padding(top = 30.dp)) - DividerWithText(stringResource(R.string.login_join_with_social_account)) + Spacer(modifier = Modifier.height(30.dp)) + DividerWithText(stringResource(R.string.login_join_with_social_account)) Image( painter = painterResource(id = R.drawable.ic_social_login), - contentDescription = "Social Login", + contentDescription = "Social Login" ) - Spacer(modifier = Modifier.padding(top = 20.dp)) + Spacer(modifier = Modifier.height(20.dp)) DescriptionText(stringResource(R.string.login_join_social_account_description)) } NavigateToLogin( - backgroundColor = buttonEnableBackgroundColor(signUpEmail, signUpPassword) + backgroundColor = buttonEnableBackgroundColor(uiState.email, uiState.password) ) { - val userInfo = UserInfo( - userName = signUpEmail, - password = signUpPassword, - hobby = signUpHobby - ) - - viewModel.submitSignUp(userInfo) - } - - when (val state = signUpState) { - is UiState.Success -> { - val response = state.data - LaunchedEffect(response) { - navController.navigate("login") { - popUpTo("signup") { inclusive = true } - } - } - } - - is UiState.Failure -> { - context.showToast(state.errorMessage) - Log.d("hi", state.errorMessage) - } - - else -> Unit + viewModel.setEvent(SignUpContract.SignUpEvent.SubmitSignUp) } } } - @Composable fun SignUpTopBar() { Box( diff --git a/app/src/main/java/org/sopt/and/feature/signup/SignUpViewModel.kt b/app/src/main/java/org/sopt/and/feature/signup/SignUpViewModel.kt index 6a8c890..ee9accb 100644 --- a/app/src/main/java/org/sopt/and/feature/signup/SignUpViewModel.kt +++ b/app/src/main/java/org/sopt/and/feature/signup/SignUpViewModel.kt @@ -1,60 +1,51 @@ -package org.sopt.and.feature.signup +package org.sopt.and.feature.signup.viewmodel -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -import org.sopt.and.UiState -import org.sopt.and.domain.repository.SignUpRepository -import org.sopt.and.domain.entity.UserNumber +import org.sopt.and.core.component.BaseViewModel import org.sopt.and.domain.entity.UserInfo +import org.sopt.and.domain.repository.SignUpRepository +import org.sopt.and.feature.signup.model.SignUpContract.SignUpEvent +import org.sopt.and.feature.signup.model.SignUpContract.SignUpSideEffect +import org.sopt.and.feature.signup.model.SignUpContract.SignUpState import javax.inject.Inject @HiltViewModel class SignUpViewModel @Inject constructor( private val signUpRepository: SignUpRepository -) : ViewModel() { - - private val _email = MutableStateFlow("") - val email: StateFlow = _email - - private val _password = MutableStateFlow("") - val password: StateFlow = _password - - private val _hobby = MutableStateFlow("") - val hobby: StateFlow = _hobby +) : BaseViewModel() { - private val _signUpState = MutableStateFlow>(UiState.Loading) - val signUpState: StateFlow> = _signUpState + override fun createInitialState() = SignUpState() - fun updateEmail(newEmail: String) { - _signUpState.value = UiState.Loading - _email.value = newEmail + override suspend fun handleEvent(event: SignUpEvent) { + when (event) { + is SignUpEvent.UpdateEmail -> setState { copy(email = event.email) } + is SignUpEvent.UpdatePassword -> setState { copy(password = event.password) } + is SignUpEvent.UpdateHobby -> setState { copy(hobby = event.hobby) } + is SignUpEvent.SubmitSignUp -> submitSignUp() + } } - fun updatePassword(newPassword: String) { - _signUpState.value = UiState.Loading - _password.value = newPassword - } + private fun submitSignUp() { + val email = uiState.value.email + val password = uiState.value.password + val hobby = uiState.value.hobby - fun updateHobby(newHobby: String) { - _signUpState.value = UiState.Loading - _hobby.value = newHobby - } - - fun submitSignUp(userInfo: UserInfo) { + setState { copy(isLoading = true, errorMessage = null) } viewModelScope.launch { - signUpRepository.postSignUp(userInfo) - .onSuccess { response -> - _signUpState.emit(UiState.Success(response)) + signUpRepository.postSignUp(UserInfo(email, password, hobby)) + .onSuccess { + setSideEffect { SignUpSideEffect.NavigateToLogin } } .onFailure { exception -> - val errorMessage = exception.message ?: "알 수 없는 오류" - _signUpState.emit(UiState.Failure(errorMessage)) + setState { + copy( + errorMessage = exception.message ?: "오류 발생" + ) + } + setSideEffect { SignUpSideEffect.ShowSnackbar("회원가입 실패: ${exception.message}") } } } } - } From a14722859d6ef367c7fb9cbfef85aca9db0c4080 Mon Sep 17 00:00:00 2001 From: Hyoeun Date: Tue, 17 Dec 2024 20:53:18 +0900 Subject: [PATCH 4/9] =?UTF-8?q?#11=20[Mod]=20MyPageViewModel,ProfileScreen?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../and/feature/mypage/MyPageViewModel.kt | 38 +++++++++------ .../sopt/and/feature/mypage/ProfileScreen.kt | 47 +++++++++++++------ 2 files changed, 56 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/org/sopt/and/feature/mypage/MyPageViewModel.kt b/app/src/main/java/org/sopt/and/feature/mypage/MyPageViewModel.kt index 505c71d..8418b08 100644 --- a/app/src/main/java/org/sopt/and/feature/mypage/MyPageViewModel.kt +++ b/app/src/main/java/org/sopt/and/feature/mypage/MyPageViewModel.kt @@ -1,31 +1,39 @@ -package org.sopt.and.feature.mypage +package org.sopt.and.feature.mypage.viewmodel -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import org.sopt.and.core.component.BaseViewModel import org.sopt.and.domain.repository.MyPageRepository +import org.sopt.and.feature.mypage.model.MyPageContract.MyPageEvent +import org.sopt.and.feature.mypage.model.MyPageContract.MyPageSideEffect +import org.sopt.and.feature.mypage.model.MyPageContract.MyPageState import javax.inject.Inject @HiltViewModel class MyPageViewModel @Inject constructor( private val myPageRepository: MyPageRepository -) : ViewModel() { +) : BaseViewModel() { - private val _hobby = MutableStateFlow(null) - val hobby: StateFlow = _hobby.asStateFlow() + override fun createInitialState() = MyPageState() - fun loadHobby(token: String) { + override suspend fun handleEvent(event: MyPageEvent) { + when (event) { + is MyPageEvent.LoadHobby -> loadHobby(event.token) + } + } + + private fun loadHobby(token: String) { + setState { copy(isLoading = true) } viewModelScope.launch { - val result = myPageRepository.getMyHobby(token) - result.onSuccess { response -> - _hobby.value = response.hobby - }.onFailure { - _hobby.value = "데이터를 불러오는데 실패했습니다." - } + myPageRepository.getMyHobby(token) + .onSuccess { response -> + setState { copy(hobby = response.hobby, isLoading = false) } + } + .onFailure { + setState { copy(hobby = "", isLoading = false, error = "데이터를 불러오는데 실패했습니다.") } + setSideEffect { MyPageSideEffect.ShowErrorToast("데이터를 불러오는데 실패했습니다.") } + } } } } diff --git a/app/src/main/java/org/sopt/and/feature/mypage/ProfileScreen.kt b/app/src/main/java/org/sopt/and/feature/mypage/ProfileScreen.kt index 88393f1..6bb14e4 100644 --- a/app/src/main/java/org/sopt/and/feature/mypage/ProfileScreen.kt +++ b/app/src/main/java/org/sopt/and/feature/mypage/ProfileScreen.kt @@ -2,6 +2,7 @@ package org.sopt.and.feature.mypage import android.content.Context import android.util.Log +import android.widget.Toast import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column @@ -29,26 +30,39 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel +import kotlinx.coroutines.flow.collectLatest import org.sopt.and.R +import org.sopt.and.feature.mypage.model.MyPageContract +import org.sopt.and.feature.mypage.viewmodel.MyPageViewModel @Composable -fun ProfileScreen() { - val viewModel: MyPageViewModel = hiltViewModel() - val hobby by viewModel.hobby.collectAsState() +fun ProfileScreen(viewModel: MyPageViewModel = hiltViewModel()) { + val uiState by viewModel.uiState.collectAsState() val context = LocalContext.current LaunchedEffect(Unit) { val token = getAuthToken(context) if (token != null) { - viewModel.loadHobby(token) + viewModel.setEvent(MyPageContract.MyPageEvent.LoadHobby(token)) } else { - Log.e("MyPageScreen", "토근 못 찾음") + Log.e("ProfileScreen", "토큰 못 찾음") } } + + LaunchedEffect(Unit) { + viewModel.sideEffect.collectLatest { sideEffect -> + when (sideEffect) { + is MyPageContract.MyPageSideEffect.ShowErrorToast -> { + Toast.makeText(context, sideEffect.message, Toast.LENGTH_SHORT).show() + } + } + } + } + Column( modifier = Modifier .fillMaxSize() - .verticalScroll(rememberScrollState()), + .verticalScroll(rememberScrollState()) ) { Row( modifier = Modifier @@ -63,30 +77,35 @@ fun ProfileScreen() { alignment = Alignment.CenterStart ) Text( - hobby ?: "찾을 수 없습니다", + text = when { + uiState.isLoading -> "로딩 중..." + uiState.hobby.isNotEmpty() -> uiState.hobby + else -> "찾을 수 없습니다" + }, color = Color.White, + fontSize = 20.sp, modifier = Modifier .weight(1f) - .padding(end = 8.dp), - fontSize = 20.sp + .padding(end = 8.dp) ) Image( painter = painterResource(id = R.drawable.ic_alarm), - contentDescription = "alarm", + contentDescription = "Alarm Icon", modifier = Modifier.size(80.dp), alignment = Alignment.CenterEnd ) } + MyPagePurchase(stringResource(R.string.profile_first_purchase_description)) Spacer(modifier = Modifier.padding(top = 4.dp)) MyPagePurchase(stringResource(R.string.profile_no_ticket)) VideoList( - stringResource(R.string.profile_total_view_history), - stringResource(R.string.profile_no_view_history) + videoDescription = stringResource(R.string.profile_total_view_history), + emptyDescription = stringResource(R.string.profile_no_view_history) ) VideoList( - stringResource(R.string.profile_interest_program), - stringResource(R.string.profile_no_interest_program) + videoDescription = stringResource(R.string.profile_interest_program), + emptyDescription = stringResource(R.string.profile_no_interest_program) ) } } From d9bb720840800fd935ca82452403d71a7a760740 Mon Sep 17 00:00:00 2001 From: Hyoeun Date: Tue, 17 Dec 2024 20:53:33 +0900 Subject: [PATCH 5/9] =?UTF-8?q?#11=20[Mod]=20LoginScreen,LoginViewModel=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/sopt/and/feature/login/LoginScreen.kt | 111 +++++++++--------- .../sopt/and/feature/login/LoginViewModel.kt | 51 ++++---- 2 files changed, 74 insertions(+), 88 deletions(-) diff --git a/app/src/main/java/org/sopt/and/feature/login/LoginScreen.kt b/app/src/main/java/org/sopt/and/feature/login/LoginScreen.kt index f3206f0..c386b92 100644 --- a/app/src/main/java/org/sopt/and/feature/login/LoginScreen.kt +++ b/app/src/main/java/org/sopt/and/feature/login/LoginScreen.kt @@ -1,6 +1,5 @@ package org.sopt.and.feature.login -import android.content.Context import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -42,26 +41,39 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController +import kotlinx.coroutines.flow.collectLatest import org.sopt.and.R -import org.sopt.and.UiState import org.sopt.and.core.component.DescriptionText import org.sopt.and.core.component.DividerWithText import org.sopt.and.core.component.textfield.CustomEmailTextField import org.sopt.and.core.component.textfield.CustomPwTextField -import org.sopt.and.domain.entity.LoginInfo +import org.sopt.and.feature.login.model.LoginContract +import org.sopt.and.feature.login.viewmodel.LoginViewModel import org.sopt.and.ui.theme.ANDANDROIDTheme @Composable -fun LoginScreen(navController: NavController) { - val viewModel: LoginViewModel = hiltViewModel() - - val loginEmail by viewModel.email.collectAsState() - val loginPassword by viewModel.password.collectAsState() - var passwordVisible by remember { mutableStateOf(false) } +fun LoginScreen(navController: NavController, viewModel: LoginViewModel = hiltViewModel()) { + val uiState by viewModel.uiState.collectAsState() val context = LocalContext.current - val loginState by viewModel.loginState.collectAsState() + var passwordVisible by remember { mutableStateOf(false) } val snackbarHostState = remember { SnackbarHostState() } + LaunchedEffect(Unit) { + viewModel.sideEffect.collectLatest { sideEffect -> + when (sideEffect) { + is LoginContract.LoginSideEffect.NavigateToMyPage -> { + navController.navigate("mypage") { + popUpTo("login") { inclusive = true } + } + } + + is LoginContract.LoginSideEffect.ShowSnackbar -> { + snackbarHostState.showSnackbar(sideEffect.message) + } + } + } + } + Column( modifier = Modifier .fillMaxSize() @@ -69,23 +81,24 @@ fun LoginScreen(navController: NavController) { .padding(horizontal = 10.dp) ) { LoginTopBar() - Spacer(modifier = Modifier.padding(top = 30.dp)) + Spacer(modifier = Modifier.height(30.dp)) + CustomEmailTextField( - value = loginEmail, - onValueChange = { viewModel.updateEmail(it) }, + value = uiState.email, + onValueChange = { viewModel.setEvent(LoginContract.LoginEvent.UpdateEmail(it)) }, placeholder = stringResource(R.string.login_email_id) ) - Spacer(modifier = Modifier.padding(top = 10.dp)) + + Spacer(modifier = Modifier.height(10.dp)) + Box( modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd ) { CustomPwTextField( - value = loginPassword, - onValueChange = { viewModel.updatePassword(it) }, - placeholder = stringResource(R.string.login_setting_password), -// passwordVisible = passwordVisible, -// padding = PaddingValues(vertical = 10.dp) + value = uiState.password, + onValueChange = { viewModel.setEvent(LoginContract.LoginEvent.UpdatePassword(it)) }, + placeholder = stringResource(R.string.login_setting_password) ) Text( text = if (passwordVisible) "hide" else "show", @@ -95,61 +108,45 @@ fun LoginScreen(navController: NavController) { .clickable { passwordVisible = !passwordVisible } ) } - Spacer(modifier = Modifier.padding(top = 30.dp)) - - NavigateToMain { - val userInfo = LoginInfo(loginEmail, loginPassword) - viewModel.submitLogin(userInfo) - } - - when (val state = loginState) { - is UiState.Success -> { - LaunchedEffect(state.data) { - val authToken = state.data?.token - if (authToken != null) { - snackbarHostState.showSnackbar(context.getString(R.string.login_success)) - saveAuthToken(context, authToken) - navController.navigate("mypage") { - popUpTo("login") { inclusive = true } - } - } else { - snackbarHostState.showSnackbar("로그인 실패") - } - } - } - is UiState.Failure -> { - LaunchedEffect(state.errorMessage) { - snackbarHostState.showSnackbar(state.errorMessage) - } - } + Spacer(modifier = Modifier.height(30.dp)) - else -> Unit + NavigateToMain { + viewModel.setEvent( + LoginContract.LoginEvent.SubmitLogin( + uiState.email, + uiState.password + ) + ) } + Spacer(modifier = Modifier.height(20.dp)) - Spacer(modifier = Modifier.padding(top = 20.dp)) ThreeTextsWithDividers( modifier = Modifier.fillMaxWidth(), - stringResource(R.string.login_find_id), - stringResource(R.string.login_setting_password_again), - stringResource(R.string.sign_up), + text1 = stringResource(R.string.login_find_id), + text2 = stringResource(R.string.login_setting_password_again), + text3 = stringResource(R.string.sign_up), onSignUpClick = { navController.navigate("signup") } ) + DividerWithText(stringResource(R.string.login_join_with_social_account)) + Image( painter = painterResource(id = R.drawable.ic_social_login), - contentDescription = "Social Login", + contentDescription = "Social Login" ) - Spacer(modifier = Modifier.padding(top = 20.dp)) + + Spacer(modifier = Modifier.height(20.dp)) + DescriptionText(stringResource(R.string.login_join_social_account_description)) SnackbarHost(hostState = snackbarHostState) } } -fun saveAuthToken(context: Context, token: String) { - val sharedPreferences = context.getSharedPreferences("app_preferences", Context.MODE_PRIVATE) - sharedPreferences.edit().putString("auth_token", token).apply() -} +//fun saveAuthToken(context: Context, token: String) { +// val sharedPreferences = context.getSharedPreferences("app_preferences", Context.MODE_PRIVATE) +// sharedPreferences.edit().putString("auth_token", token).apply() +//} @Composable fun LoginTopBar() { diff --git a/app/src/main/java/org/sopt/and/feature/login/LoginViewModel.kt b/app/src/main/java/org/sopt/and/feature/login/LoginViewModel.kt index 07c48aa..ab9db96 100644 --- a/app/src/main/java/org/sopt/and/feature/login/LoginViewModel.kt +++ b/app/src/main/java/org/sopt/and/feature/login/LoginViewModel.kt @@ -1,51 +1,40 @@ -package org.sopt.and.feature.login +package org.sopt.and.feature.login.viewmodel -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -import org.sopt.and.UiState -import org.sopt.and.domain.repository.LoginRepository +import org.sopt.and.core.component.BaseViewModel import org.sopt.and.domain.entity.LoginInfo -import org.sopt.and.domain.entity.Token +import org.sopt.and.domain.repository.LoginRepository +import org.sopt.and.feature.login.model.LoginContract.LoginEvent +import org.sopt.and.feature.login.model.LoginContract.LoginSideEffect +import org.sopt.and.feature.login.model.LoginContract.LoginState import javax.inject.Inject @HiltViewModel class LoginViewModel @Inject constructor( private val loginRepository: LoginRepository -) : ViewModel() { - - private val _email = MutableStateFlow("") - val email: StateFlow = _email - - private val _password = MutableStateFlow("") - val password: StateFlow = _password +) : BaseViewModel() { - private val _loginState = MutableStateFlow>(UiState.Loading) - val loginState: StateFlow> = _loginState + override fun createInitialState() = LoginState() - private val _authToken = MutableStateFlow(null) - val authToken: StateFlow = _authToken - - fun updateEmail(newEmail: String) { - _email.value = newEmail - } - - fun updatePassword(newPassword: String) { - _password.value = newPassword + override suspend fun handleEvent(event: LoginEvent) { + when (event) { + is LoginEvent.UpdateEmail -> setState { copy(email = event.email) } + is LoginEvent.UpdatePassword -> setState { copy(password = event.password) } + is LoginEvent.SubmitLogin -> submitLogin(event.email, event.password) + } } - fun submitLogin(userInfo: LoginInfo) { + private fun submitLogin(email: String, password: String) { + setState { copy(isLoading = true, errorMessage = null) } viewModelScope.launch { - loginRepository.postLogin(userInfo) + loginRepository.postLogin(LoginInfo(email, password)) .onSuccess { response -> - _loginState.emit(UiState.Success(response)) - _authToken.emit(response.token) + setSideEffect { LoginSideEffect.NavigateToMyPage } } - .onFailure { exception -> - _loginState.emit(UiState.Failure("로그인 실패")) + .onFailure { + setSideEffect { LoginSideEffect.ShowSnackbar("로그인 실패") } } } } From 9b76ec1f8792c294f7f290fdce43d8f294e7f3d2 Mon Sep 17 00:00:00 2001 From: Hyoeun Date: Tue, 17 Dec 2024 20:53:48 +0900 Subject: [PATCH 6/9] =?UTF-8?q?#11=20[Feat]=20LoginContract=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../and/feature/login/model/LoginContract.kt | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 app/src/main/java/org/sopt/and/feature/login/model/LoginContract.kt diff --git a/app/src/main/java/org/sopt/and/feature/login/model/LoginContract.kt b/app/src/main/java/org/sopt/and/feature/login/model/LoginContract.kt new file mode 100644 index 0000000..acbb9a3 --- /dev/null +++ b/app/src/main/java/org/sopt/and/feature/login/model/LoginContract.kt @@ -0,0 +1,25 @@ +package org.sopt.and.feature.login.model + +import org.sopt.and.core.component.UiEvent +import org.sopt.and.core.component.UiSideEffect +import org.sopt.and.core.component.UiState + +class LoginContract { + data class LoginState( + val email: String = "", + val password: String = "", + val isLoading: Boolean = false, + val errorMessage: String? = null + ) : UiState + + sealed class LoginEvent : UiEvent { + data class UpdateEmail(val email: String) : LoginEvent() + data class UpdatePassword(val password: String) : LoginEvent() + data class SubmitLogin(val email: String, val password: String) : LoginEvent() + } + + sealed class LoginSideEffect : UiSideEffect { + data object NavigateToMyPage : LoginSideEffect() + data class ShowSnackbar(val message: String) : LoginSideEffect() + } +} From 3470dbd1e0b41a29930c2d3ffcf124ad401005f9 Mon Sep 17 00:00:00 2001 From: Hyoeun Date: Tue, 17 Dec 2024 20:53:57 +0900 Subject: [PATCH 7/9] =?UTF-8?q?#11=20[Feat]=20MyPageContract=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feature/mypage/model/MyPageContract.kt | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 app/src/main/java/org/sopt/and/feature/mypage/model/MyPageContract.kt diff --git a/app/src/main/java/org/sopt/and/feature/mypage/model/MyPageContract.kt b/app/src/main/java/org/sopt/and/feature/mypage/model/MyPageContract.kt new file mode 100644 index 0000000..263eded --- /dev/null +++ b/app/src/main/java/org/sopt/and/feature/mypage/model/MyPageContract.kt @@ -0,0 +1,21 @@ +package org.sopt.and.feature.mypage.model + +import org.sopt.and.core.component.UiEvent +import org.sopt.and.core.component.UiSideEffect +import org.sopt.and.core.component.UiState + +class MyPageContract { + data class MyPageState( + val hobby: String = "", + val isLoading: Boolean = false, + val error: String? = null + ) : UiState + + sealed class MyPageEvent : UiEvent { + data class LoadHobby(val token: String) : MyPageEvent() + } + + sealed class MyPageSideEffect : UiSideEffect { + data class ShowErrorToast(val message: String) : MyPageSideEffect() + } +} From dbb4668b4d2980549ccf30c84568c15cecfbdac1 Mon Sep 17 00:00:00 2001 From: Hyoeun Date: Tue, 17 Dec 2024 20:54:07 +0900 Subject: [PATCH 8/9] =?UTF-8?q?#11=20[Feat]=20BaseViewModel=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sopt/and/core/component/BaseViewModel.kt | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 app/src/main/java/org/sopt/and/core/component/BaseViewModel.kt diff --git a/app/src/main/java/org/sopt/and/core/component/BaseViewModel.kt b/app/src/main/java/org/sopt/and/core/component/BaseViewModel.kt new file mode 100644 index 0000000..0f08795 --- /dev/null +++ b/app/src/main/java/org/sopt/and/core/component/BaseViewModel.kt @@ -0,0 +1,38 @@ +package org.sopt.and.core.component + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +abstract class BaseViewModel : + ViewModel() { + + private val initialState: State by lazy { createInitialState() } + abstract fun createInitialState(): State + + private val _uiState = MutableStateFlow(initialState) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _sideEffect = MutableSharedFlow() + val sideEffect: SharedFlow = _sideEffect.asSharedFlow() + + fun setState(reduce: State.() -> State) { + _uiState.value = _uiState.value.reduce() + } + + fun setSideEffect(builder: () -> SideEffect) { + viewModelScope.launch { _sideEffect.emit(builder()) } + } + + fun setEvent(event: Event) { + viewModelScope.launch { handleEvent(event) } + } + + protected abstract suspend fun handleEvent(event: Event) +} From aab2278e36165fa5ac8c83fae5727dbff43418c3 Mon Sep 17 00:00:00 2001 From: Hyoeun Date: Tue, 17 Dec 2024 21:26:18 +0900 Subject: [PATCH 9/9] =?UTF-8?q?#11=20[Fix]=20hobby=20=EB=B6=88=EB=9F=AC?= =?UTF-8?q?=EC=98=A4=EC=A7=80=20=EB=AA=BB=ED=95=9C=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/sopt/and/feature/login/LoginScreen.kt | 12 +++++++----- .../org/sopt/and/feature/login/LoginViewModel.kt | 2 +- .../sopt/and/feature/login/model/LoginContract.kt | 2 +- .../org/sopt/and/feature/mypage/MyPageViewModel.kt | 4 ++-- .../org/sopt/and/feature/mypage/ProfileScreen.kt | 2 +- 5 files changed, 12 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/org/sopt/and/feature/login/LoginScreen.kt b/app/src/main/java/org/sopt/and/feature/login/LoginScreen.kt index c386b92..4a4d8b2 100644 --- a/app/src/main/java/org/sopt/and/feature/login/LoginScreen.kt +++ b/app/src/main/java/org/sopt/and/feature/login/LoginScreen.kt @@ -1,5 +1,6 @@ package org.sopt.and.feature.login +import android.content.Context import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -61,7 +62,8 @@ fun LoginScreen(navController: NavController, viewModel: LoginViewModel = hiltVi LaunchedEffect(Unit) { viewModel.sideEffect.collectLatest { sideEffect -> when (sideEffect) { - is LoginContract.LoginSideEffect.NavigateToMyPage -> { + is LoginContract.LoginSideEffect.NavigateToMyPageWithToken -> { + saveAuthToken(context, sideEffect.token) navController.navigate("mypage") { popUpTo("login") { inclusive = true } } @@ -143,10 +145,10 @@ fun LoginScreen(navController: NavController, viewModel: LoginViewModel = hiltVi } } -//fun saveAuthToken(context: Context, token: String) { -// val sharedPreferences = context.getSharedPreferences("app_preferences", Context.MODE_PRIVATE) -// sharedPreferences.edit().putString("auth_token", token).apply() -//} +fun saveAuthToken(context: Context, token: String) { + val sharedPreferences = context.getSharedPreferences("app_preferences", Context.MODE_PRIVATE) + sharedPreferences.edit().putString("auth_token", token).apply() +} @Composable fun LoginTopBar() { diff --git a/app/src/main/java/org/sopt/and/feature/login/LoginViewModel.kt b/app/src/main/java/org/sopt/and/feature/login/LoginViewModel.kt index ab9db96..351c9d5 100644 --- a/app/src/main/java/org/sopt/and/feature/login/LoginViewModel.kt +++ b/app/src/main/java/org/sopt/and/feature/login/LoginViewModel.kt @@ -31,7 +31,7 @@ class LoginViewModel @Inject constructor( viewModelScope.launch { loginRepository.postLogin(LoginInfo(email, password)) .onSuccess { response -> - setSideEffect { LoginSideEffect.NavigateToMyPage } + setSideEffect { LoginSideEffect.NavigateToMyPageWithToken(response.token) } } .onFailure { setSideEffect { LoginSideEffect.ShowSnackbar("로그인 실패") } diff --git a/app/src/main/java/org/sopt/and/feature/login/model/LoginContract.kt b/app/src/main/java/org/sopt/and/feature/login/model/LoginContract.kt index acbb9a3..98e97a1 100644 --- a/app/src/main/java/org/sopt/and/feature/login/model/LoginContract.kt +++ b/app/src/main/java/org/sopt/and/feature/login/model/LoginContract.kt @@ -19,7 +19,7 @@ class LoginContract { } sealed class LoginSideEffect : UiSideEffect { - data object NavigateToMyPage : LoginSideEffect() + data class NavigateToMyPageWithToken(val token: String) : LoginSideEffect() data class ShowSnackbar(val message: String) : LoginSideEffect() } } diff --git a/app/src/main/java/org/sopt/and/feature/mypage/MyPageViewModel.kt b/app/src/main/java/org/sopt/and/feature/mypage/MyPageViewModel.kt index 8418b08..7f7f47d 100644 --- a/app/src/main/java/org/sopt/and/feature/mypage/MyPageViewModel.kt +++ b/app/src/main/java/org/sopt/and/feature/mypage/MyPageViewModel.kt @@ -31,8 +31,8 @@ class MyPageViewModel @Inject constructor( setState { copy(hobby = response.hobby, isLoading = false) } } .onFailure { - setState { copy(hobby = "", isLoading = false, error = "데이터를 불러오는데 실패했습니다.") } - setSideEffect { MyPageSideEffect.ShowErrorToast("데이터를 불러오는데 실패했습니다.") } + setState { copy(hobby = "", isLoading = false) } + setSideEffect { MyPageSideEffect.ShowErrorToast("불러오지 못함") } } } } diff --git a/app/src/main/java/org/sopt/and/feature/mypage/ProfileScreen.kt b/app/src/main/java/org/sopt/and/feature/mypage/ProfileScreen.kt index 6bb14e4..24a1437 100644 --- a/app/src/main/java/org/sopt/and/feature/mypage/ProfileScreen.kt +++ b/app/src/main/java/org/sopt/and/feature/mypage/ProfileScreen.kt @@ -45,7 +45,7 @@ fun ProfileScreen(viewModel: MyPageViewModel = hiltViewModel()) { if (token != null) { viewModel.setEvent(MyPageContract.MyPageEvent.LoadHobby(token)) } else { - Log.e("ProfileScreen", "토큰 못 찾음") + Log.d("ProfileScreen", "토큰 못 찾음") } }