diff --git a/app/src/main/java/org/sopt/and/core/preference/PreferenceImpl.kt b/app/src/main/java/org/sopt/and/core/preference/PreferenceImpl.kt new file mode 100644 index 0000000..22e09d5 --- /dev/null +++ b/app/src/main/java/org/sopt/and/core/preference/PreferenceImpl.kt @@ -0,0 +1,26 @@ +package org.sopt.and.core.preference + +import android.content.Context +import androidx.compose.runtime.staticCompositionLocalOf +import dagger.hilt.android.qualifiers.ApplicationContext + +class PreferenceImpl( + @ApplicationContext private val context: Context +) { + private val preference = context.getSharedPreferences( + PREF_NAME, Context.MODE_PRIVATE + ) + + var token: String + get() = preference.getString(TOKEN, "").toString() + set(value) = preference.edit().putString(TOKEN, value).apply() + + companion object{ + private const val PREF_NAME = "wavve_prefs" + private const val TOKEN = "token" + + val LocalPreference = staticCompositionLocalOf { + error("Preference Failed") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/core/preference/PreferenceModule.kt b/app/src/main/java/org/sopt/and/core/preference/PreferenceModule.kt new file mode 100644 index 0000000..ebe9c03 --- /dev/null +++ b/app/src/main/java/org/sopt/and/core/preference/PreferenceModule.kt @@ -0,0 +1,19 @@ +package org.sopt.and.core.preference + +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object PreferenceModule { + @Provides + @Singleton + fun providePreferenceImpl( + @ApplicationContext context: Context + ) = PreferenceImpl(context) +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/core/state/UiState.kt b/app/src/main/java/org/sopt/and/core/state/UiState.kt deleted file mode 100644 index 3a3a53e..0000000 --- a/app/src/main/java/org/sopt/and/core/state/UiState.kt +++ /dev/null @@ -1,15 +0,0 @@ -package org.sopt.and.core.state - -sealed interface UiState { - data object Empty : UiState - - data object Loading : UiState - - data class Success( - val data: T, - ) : UiState - - data class Failure( - val message: String, - ) : UiState -} diff --git a/app/src/main/java/org/sopt/and/core/util/BaseViewModel.kt b/app/src/main/java/org/sopt/and/core/util/BaseViewModel.kt new file mode 100644 index 0000000..12ebb78 --- /dev/null +++ b/app/src/main/java/org/sopt/and/core/util/BaseViewModel.kt @@ -0,0 +1,50 @@ +package org.sopt.and.core.util + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.Flow +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 + get() = _uiState.asStateFlow() + val currentState: State + get() = uiState.value + + private val _event: MutableSharedFlow = MutableSharedFlow() + val event: SharedFlow + get() = _event.asSharedFlow() + + private val _sideEffect: MutableSharedFlow = MutableSharedFlow() + val sideEffect: Flow + get() = _sideEffect.asSharedFlow() + + fun setState(reduce: State.() -> State) { + _uiState.value = currentState.reduce() + } + + open fun setEvent(event: Event) { + dispatchEvent(event) + } + + private fun dispatchEvent(event: Event) = viewModelScope.launch { + handleEvent(event) + } + + protected abstract suspend fun handleEvent(event: Event) + + fun setSideEffect(sideEffect: SideEffect) { + viewModelScope.launch { _sideEffect.emit(sideEffect) } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/core/util/UiEvent.kt b/app/src/main/java/org/sopt/and/core/util/UiEvent.kt new file mode 100644 index 0000000..3e6b186 --- /dev/null +++ b/app/src/main/java/org/sopt/and/core/util/UiEvent.kt @@ -0,0 +1,3 @@ +package org.sopt.and.core.util + +interface UiEvent \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/core/util/UiSideEffect.kt b/app/src/main/java/org/sopt/and/core/util/UiSideEffect.kt new file mode 100644 index 0000000..4f992f6 --- /dev/null +++ b/app/src/main/java/org/sopt/and/core/util/UiSideEffect.kt @@ -0,0 +1,3 @@ +package org.sopt.and.core.util + +interface UiSideEffect \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/core/util/UiState.kt b/app/src/main/java/org/sopt/and/core/util/UiState.kt new file mode 100644 index 0000000..3bd026a --- /dev/null +++ b/app/src/main/java/org/sopt/and/core/util/UiState.kt @@ -0,0 +1,3 @@ +package org.sopt.and.core.util + +interface UiState \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/feature/main/MainActivity.kt b/app/src/main/java/org/sopt/and/feature/main/MainActivity.kt index 55f0457..3b7caec 100644 --- a/app/src/main/java/org/sopt/and/feature/main/MainActivity.kt +++ b/app/src/main/java/org/sopt/and/feature/main/MainActivity.kt @@ -4,12 +4,10 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.runtime.CompositionLocalProvider import dagger.hilt.android.AndroidEntryPoint import org.sopt.and.core.designsystem.theme.ANDANDROIDTheme +import org.sopt.and.core.preference.PreferenceImpl @AndroidEntryPoint class MainActivity : ComponentActivity() { @@ -19,24 +17,12 @@ class MainActivity : ComponentActivity() { setContent { val navigator: MainNavigator = rememberMainNavigator() ANDANDROIDTheme { - MainScreen(navigator) + CompositionLocalProvider( + PreferenceImpl.LocalPreference provides PreferenceImpl(this) + ) { + MainScreen(navigator) + } } } } } - -@Composable -fun Greeting(name: String, modifier: Modifier = Modifier) { - Text( - text = "Hello $name!", - modifier = modifier - ) -} - -@Preview(showBackground = true) -@Composable -fun GreetingPreview() { - ANDANDROIDTheme { - Greeting("Android") - } -} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/feature/main/MainScreen.kt b/app/src/main/java/org/sopt/and/feature/main/MainScreen.kt index 8161d3d..a411656 100644 --- a/app/src/main/java/org/sopt/and/feature/main/MainScreen.kt +++ b/app/src/main/java/org/sopt/and/feature/main/MainScreen.kt @@ -77,7 +77,6 @@ fun MainScreen( ) myNavGraph( paddingValues = paddingValues, - navHostController = navigator.navController, ) signInNavGraph( diff --git a/app/src/main/java/org/sopt/and/feature/my/MyContract.kt b/app/src/main/java/org/sopt/and/feature/my/MyContract.kt new file mode 100644 index 0000000..e31210e --- /dev/null +++ b/app/src/main/java/org/sopt/and/feature/my/MyContract.kt @@ -0,0 +1,9 @@ +package org.sopt.and.feature.my + +import org.sopt.and.core.util.UiState + +class MyContract { + data class MyUiState( + val hobby: String = "" + ) : UiState +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/feature/my/MyRoute.kt b/app/src/main/java/org/sopt/and/feature/my/MyRoute.kt index 8ff9da6..0a0433c 100644 --- a/app/src/main/java/org/sopt/and/feature/my/MyRoute.kt +++ b/app/src/main/java/org/sopt/and/feature/my/MyRoute.kt @@ -1,6 +1,5 @@ package org.sopt.and.feature.my -import android.content.Context import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column @@ -20,47 +19,35 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavHostController import org.sopt.and.R import org.sopt.and.core.designsystem.theme.ANDANDROIDTheme -import org.sopt.and.core.state.UiState +import org.sopt.and.core.preference.PreferenceImpl.Companion.LocalPreference import org.sopt.and.feature.my.component.MyPageContent import org.sopt.and.feature.my.component.MyPageTextButton @Composable fun MyRoute( paddingValues: PaddingValues, - navController: NavHostController, viewModel: MyViewModel = hiltViewModel() ) { - val context = LocalContext.current - val lifecycleOwner = LocalLifecycleOwner.current - val sharedPreferences = context.getSharedPreferences("user_prefs", Context.MODE_PRIVATE) - - val homeState by viewModel.myState.collectAsStateWithLifecycle() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val preference = LocalPreference.current LaunchedEffect(true) { - viewModel.getHobby(sharedPreferences) + viewModel.getHobby(preference.token) } - when (homeState.hobby) { - is UiState.Success -> { - MyScreen( - hobby = (homeState.hobby as? UiState.Success)?.data ?: "" , - ) - } - - else -> {} - } + MyScreen( + hobby = uiState.hobby, + modifier = Modifier.padding(bottom = paddingValues.calculateBottomPadding()) + ) } @Composable diff --git a/app/src/main/java/org/sopt/and/feature/my/MyState.kt b/app/src/main/java/org/sopt/and/feature/my/MyState.kt deleted file mode 100644 index f0c953e..0000000 --- a/app/src/main/java/org/sopt/and/feature/my/MyState.kt +++ /dev/null @@ -1,7 +0,0 @@ -package org.sopt.and.feature.my - -import org.sopt.and.core.state.UiState - -data class MyState( - val hobby: UiState = UiState.Loading, -) diff --git a/app/src/main/java/org/sopt/and/feature/my/MyViewModel.kt b/app/src/main/java/org/sopt/and/feature/my/MyViewModel.kt index 5414bf7..8469cd7 100644 --- a/app/src/main/java/org/sopt/and/feature/my/MyViewModel.kt +++ b/app/src/main/java/org/sopt/and/feature/my/MyViewModel.kt @@ -1,31 +1,26 @@ package org.sopt.and.feature.my -import android.content.SharedPreferences -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch -import org.sopt.and.core.state.UiState +import org.sopt.and.core.util.BaseViewModel import org.sopt.and.domain.usecase.GetMyHobbyUseCase import javax.inject.Inject @HiltViewModel class MyViewModel @Inject constructor( private val getMyHobbyUseCase: GetMyHobbyUseCase, -) : ViewModel() { - var myState: MutableStateFlow = MutableStateFlow(MyState()) - private set +) : BaseViewModel() { + override fun createInitialState(): MyContract.MyUiState = + MyContract.MyUiState() - fun getHobby(sharedPreferences: SharedPreferences) { - val token = sharedPreferences.getString("token", null) ?: "" + override suspend fun handleEvent(event: Nothing) {} + fun getHobby(token: String) { viewModelScope.launch { getMyHobbyUseCase(token) .onSuccess { response -> - myState.value = myState.value.copy( - hobby = UiState.Success(response.hobby) - ) + setState { copy(hobby = response.hobby) } } } } diff --git a/app/src/main/java/org/sopt/and/feature/my/navigation/MyNavigation.kt b/app/src/main/java/org/sopt/and/feature/my/navigation/MyNavigation.kt index 1ae8d85..084296e 100644 --- a/app/src/main/java/org/sopt/and/feature/my/navigation/MyNavigation.kt +++ b/app/src/main/java/org/sopt/and/feature/my/navigation/MyNavigation.kt @@ -5,7 +5,6 @@ import androidx.compose.animation.ExitTransition import androidx.compose.foundation.layout.PaddingValues import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder -import androidx.navigation.NavHostController import androidx.navigation.NavOptions import androidx.navigation.compose.composable import kotlinx.serialization.Serializable @@ -21,7 +20,6 @@ fun NavController.navigateToMy(navOptions: NavOptions? = null) { fun NavGraphBuilder.myNavGraph( paddingValues: PaddingValues, - navHostController: NavHostController, ) { composable( exitTransition = { @@ -39,7 +37,6 @@ fun NavGraphBuilder.myNavGraph( ) { MyRoute( paddingValues = paddingValues, - navController = navHostController ) } } diff --git a/app/src/main/java/org/sopt/and/feature/signin/SignInContract.kt b/app/src/main/java/org/sopt/and/feature/signin/SignInContract.kt new file mode 100644 index 0000000..3c15759 --- /dev/null +++ b/app/src/main/java/org/sopt/and/feature/signin/SignInContract.kt @@ -0,0 +1,27 @@ +package org.sopt.and.feature.signin + +import androidx.annotation.StringRes +import org.sopt.and.core.util.UiEvent +import org.sopt.and.core.util.UiSideEffect +import org.sopt.and.core.util.UiState + +class SignInContract { + data class SignInUiState( + val username: String = "", + val password: String = "", + ): UiState + + sealed interface SignInSideEffect : UiSideEffect { + data class ShowToast(@StringRes val message: Int) : SignInSideEffect + data object NavigateToSignUp : SignInSideEffect + data class NavigateToHome(val token: String) : SignInSideEffect + } + + sealed class SignInEvent : UiEvent { + data class OnUsernameChanged(val username: String) : SignInEvent() + data class OnPasswordChanged(val password: String) : SignInEvent() + data object OnSignInButtonClicked : SignInEvent() + data object OnSignUpButtonClicked : SignInEvent() + } + +} diff --git a/app/src/main/java/org/sopt/and/feature/signin/SignInRoute.kt b/app/src/main/java/org/sopt/and/feature/signin/SignInRoute.kt index 5357e4b..5db755f 100644 --- a/app/src/main/java/org/sopt/and/feature/signin/SignInRoute.kt +++ b/app/src/main/java/org/sopt/and/feature/signin/SignInRoute.kt @@ -24,7 +24,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel @@ -36,8 +35,8 @@ import org.sopt.and.core.designsystem.component.SocialLoginButton import org.sopt.and.core.designsystem.component.textfield.EmailTextField import org.sopt.and.core.designsystem.component.textfield.PasswordTextField import org.sopt.and.core.designsystem.component.topappbar.BackButtonTopAppBar -import org.sopt.and.core.designsystem.theme.ANDANDROIDTheme import org.sopt.and.core.extension.toast +import org.sopt.and.core.preference.PreferenceImpl.Companion.LocalPreference @Composable fun SignInRoute( @@ -46,27 +45,26 @@ fun SignInRoute( viewModel: SignInViewModel = hiltViewModel(), modifier: Modifier = Modifier, ) { - val signInState by viewModel.signInState.collectAsStateWithLifecycle() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current + val preferences = LocalPreference.current - viewModel.initializePreferences(context) - - LaunchedEffect(viewModel.signInSideEffect, lifecycleOwner) { - viewModel.signInSideEffect.flowWithLifecycle(lifecycle = lifecycleOwner.lifecycle) + LaunchedEffect(viewModel.sideEffect, lifecycleOwner) { + viewModel.sideEffect.flowWithLifecycle(lifecycle = lifecycleOwner.lifecycle) .collect { sideEffect -> when (sideEffect) { - is SignInSideEffect.ShowToast -> { + is SignInContract.SignInSideEffect.ShowToast -> { context.toast(sideEffect.message) } - is SignInSideEffect.ShowSnackBar -> {} - is SignInSideEffect.NavigateToSignUp -> { + is SignInContract.SignInSideEffect.NavigateToSignUp -> { navigateToSignUp() } - is SignInSideEffect.NavigateToHome -> { + is SignInContract.SignInSideEffect.NavigateToHome -> { + preferences.token = sideEffect.token navigateToHome() } } @@ -74,11 +72,19 @@ fun SignInRoute( } SignInScreen( - onSignUpButtonClick = viewModel::onSignUpButtonClick, - onSignInButtonClick = viewModel::signIn, - onIdChange = viewModel::updateEmail, - onPasswordChange = viewModel::updatePassword, - signInState = signInState, + onSignUpButtonClick = { + viewModel.setEvent(SignInContract.SignInEvent.OnSignUpButtonClicked) + }, + onSignInButtonClick = { + viewModel.setEvent(SignInContract.SignInEvent.OnSignInButtonClicked) + }, + onUsernameChange = { newValue -> + viewModel.setEvent(SignInContract.SignInEvent.OnUsernameChanged(newValue)) + }, + onPasswordChange = { newValue -> + viewModel.setEvent(SignInContract.SignInEvent.OnPasswordChanged(newValue)) + }, + signInState = uiState, modifier = modifier, ) } @@ -87,9 +93,9 @@ fun SignInRoute( fun SignInScreen( onSignInButtonClick: () -> Unit, onSignUpButtonClick: () -> Unit, - onIdChange: (String) -> Unit, + onUsernameChange: (String) -> Unit, onPasswordChange: (String) -> Unit, - signInState: SignInState, + signInState: SignInContract.SignInUiState, modifier: Modifier = Modifier, ) { Column( @@ -102,9 +108,9 @@ fun SignInScreen( onBackClick = {}, ) EmailTextField( - email = signInState.email, + email = signInState.username, hint = stringResource(R.string.sign_in_email), - onValueChange = onIdChange, + onValueChange = onUsernameChange, modifier = Modifier .padding(top = 50.dp) .padding(horizontal = 20.dp), @@ -223,18 +229,3 @@ fun SignInScreen( ) } } - -@Preview(showBackground = true) -@Composable -fun SignInPreview() { - ANDANDROIDTheme { - SignInScreen( - onSignUpButtonClick = { }, - onSignInButtonClick = { }, - onIdChange = { }, - onPasswordChange = { }, - signInState = SignInState(), - modifier = Modifier, - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/feature/signin/SignInSideEffect.kt b/app/src/main/java/org/sopt/and/feature/signin/SignInSideEffect.kt deleted file mode 100644 index d0f77b9..0000000 --- a/app/src/main/java/org/sopt/and/feature/signin/SignInSideEffect.kt +++ /dev/null @@ -1,10 +0,0 @@ -package org.sopt.and.feature.signin - -import androidx.annotation.StringRes - -sealed class SignInSideEffect { - data class ShowToast(@StringRes val message: Int) : SignInSideEffect() - data class ShowSnackBar(@StringRes val message: Int) : SignInSideEffect() - data object NavigateToSignUp : SignInSideEffect() - data object NavigateToHome : SignInSideEffect() -} diff --git a/app/src/main/java/org/sopt/and/feature/signin/SignInState.kt b/app/src/main/java/org/sopt/and/feature/signin/SignInState.kt deleted file mode 100644 index 22d7e48..0000000 --- a/app/src/main/java/org/sopt/and/feature/signin/SignInState.kt +++ /dev/null @@ -1,7 +0,0 @@ -package org.sopt.and.feature.signin - -data class SignInState( - val email: String = "", - val password: String = "", - val token: String = "", -) \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/feature/signin/SignInViewModel.kt b/app/src/main/java/org/sopt/and/feature/signin/SignInViewModel.kt index 9971011..c36093a 100644 --- a/app/src/main/java/org/sopt/and/feature/signin/SignInViewModel.kt +++ b/app/src/main/java/org/sopt/and/feature/signin/SignInViewModel.kt @@ -1,81 +1,52 @@ package org.sopt.and.feature.signin import android.content.Context -import android.content.SharedPreferences -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.sopt.and.R +import org.sopt.and.core.util.BaseViewModel import org.sopt.and.domain.usecase.PostSignInUseCase import javax.inject.Inject @HiltViewModel class SignInViewModel @Inject constructor( private val postSignInUseCase: PostSignInUseCase, -) : ViewModel() { - private val _signInState: MutableStateFlow = MutableStateFlow(SignInState()) - val signInState get() = _signInState.asStateFlow() - - private val _signInSideEffect = MutableSharedFlow() - val signInSideEffect get() = _signInSideEffect.asSharedFlow() - - private var sharedPreferences: SharedPreferences? = null - - fun onSignUpButtonClick() { - viewModelScope.launch { - _signInSideEffect.emit(SignInSideEffect.NavigateToSignUp) - } - } - - fun signIn() { - viewModelScope.launch { - postSignInUseCase( - username = _signInState.value.email, - password = _signInState.value.password, - ).onSuccess { response -> - saveToken(response.token) - _signInSideEffect.emit(SignInSideEffect.NavigateToHome) - }.onFailure { - _signInSideEffect.emit(SignInSideEffect.ShowToast(R.string.sign_in_failed)) +) : BaseViewModel() { + override fun createInitialState(): SignInContract.SignInUiState = + SignInContract.SignInUiState() + + override suspend fun handleEvent(event: SignInContract.SignInEvent) { + when (event) { + is SignInContract.SignInEvent.OnUsernameChanged -> { + setState { copy(username = event.username) } } - } - } - fun initializePreferences(context: Context) { - sharedPreferences = context.getSharedPreferences("user_prefs", Context.MODE_PRIVATE) - } - - private fun saveToken(token: String) { - sharedPreferences?.edit()?.putString("token", token)?.apply() - } + is SignInContract.SignInEvent.OnPasswordChanged -> { + setState { copy(password = event.password) } + } - fun updateEmail(email: String) { - _signInState.update { - it.copy( - email = email - ) - } - } + is SignInContract.SignInEvent.OnSignInButtonClicked -> { + signIn() + } - fun updatePassword(password: String) { - _signInState.update { - it.copy( - password = password - ) + is SignInContract.SignInEvent.OnSignUpButtonClicked -> { + setSideEffect(SignInContract.SignInSideEffect.NavigateToSignUp) + } } } - fun updateToken(token: String) { - _signInState.update { - it.copy( - token = token - ) + private fun signIn() = viewModelScope.launch { + with(currentState) { + postSignInUseCase(username, password) + .onSuccess { response -> + setSideEffect(SignInContract.SignInSideEffect.NavigateToHome(response.token)) + }.onFailure { + setSideEffect(SignInContract.SignInSideEffect.ShowToast(R.string.sign_in_failed)) + } } } } \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/feature/signup/SignUpContract.kt b/app/src/main/java/org/sopt/and/feature/signup/SignUpContract.kt new file mode 100644 index 0000000..4d8e75a --- /dev/null +++ b/app/src/main/java/org/sopt/and/feature/signup/SignUpContract.kt @@ -0,0 +1,29 @@ +package org.sopt.and.feature.signup + +import androidx.annotation.StringRes +import org.sopt.and.core.util.UiEvent +import org.sopt.and.core.util.UiSideEffect +import org.sopt.and.core.util.UiState + +class SignUpContract { + data class SignUpUiState( + val username: String = "", + val password: String = "", + val hobby: String = "", + val isSignUpEnabled: Boolean = false, + ) : UiState + + sealed interface SignUpSideEffect : UiSideEffect { + data class ShowToast(@StringRes val message: Int) : SignUpSideEffect + data object NavigateToSignIn : SignUpSideEffect + data object NavigateUp : SignUpSideEffect + } + + sealed class SignUpEvent : UiEvent { + data class OnUsernameChanged(val username: String) : SignUpEvent() + data class OnPasswordChanged(val password: String) : SignUpEvent() + data class OnHobbyChanged(val hobby: String) : SignUpEvent() + data object OnSignUpButtonClicked : SignUpEvent() + data object OnBackButtonClicked : SignUpEvent() + } +} diff --git a/app/src/main/java/org/sopt/and/feature/signup/SignUpRoute.kt b/app/src/main/java/org/sopt/and/feature/signup/SignUpRoute.kt index db9b460..e742379 100644 --- a/app/src/main/java/org/sopt/and/feature/signup/SignUpRoute.kt +++ b/app/src/main/java/org/sopt/and/feature/signup/SignUpRoute.kt @@ -23,7 +23,6 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel @@ -35,7 +34,6 @@ import org.sopt.and.core.designsystem.component.SocialLoginButton import org.sopt.and.core.designsystem.component.textfield.EmailTextField import org.sopt.and.core.designsystem.component.textfield.PasswordTextField import org.sopt.and.core.designsystem.component.topappbar.CloseButtonTopAppBar -import org.sopt.and.core.designsystem.theme.ANDANDROIDTheme import org.sopt.and.core.extension.toast @Composable @@ -45,32 +43,48 @@ fun SignUpRoute( navigateToSignIn: () -> Unit, popStackBack: () -> Unit, ) { - val signUpState by viewModel.signUpState.collectAsStateWithLifecycle() - val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current - LaunchedEffect(viewModel.signUpSideEffect, lifecycleOwner) { - viewModel.signUpSideEffect.flowWithLifecycle(lifecycle = lifecycleOwner.lifecycle) + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(viewModel.sideEffect, lifecycleOwner) { + viewModel.sideEffect.flowWithLifecycle(lifecycle = lifecycleOwner.lifecycle) .collect { sideEffect -> when (sideEffect) { - is SignUpSideEffect.Toast -> context.toast(sideEffect.message) - is SignUpSideEffect.NavigateToSignIn -> { - context.toast(R.string.sign_up_success) - navigateToSignIn() - } + is SignUpContract.SignUpSideEffect.ShowToast -> context.toast(sideEffect.message) + is SignUpContract.SignUpSideEffect.NavigateToSignIn -> navigateToSignIn() + is SignUpContract.SignUpSideEffect.NavigateUp -> popStackBack() } } } SignUpScreen( - onSignUpButtonClick = viewModel::onSignUpClick, - onIdChange = viewModel::updateEmail, - onPasswordChange = viewModel::updatePassword, - onHobbyChange = viewModel::updateHobby, + onSignUpButtonClick = { viewModel.setEvent(SignUpContract.SignUpEvent.OnSignUpButtonClicked) }, + onUsernameChange = { newValue -> + viewModel.setEvent( + SignUpContract.SignUpEvent.OnUsernameChanged( + newValue + ) + ) + }, + onPasswordChange = { newValue -> + viewModel.setEvent( + SignUpContract.SignUpEvent.OnPasswordChanged( + newValue + ) + ) + }, + onHobbyChange = { newValue -> + viewModel.setEvent( + SignUpContract.SignUpEvent.OnHobbyChanged( + newValue + ) + ) + }, onCloseButtonClick = popStackBack, modifier = modifier, - signUpState = signUpState, + uiState = uiState ) } @@ -78,12 +92,12 @@ fun SignUpRoute( @Composable fun SignUpScreen( onSignUpButtonClick: () -> Unit, - onIdChange: (String) -> Unit, + onUsernameChange: (String) -> Unit, onPasswordChange: (String) -> Unit, onHobbyChange: (String) -> Unit, onCloseButtonClick: () -> Unit, + uiState: SignUpContract.SignUpUiState, modifier: Modifier = Modifier, - signUpState: SignUpState, ) { Column( modifier = modifier @@ -113,9 +127,9 @@ fun SignUpScreen( ) EmailTextField( - email = signUpState.email, + email = uiState.username, hint = stringResource(R.string.sign_up_email_hint), - onValueChange = onIdChange, + onValueChange = onUsernameChange, modifier = Modifier .padding(top = 30.dp) .padding(horizontal = 20.dp), @@ -141,7 +155,7 @@ fun SignUpScreen( } PasswordTextField( - password = signUpState.password, + password = uiState.password, hint = stringResource(R.string.sign_up_password_hint), onValueChange = onPasswordChange, modifier = Modifier @@ -170,7 +184,7 @@ fun SignUpScreen( } EmailTextField( - email = signUpState.hobby, + email = uiState.hobby, hint = stringResource(R.string.sign_up_hobby_hint), onValueChange = onHobbyChange, modifier = Modifier @@ -262,19 +276,3 @@ private fun SignUpTitle( ) } } - -@Preview(showBackground = true) -@Composable -fun SignInPreview() { - ANDANDROIDTheme { - SignUpScreen( - onSignUpButtonClick = { }, - modifier = Modifier, - onIdChange = { }, - onPasswordChange = { }, - onHobbyChange = { }, - onCloseButtonClick = { }, - signUpState = SignUpState() - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/feature/signup/SignUpSideEffect.kt b/app/src/main/java/org/sopt/and/feature/signup/SignUpSideEffect.kt deleted file mode 100644 index 7ac1551..0000000 --- a/app/src/main/java/org/sopt/and/feature/signup/SignUpSideEffect.kt +++ /dev/null @@ -1,8 +0,0 @@ -package org.sopt.and.feature.signup - -import androidx.annotation.StringRes - -sealed class SignUpSideEffect { - data class Toast(@StringRes val message: Int) : SignUpSideEffect() - data object NavigateToSignIn : SignUpSideEffect() -} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/feature/signup/SignUpState.kt b/app/src/main/java/org/sopt/and/feature/signup/SignUpState.kt deleted file mode 100644 index b8c8811..0000000 --- a/app/src/main/java/org/sopt/and/feature/signup/SignUpState.kt +++ /dev/null @@ -1,7 +0,0 @@ -package org.sopt.and.feature.signup - -data class SignUpState( - val email: String = "", - val password: String = "", - val hobby: String = "", -) \ No newline at end of file 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 0fae244..2e970f6 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,102 +1,70 @@ package org.sopt.and.feature.signup -import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.sopt.and.R +import org.sopt.and.core.util.BaseViewModel import org.sopt.and.domain.usecase.PostSignUpUseCase import javax.inject.Inject @HiltViewModel class SignUpViewModel @Inject constructor( private val postSignUpUseCase: PostSignUpUseCase, -) : ViewModel() { - private val _signUpState: MutableStateFlow = MutableStateFlow(SignUpState()) - val signUpState get() = _signUpState.asStateFlow() +) : BaseViewModel() { + override fun createInitialState(): SignUpContract.SignUpUiState = + SignUpContract.SignUpUiState() - private val _signUpSideEffect = MutableSharedFlow() - val signUpSideEffect get() = _signUpSideEffect.asSharedFlow() - - fun onSignUpClick() { - viewModelScope.launch { - if (!isInputValid(_signUpState.value.email) || !isInputValid(_signUpState.value.hobby)) { - _signUpSideEffect.emit(SignUpSideEffect.Toast(R.string.textfield_input_length)) - } else if (!isPasswordValid(_signUpState.value.password)) { - _signUpSideEffect.emit(SignUpSideEffect.Toast(R.string.sign_up_not_valid_password)) - } else { - postSignUpUseCase( - username = _signUpState.value.email, - password = _signUpState.value.password, - hobby = _signUpState.value.hobby - ).onSuccess { response -> - _signUpSideEffect.emit( - SignUpSideEffect.Toast(R.string.sign_up_success) - ) - if (response.userNumber != null) { - _signUpSideEffect.emit(SignUpSideEffect.NavigateToSignIn) - } - }.onFailure { - _signUpSideEffect.emit(SignUpSideEffect.Toast(R.string.sign_up_failed)) - } + override suspend fun handleEvent(event: SignUpContract.SignUpEvent) { + when (event) { + is SignUpContract.SignUpEvent.OnUsernameChanged -> { + val isSignUpEnabled = isSignUpAvailable() + setState { copy(username = event.username, isSignUpEnabled = isSignUpEnabled) } } - } - } - private fun isInputValid(text: String): Boolean = - text.length <= MAX_LENGTH + is SignUpContract.SignUpEvent.OnPasswordChanged -> { + val isSignUpEnabled = isSignUpAvailable() + setState { copy(password = event.password, isSignUpEnabled = isSignUpEnabled) } + } - private fun isPasswordValid(password: String): Boolean { - if (password.length <= MAX_LENGTH) { - var count = 0 - if (password.contains(UPPER_CASE_REGEX.toRegex())) count++ - if (password.contains(LOWER_CASE_REGEX.toRegex())) count++ - if (password.contains(NUMBER_REGEX.toRegex())) count++ - if (password.contains(SPECIAL_CHAR_REGEX.toRegex())) count++ + is SignUpContract.SignUpEvent.OnHobbyChanged -> { + val isSignUpEnabled = isSignUpAvailable() + setState { copy(hobby = event.hobby, isSignUpEnabled = isSignUpEnabled) } + } - if (count >= PASSWORD_TYPE) return true - } - return false - } + is SignUpContract.SignUpEvent.OnBackButtonClicked -> { + setSideEffect(sideEffect = SignUpContract.SignUpSideEffect.NavigateUp) + } - fun updateEmail(email: String) { - _signUpState.update { - it.copy( - email = email - ) + is SignUpContract.SignUpEvent.OnSignUpButtonClicked -> { + signUp() + } } } - fun updatePassword(password: String) { - _signUpState.update { - it.copy( - password = password - ) + private fun signUp() = viewModelScope.launch { + with(currentState) { + if (isSignUpEnabled) { + postSignUpUseCase(username, password, hobby) + .onSuccess { + setSideEffect(SignUpContract.SignUpSideEffect.ShowToast(R.string.sign_up_success)) + setSideEffect(SignUpContract.SignUpSideEffect.NavigateToSignIn) + }.onFailure { + setSideEffect(SignUpContract.SignUpSideEffect.ShowToast(R.string.sign_up_failed)) + } + } } } - fun updateHobby(hobby: String) { - _signUpState.update { - it.copy( - hobby = hobby - ) + private fun isSignUpAvailable(): Boolean = + with(currentState) { + username.length in MIN_LENGTH..MAX_LENGTH + && password.length in MIN_LENGTH..MAX_LENGTH + && hobby.length in MIN_LENGTH..MAX_LENGTH } - } companion object { private const val MAX_LENGTH = 8 - private const val PASSWORD_LENGTH_MIN = 8 - private const val PASSWORD_LENGTH_MAX = 20 - private const val PASSWORD_TYPE = 3 - - const val UPPER_CASE_REGEX = "[A-Z]" - const val LOWER_CASE_REGEX = "[a-z]" - const val NUMBER_REGEX = "[0-9]" - const val SPECIAL_CHAR_REGEX = "[!@#\$%^&*(),.?\":{}|<>]" + private const val MIN_LENGTH = 1 } } \ No newline at end of file