From 5fedc566ae63b07a2f92be307e5091b0887bd727 Mon Sep 17 00:00:00 2001 From: boiledegg Date: Fri, 13 Dec 2024 00:53:26 +0900 Subject: [PATCH 1/9] =?UTF-8?q?add/#14:=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/viewmodel/BaseViewModel.kt | 61 +++++++++++++++++++ .../org/sopt/and/core/viewmodel/UiEvent.kt | 3 + .../sopt/and/core/viewmodel/UiSideEffect.kt | 3 + .../org/sopt/and/core/viewmodel/UiState.kt | 3 + 4 files changed, 70 insertions(+) create mode 100644 app/src/main/java/org/sopt/and/core/viewmodel/BaseViewModel.kt create mode 100644 app/src/main/java/org/sopt/and/core/viewmodel/UiEvent.kt create mode 100644 app/src/main/java/org/sopt/and/core/viewmodel/UiSideEffect.kt create mode 100644 app/src/main/java/org/sopt/and/core/viewmodel/UiState.kt diff --git a/app/src/main/java/org/sopt/and/core/viewmodel/BaseViewModel.kt b/app/src/main/java/org/sopt/and/core/viewmodel/BaseViewModel.kt new file mode 100644 index 0000000..537fd78 --- /dev/null +++ b/app/src/main/java/org/sopt/and/core/viewmodel/BaseViewModel.kt @@ -0,0 +1,61 @@ +package org.sopt.and.core.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +abstract class BaseViewModel( +) : ViewModel() { + + /** + * [createInitialState] is a function that creates the initial state of the state. + * Its role is to force users to initialize UiState in child viewmodel + */ + private val initialState: State by lazy { createInitialState() } + abstract fun createInitialState(): State + + private val _uiState = MutableStateFlow(initialState) + val uiState = _uiState.asStateFlow() + val currentState: State + get() = uiState.value + + private val _event: MutableSharedFlow = MutableSharedFlow() + val event = _event.asSharedFlow() + + private val _sideEffect: MutableSharedFlow = MutableSharedFlow() + val sideEffect = _sideEffect.asSharedFlow() + + fun setState(reduce: State.() -> State) { + _uiState.value = currentState.reduce() + } + + /** + * [setEvent] is used in UI to send out event which can change UI State + * + * It is set as `open`, making it possible for child viewmodel to override and customize this method + */ + open fun setEvent(event: Event) { + dispatchEvent(event) + } + + /** + * [dispatchEvent] is used to wrap event logins with coroutineScope + * + * By doing so, event logics can handle multiple emissions such as `SideEffect` + */ + 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/viewmodel/UiEvent.kt b/app/src/main/java/org/sopt/and/core/viewmodel/UiEvent.kt new file mode 100644 index 0000000..60856d7 --- /dev/null +++ b/app/src/main/java/org/sopt/and/core/viewmodel/UiEvent.kt @@ -0,0 +1,3 @@ +package org.sopt.and.core.viewmodel + +interface UiEvent \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/core/viewmodel/UiSideEffect.kt b/app/src/main/java/org/sopt/and/core/viewmodel/UiSideEffect.kt new file mode 100644 index 0000000..2141355 --- /dev/null +++ b/app/src/main/java/org/sopt/and/core/viewmodel/UiSideEffect.kt @@ -0,0 +1,3 @@ +package org.sopt.and.core.viewmodel + +interface UiSideEffect \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/core/viewmodel/UiState.kt b/app/src/main/java/org/sopt/and/core/viewmodel/UiState.kt new file mode 100644 index 0000000..4afa3cb --- /dev/null +++ b/app/src/main/java/org/sopt/and/core/viewmodel/UiState.kt @@ -0,0 +1,3 @@ +package org.sopt.and.core.viewmodel + +interface UiState \ No newline at end of file From 2b47925d9734815186e924135302c4f768940009 Mon Sep 17 00:00:00 2001 From: boiledegg Date: Fri, 13 Dec 2024 00:53:54 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat/#14:=20SignIn=20MVI=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../and/presentation/signin/SignInScreen.kt | 36 ++++++++---- .../presentation/signin/SignInSideEffect.kt | 11 ---- .../presentation/signin/SignInViewModel.kt | 56 ++++++++++--------- .../signin/contract/SignInEvent.kt | 10 ++++ .../signin/contract/SignInSideEffect.kt | 10 ++++ .../signin/contract/SignInUiState.kt | 9 +++ .../signin/state/SignInUiState.kt | 6 -- 7 files changed, 82 insertions(+), 56 deletions(-) delete mode 100644 app/src/main/java/org/sopt/and/presentation/signin/SignInSideEffect.kt create mode 100644 app/src/main/java/org/sopt/and/presentation/signin/contract/SignInEvent.kt create mode 100644 app/src/main/java/org/sopt/and/presentation/signin/contract/SignInSideEffect.kt create mode 100644 app/src/main/java/org/sopt/and/presentation/signin/contract/SignInUiState.kt delete mode 100644 app/src/main/java/org/sopt/and/presentation/signin/state/SignInUiState.kt diff --git a/app/src/main/java/org/sopt/and/presentation/signin/SignInScreen.kt b/app/src/main/java/org/sopt/and/presentation/signin/SignInScreen.kt index f4cc3b7..70e5682 100644 --- a/app/src/main/java/org/sopt/and/presentation/signin/SignInScreen.kt +++ b/app/src/main/java/org/sopt/and/presentation/signin/SignInScreen.kt @@ -40,7 +40,9 @@ import org.sopt.and.core.extension.showWavveSnackBar import org.sopt.and.core.extension.toast import org.sopt.and.core.preference.PreferenceUtil.Companion.LocalPreference import org.sopt.and.presentation.signin.component.SignInExtraServiceGroup -import org.sopt.and.presentation.signin.state.SignInUiState +import org.sopt.and.presentation.signin.contract.SignInEvent +import org.sopt.and.presentation.signin.contract.SignInSideEffect +import org.sopt.and.presentation.signin.contract.SignInUiState @Composable fun SignInRoute( @@ -61,14 +63,16 @@ fun SignInRoute( viewModel.sideEffect.flowWithLifecycle(lifecycle = lifecycleOwner.lifecycle) .collect { sideEffect -> when (sideEffect) { - is SignInSideEffect.Toast -> context.toast( - context.getString(sideEffect.message) - ) + is SignInSideEffect.ShowToast -> { + context.toast(sideEffect.message) + } - is SignInSideEffect.SnackBar -> snackBarHost.showWavveSnackBar( - context = context, - message = sideEffect.message - ) + is SignInSideEffect.ShowSnackBar -> { + snackBarHost.showWavveSnackBar( + context = context, + message = sideEffect.message + ) + } is SignInSideEffect.NavigateToHome -> { with(preference) { @@ -88,10 +92,18 @@ fun SignInRoute( SignInScreen( uiState = uiState, snackBarHost = snackBarHost, - onIdChange = viewModel::updateId, - onPasswordChange = viewModel::updatePassword, - onLoginClick = viewModel::onSignInButtonClick, - onSignUpClick = viewModel::onSignUpButtonClick, + onIdChange = { newValue -> + viewModel.setEvent(SignInEvent.OnIdTextFieldChanged(newValue)) + }, + onPasswordChange = { newValue -> + viewModel.setEvent(SignInEvent.OnPasswordTextFieldChanged(newValue)) + }, + onLoginClick = { + viewModel.setEvent(SignInEvent.OnSignInButtonClicked) + }, + onSignUpClick = { + viewModel.setEvent(SignInEvent.OnSignUpButtonClicked) + }, modifier = modifier ) } diff --git a/app/src/main/java/org/sopt/and/presentation/signin/SignInSideEffect.kt b/app/src/main/java/org/sopt/and/presentation/signin/SignInSideEffect.kt deleted file mode 100644 index 70271d1..0000000 --- a/app/src/main/java/org/sopt/and/presentation/signin/SignInSideEffect.kt +++ /dev/null @@ -1,11 +0,0 @@ -package org.sopt.and.presentation.signin - -import androidx.annotation.StringRes - -sealed class SignInSideEffect { - data class Toast(@StringRes val message: Int) : SignInSideEffect() - data class SnackBar( val message: String) : SignInSideEffect() - data class NavigateToHome(val token: String) : SignInSideEffect() - data object NavigateToSignUp : SignInSideEffect() - -} diff --git a/app/src/main/java/org/sopt/and/presentation/signin/SignInViewModel.kt b/app/src/main/java/org/sopt/and/presentation/signin/SignInViewModel.kt index 4639b7d..3e766e4 100644 --- a/app/src/main/java/org/sopt/and/presentation/signin/SignInViewModel.kt +++ b/app/src/main/java/org/sopt/and/presentation/signin/SignInViewModel.kt @@ -1,55 +1,57 @@ package org.sopt.and.presentation.signin -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.core.viewmodel.BaseViewModel import org.sopt.and.domain.entity.User import org.sopt.and.domain.usecase.SignInUseCase -import org.sopt.and.presentation.signin.state.SignInUiState +import org.sopt.and.presentation.signin.contract.SignInEvent +import org.sopt.and.presentation.signin.contract.SignInSideEffect +import org.sopt.and.presentation.signin.contract.SignInUiState import javax.inject.Inject @HiltViewModel class SignInViewModel @Inject constructor( private val signInUseCase: SignInUseCase -) : ViewModel() { - private var _uiState = MutableStateFlow(SignInUiState()) - val uiState = _uiState.asStateFlow() +) : BaseViewModel() { - private var _sideEffect = MutableSharedFlow() - val sideEffect = _sideEffect.asSharedFlow() + override fun createInitialState(): SignInUiState = SignInUiState() - fun updateId(id: String) = _uiState.update { currentState -> - currentState.copy( - id = id - ) - } + override suspend fun handleEvent(event: SignInEvent) { + when (event) { + is SignInEvent.OnIdTextFieldChanged -> { + setState { copy(id = event.id) } + } + + is SignInEvent.OnPasswordTextFieldChanged -> { + setState { copy(password = event.password) } + } - fun updatePassword(password: String) = _uiState.update { currentState -> - currentState.copy( - password = password - ) + SignInEvent.OnSignInButtonClicked -> { + postSignIn() + } + + SignInEvent.OnSignUpButtonClicked -> { + navigateToSignUp() + } + } } - fun onSignUpButtonClick() { + private fun navigateToSignUp() { viewModelScope.launch { - _sideEffect.emit(SignInSideEffect.NavigateToSignUp) + setSideEffect(SignInSideEffect.NavigateToSignUp) } } - fun onSignInButtonClick() = viewModelScope.launch { - val user = with(_uiState.value) { User(id, password, "") } + private fun postSignIn() = viewModelScope.launch { + val user = with(currentState) { User(id, password, "") } signInUseCase.invoke(user).onSuccess { response -> response.token?.run { - _sideEffect.emit(SignInSideEffect.NavigateToHome(this)) + setSideEffect(SignInSideEffect.NavigateToHome(this)) } }.onFailure { throwable -> - _sideEffect.emit(SignInSideEffect.SnackBar(throwable.message.orEmpty())) + setSideEffect(SignInSideEffect.ShowSnackBar(throwable.message.orEmpty())) } } } \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/signin/contract/SignInEvent.kt b/app/src/main/java/org/sopt/and/presentation/signin/contract/SignInEvent.kt new file mode 100644 index 0000000..c690c3b --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/signin/contract/SignInEvent.kt @@ -0,0 +1,10 @@ +package org.sopt.and.presentation.signin.contract + +import org.sopt.and.core.viewmodel.UiEvent + +sealed class SignInEvent: UiEvent { + data class OnIdTextFieldChanged(val id: String): SignInEvent() + data class OnPasswordTextFieldChanged(val password: String): SignInEvent() + data object OnSignInButtonClicked: SignInEvent() + data object OnSignUpButtonClicked: SignInEvent() +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/signin/contract/SignInSideEffect.kt b/app/src/main/java/org/sopt/and/presentation/signin/contract/SignInSideEffect.kt new file mode 100644 index 0000000..9a1cdb8 --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/signin/contract/SignInSideEffect.kt @@ -0,0 +1,10 @@ +package org.sopt.and.presentation.signin.contract + +import org.sopt.and.core.viewmodel.UiSideEffect + +sealed interface SignInSideEffect: UiSideEffect { + data class ShowToast(val message: String) : SignInSideEffect + data class ShowSnackBar(val message: String) : SignInSideEffect + data class NavigateToHome(val token: String) : SignInSideEffect + data object NavigateToSignUp : SignInSideEffect +} diff --git a/app/src/main/java/org/sopt/and/presentation/signin/contract/SignInUiState.kt b/app/src/main/java/org/sopt/and/presentation/signin/contract/SignInUiState.kt new file mode 100644 index 0000000..13f273a --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/signin/contract/SignInUiState.kt @@ -0,0 +1,9 @@ +package org.sopt.and.presentation.signin.contract + +import org.sopt.and.core.viewmodel.UiState + + +data class SignInUiState( + val id: String = "", + val password: String = "" +): UiState \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/signin/state/SignInUiState.kt b/app/src/main/java/org/sopt/and/presentation/signin/state/SignInUiState.kt deleted file mode 100644 index 9dbd1d7..0000000 --- a/app/src/main/java/org/sopt/and/presentation/signin/state/SignInUiState.kt +++ /dev/null @@ -1,6 +0,0 @@ -package org.sopt.and.presentation.signin.state - -data class SignInUiState( - val id: String = "", - val password: String = "" -) \ No newline at end of file From 5889e715198426e2455ac2c3aca7412f00215344 Mon Sep 17 00:00:00 2001 From: boiledegg Date: Fri, 13 Dec 2024 01:02:28 +0900 Subject: [PATCH 3/9] =?UTF-8?q?rename/#14:=20SignIn=20Event=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sopt/and/presentation/signin/SignInScreen.kt | 10 +++++----- .../and/presentation/signin/SignInViewModel.kt | 14 +++++++------- .../presentation/signin/contract/SignInEvent.kt | 10 ---------- .../presentation/signin/contract/SignInUiEvent.kt | 10 ++++++++++ 4 files changed, 22 insertions(+), 22 deletions(-) delete mode 100644 app/src/main/java/org/sopt/and/presentation/signin/contract/SignInEvent.kt create mode 100644 app/src/main/java/org/sopt/and/presentation/signin/contract/SignInUiEvent.kt diff --git a/app/src/main/java/org/sopt/and/presentation/signin/SignInScreen.kt b/app/src/main/java/org/sopt/and/presentation/signin/SignInScreen.kt index 70e5682..c9346f8 100644 --- a/app/src/main/java/org/sopt/and/presentation/signin/SignInScreen.kt +++ b/app/src/main/java/org/sopt/and/presentation/signin/SignInScreen.kt @@ -40,7 +40,7 @@ import org.sopt.and.core.extension.showWavveSnackBar import org.sopt.and.core.extension.toast import org.sopt.and.core.preference.PreferenceUtil.Companion.LocalPreference import org.sopt.and.presentation.signin.component.SignInExtraServiceGroup -import org.sopt.and.presentation.signin.contract.SignInEvent +import org.sopt.and.presentation.signin.contract.SignInUiEvent import org.sopt.and.presentation.signin.contract.SignInSideEffect import org.sopt.and.presentation.signin.contract.SignInUiState @@ -93,16 +93,16 @@ fun SignInRoute( uiState = uiState, snackBarHost = snackBarHost, onIdChange = { newValue -> - viewModel.setEvent(SignInEvent.OnIdTextFieldChanged(newValue)) + viewModel.setEvent(SignInUiEvent.OnIdTextFieldChanged(newValue)) }, onPasswordChange = { newValue -> - viewModel.setEvent(SignInEvent.OnPasswordTextFieldChanged(newValue)) + viewModel.setEvent(SignInUiEvent.OnPasswordTextFieldChanged(newValue)) }, onLoginClick = { - viewModel.setEvent(SignInEvent.OnSignInButtonClicked) + viewModel.setEvent(SignInUiEvent.OnSignInButtonClicked) }, onSignUpClick = { - viewModel.setEvent(SignInEvent.OnSignUpButtonClicked) + viewModel.setEvent(SignInUiEvent.OnSignUpButtonClicked) }, modifier = modifier ) diff --git a/app/src/main/java/org/sopt/and/presentation/signin/SignInViewModel.kt b/app/src/main/java/org/sopt/and/presentation/signin/SignInViewModel.kt index 3e766e4..60b2bac 100644 --- a/app/src/main/java/org/sopt/and/presentation/signin/SignInViewModel.kt +++ b/app/src/main/java/org/sopt/and/presentation/signin/SignInViewModel.kt @@ -6,7 +6,7 @@ import kotlinx.coroutines.launch import org.sopt.and.core.viewmodel.BaseViewModel import org.sopt.and.domain.entity.User import org.sopt.and.domain.usecase.SignInUseCase -import org.sopt.and.presentation.signin.contract.SignInEvent +import org.sopt.and.presentation.signin.contract.SignInUiEvent import org.sopt.and.presentation.signin.contract.SignInSideEffect import org.sopt.and.presentation.signin.contract.SignInUiState import javax.inject.Inject @@ -14,25 +14,25 @@ import javax.inject.Inject @HiltViewModel class SignInViewModel @Inject constructor( private val signInUseCase: SignInUseCase -) : BaseViewModel() { +) : BaseViewModel() { override fun createInitialState(): SignInUiState = SignInUiState() - override suspend fun handleEvent(event: SignInEvent) { + override suspend fun handleEvent(event: SignInUiEvent) { when (event) { - is SignInEvent.OnIdTextFieldChanged -> { + is SignInUiEvent.OnIdTextFieldChanged -> { setState { copy(id = event.id) } } - is SignInEvent.OnPasswordTextFieldChanged -> { + is SignInUiEvent.OnPasswordTextFieldChanged -> { setState { copy(password = event.password) } } - SignInEvent.OnSignInButtonClicked -> { + SignInUiEvent.OnSignInButtonClicked -> { postSignIn() } - SignInEvent.OnSignUpButtonClicked -> { + SignInUiEvent.OnSignUpButtonClicked -> { navigateToSignUp() } } diff --git a/app/src/main/java/org/sopt/and/presentation/signin/contract/SignInEvent.kt b/app/src/main/java/org/sopt/and/presentation/signin/contract/SignInEvent.kt deleted file mode 100644 index c690c3b..0000000 --- a/app/src/main/java/org/sopt/and/presentation/signin/contract/SignInEvent.kt +++ /dev/null @@ -1,10 +0,0 @@ -package org.sopt.and.presentation.signin.contract - -import org.sopt.and.core.viewmodel.UiEvent - -sealed class SignInEvent: UiEvent { - data class OnIdTextFieldChanged(val id: String): SignInEvent() - data class OnPasswordTextFieldChanged(val password: String): SignInEvent() - data object OnSignInButtonClicked: SignInEvent() - data object OnSignUpButtonClicked: SignInEvent() -} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/signin/contract/SignInUiEvent.kt b/app/src/main/java/org/sopt/and/presentation/signin/contract/SignInUiEvent.kt new file mode 100644 index 0000000..91d8712 --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/signin/contract/SignInUiEvent.kt @@ -0,0 +1,10 @@ +package org.sopt.and.presentation.signin.contract + +import org.sopt.and.core.viewmodel.UiEvent + +sealed class SignInUiEvent: UiEvent { + data class OnIdTextFieldChanged(val id: String): SignInUiEvent() + data class OnPasswordTextFieldChanged(val password: String): SignInUiEvent() + data object OnSignInButtonClicked: SignInUiEvent() + data object OnSignUpButtonClicked: SignInUiEvent() +} \ No newline at end of file From 6ec409c96137b8bb204c4ce590137a30ae8139b8 Mon Sep 17 00:00:00 2001 From: boiledegg Date: Fri, 13 Dec 2024 01:16:06 +0900 Subject: [PATCH 4/9] =?UTF-8?q?feat/#14:=20SignUp=20MVI=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../and/presentation/signup/SignUpScreen.kt | 26 +++++-- .../presentation/signup/SignUpSideEffect.kt | 6 -- .../presentation/signup/SignUpViewModel.kt | 74 +++++++++---------- .../signup/contract/SignUpSideEffect.kt | 8 ++ .../signup/contract/SignUpUiEvent.kt | 11 +++ .../{state => contract}/SignUpUiState.kt | 6 +- 6 files changed, 75 insertions(+), 56 deletions(-) delete mode 100644 app/src/main/java/org/sopt/and/presentation/signup/SignUpSideEffect.kt create mode 100644 app/src/main/java/org/sopt/and/presentation/signup/contract/SignUpSideEffect.kt create mode 100644 app/src/main/java/org/sopt/and/presentation/signup/contract/SignUpUiEvent.kt rename app/src/main/java/org/sopt/and/presentation/signup/{state => contract}/SignUpUiState.kt (59%) diff --git a/app/src/main/java/org/sopt/and/presentation/signup/SignUpScreen.kt b/app/src/main/java/org/sopt/and/presentation/signup/SignUpScreen.kt index bb16667..cb872a1 100644 --- a/app/src/main/java/org/sopt/and/presentation/signup/SignUpScreen.kt +++ b/app/src/main/java/org/sopt/and/presentation/signup/SignUpScreen.kt @@ -29,7 +29,9 @@ import org.sopt.and.core.designsystem.component.topbar.CancelTopBar import org.sopt.and.core.extension.toast import org.sopt.and.presentation.signup.component.SignUpButton import org.sopt.and.presentation.signup.component.SignUpTitle -import org.sopt.and.presentation.signup.state.SignUpUiState +import org.sopt.and.presentation.signup.contract.SignUpSideEffect +import org.sopt.and.presentation.signup.contract.SignUpUiEvent +import org.sopt.and.presentation.signup.contract.SignUpUiState @Composable fun SignUpRoute( @@ -45,7 +47,7 @@ fun SignUpRoute( viewModel.sideEffect.flowWithLifecycle(lifecycle = lifecycleOwner.lifecycle) .collect { sideEffect -> when (sideEffect) { - is SignUpSideEffect.Toast -> context.toast(sideEffect.message) + is SignUpSideEffect.ShowToast -> context.toast(sideEffect.message) is SignUpSideEffect.NavigateUp -> navigateUp(uiState.id, uiState.password) } } @@ -54,11 +56,21 @@ fun SignUpRoute( SignUpScreen( modifier = modifier, uiState = uiState, - onIdChange = viewModel::updateId, - onPasswordChange = viewModel::updatePassword, - onHobbyChange = viewModel::updateHobby, - onSignUpButtonPress = viewModel::registerUser, - onCloseClick = {} + onIdChange = { newValue -> + viewModel.setEvent(SignUpUiEvent.OnIdTextFieldChanged(newValue)) + }, + onPasswordChange = { newValue -> + viewModel.setEvent(SignUpUiEvent.OnPasswordTextFieldChanged(newValue)) + }, + onHobbyChange = { newValue -> + viewModel.setEvent(SignUpUiEvent.OnHobbyTextFieldChanged(newValue)) + }, + onSignUpButtonPress = { + viewModel.setEvent(SignUpUiEvent.OnSignUpButtonClicked) + }, + onCloseClick = { + viewModel.setEvent(SignUpUiEvent.OnCloseButtonClicked) + } ) } diff --git a/app/src/main/java/org/sopt/and/presentation/signup/SignUpSideEffect.kt b/app/src/main/java/org/sopt/and/presentation/signup/SignUpSideEffect.kt deleted file mode 100644 index 074ac84..0000000 --- a/app/src/main/java/org/sopt/and/presentation/signup/SignUpSideEffect.kt +++ /dev/null @@ -1,6 +0,0 @@ -package org.sopt.and.presentation.signup - -sealed class SignUpSideEffect { - data class Toast(val message: String) : SignUpSideEffect() - data object NavigateUp : SignUpSideEffect() -} diff --git a/app/src/main/java/org/sopt/and/presentation/signup/SignUpViewModel.kt b/app/src/main/java/org/sopt/and/presentation/signup/SignUpViewModel.kt index 378044e..547b18b 100644 --- a/app/src/main/java/org/sopt/and/presentation/signup/SignUpViewModel.kt +++ b/app/src/main/java/org/sopt/and/presentation/signup/SignUpViewModel.kt @@ -1,69 +1,61 @@ package org.sopt.and.presentation.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.core.viewmodel.BaseViewModel import org.sopt.and.domain.entity.User import org.sopt.and.domain.usecase.SignUpUseCase -import org.sopt.and.presentation.signup.state.SignUpUiState +import org.sopt.and.presentation.signup.contract.SignUpSideEffect +import org.sopt.and.presentation.signup.contract.SignUpUiEvent +import org.sopt.and.presentation.signup.contract.SignUpUiState import javax.inject.Inject @HiltViewModel class SignUpViewModel @Inject constructor( private val registerUserUseCase: SignUpUseCase -) : ViewModel() { - private val _uiState = MutableStateFlow(SignUpUiState()) - val uiState = _uiState.asStateFlow() +) : BaseViewModel() { - private val _sideEffect = MutableSharedFlow() - val sideEffect = _sideEffect.asSharedFlow() + override fun createInitialState(): SignUpUiState = SignUpUiState() - fun updateId(id: String) { - _uiState.update { currentState -> - currentState.copy(id = id) - } - updateButtonEnabled() - } - - fun updatePassword(password: String) { - _uiState.update { currentState -> - currentState.copy(password = password) - } - updateButtonEnabled() - } - - fun updateHobby(hobby: String) { - _uiState.update { currentState -> - currentState.copy(hobby = hobby) + override suspend fun handleEvent(event: SignUpUiEvent) { + when(event) { + is SignUpUiEvent.OnIdTextFieldChanged -> { + val isButtonEnabled = checkSignUpPossible() + setState { copy(id = event.id, isButtonEnabled = isButtonEnabled) } + } + is SignUpUiEvent.OnPasswordTextFieldChanged -> { + val isButtonEnabled = checkSignUpPossible() + setState { copy(password = event.password, isButtonEnabled = isButtonEnabled) } + } + is SignUpUiEvent.OnHobbyTextFieldChanged -> { + val isButtonEnabled = checkSignUpPossible() + setState { copy(hobby = event.hobby, isButtonEnabled = isButtonEnabled) } + } + is SignUpUiEvent.OnCloseButtonClicked -> { + setSideEffect(sideEffect = SignUpSideEffect.NavigateUp) + } + is SignUpUiEvent.OnSignUpButtonClicked -> { + registerUser() + } } - updateButtonEnabled() } - private fun updateButtonEnabled() = _uiState.update { currentState -> - currentState.copy( - isButtonEnabled = _uiState.value.id.isNotBlank() - && _uiState.value.password.isNotBlank() - && _uiState.value.hobby.isNotBlank() - ) + private fun checkSignUpPossible():Boolean = with(currentState) { + id.isNotBlank() && password.isNotBlank() && hobby.isNotBlank() } - fun registerUser() = viewModelScope.launch { - with(_uiState.value) { + private fun registerUser() = viewModelScope.launch { + with(currentState) { if (isButtonEnabled) { registerUserUseCase.invoke(User(id, password, hobby)) .onSuccess { response -> - _sideEffect.emit(SignUpSideEffect.Toast(response.message)) + setSideEffect(SignUpSideEffect.ShowToast(response.message)) if (response.id != null) { - _sideEffect.emit(SignUpSideEffect.NavigateUp) + setSideEffect(SignUpSideEffect.NavigateUp) } }.onFailure { throwable -> - _sideEffect.emit(SignUpSideEffect.Toast(throwable.message.orEmpty())) + setSideEffect(SignUpSideEffect.ShowToast(throwable.message.orEmpty())) } } } diff --git a/app/src/main/java/org/sopt/and/presentation/signup/contract/SignUpSideEffect.kt b/app/src/main/java/org/sopt/and/presentation/signup/contract/SignUpSideEffect.kt new file mode 100644 index 0000000..650eb64 --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/signup/contract/SignUpSideEffect.kt @@ -0,0 +1,8 @@ +package org.sopt.and.presentation.signup.contract + +import org.sopt.and.core.viewmodel.UiSideEffect + +sealed class SignUpSideEffect: UiSideEffect { + data class ShowToast(val message: String) : SignUpSideEffect() + data object NavigateUp : SignUpSideEffect() +} diff --git a/app/src/main/java/org/sopt/and/presentation/signup/contract/SignUpUiEvent.kt b/app/src/main/java/org/sopt/and/presentation/signup/contract/SignUpUiEvent.kt new file mode 100644 index 0000000..6927caf --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/signup/contract/SignUpUiEvent.kt @@ -0,0 +1,11 @@ +package org.sopt.and.presentation.signup.contract + +import org.sopt.and.core.viewmodel.UiEvent + +sealed class SignUpUiEvent: UiEvent { + data class OnIdTextFieldChanged(val id: String): SignUpUiEvent() + data class OnPasswordTextFieldChanged(val password: String): SignUpUiEvent() + data class OnHobbyTextFieldChanged(val hobby: String): SignUpUiEvent() + data object OnCloseButtonClicked: SignUpUiEvent() + data object OnSignUpButtonClicked: SignUpUiEvent() +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/signup/state/SignUpUiState.kt b/app/src/main/java/org/sopt/and/presentation/signup/contract/SignUpUiState.kt similarity index 59% rename from app/src/main/java/org/sopt/and/presentation/signup/state/SignUpUiState.kt rename to app/src/main/java/org/sopt/and/presentation/signup/contract/SignUpUiState.kt index 87f8716..1af63b4 100644 --- a/app/src/main/java/org/sopt/and/presentation/signup/state/SignUpUiState.kt +++ b/app/src/main/java/org/sopt/and/presentation/signup/contract/SignUpUiState.kt @@ -1,8 +1,10 @@ -package org.sopt.and.presentation.signup.state +package org.sopt.and.presentation.signup.contract + +import org.sopt.and.core.viewmodel.UiState data class SignUpUiState( val id: String = "", val password: String = "", val hobby: String = "", val isButtonEnabled: Boolean = false -) +): UiState From b3118ee94719ee88d671dce6e3ad32c66603451a Mon Sep 17 00:00:00 2001 From: boiledegg Date: Fri, 13 Dec 2024 02:38:12 +0900 Subject: [PATCH 5/9] =?UTF-8?q?feat/#14:=20MyPage=20MVI=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../and/presentation/mypage/MyPageScreen.kt | 42 ++++--- .../presentation/mypage/MyPageSideEffect.kt | 5 - .../presentation/mypage/MyPageViewModel.kt | 115 +++++++++--------- .../mypage/contract/MyPageSideEffect.kt | 7 ++ .../mypage/contract/MyPageUiEvent.kt | 14 +++ .../{state => contract}/MyPageUiState.kt | 7 +- .../mypage/state/MyPageInteractionState.kt | 10 -- 7 files changed, 107 insertions(+), 93 deletions(-) delete mode 100644 app/src/main/java/org/sopt/and/presentation/mypage/MyPageSideEffect.kt create mode 100644 app/src/main/java/org/sopt/and/presentation/mypage/contract/MyPageSideEffect.kt create mode 100644 app/src/main/java/org/sopt/and/presentation/mypage/contract/MyPageUiEvent.kt rename app/src/main/java/org/sopt/and/presentation/mypage/{state => contract}/MyPageUiState.kt (69%) delete mode 100644 app/src/main/java/org/sopt/and/presentation/mypage/state/MyPageInteractionState.kt diff --git a/app/src/main/java/org/sopt/and/presentation/mypage/MyPageScreen.kt b/app/src/main/java/org/sopt/and/presentation/mypage/MyPageScreen.kt index f338ef9..9170d50 100644 --- a/app/src/main/java/org/sopt/and/presentation/mypage/MyPageScreen.kt +++ b/app/src/main/java/org/sopt/and/presentation/mypage/MyPageScreen.kt @@ -42,12 +42,14 @@ import org.sopt.and.core.designsystem.theme.Grey200 import org.sopt.and.core.designsystem.theme.WavveBackground import org.sopt.and.core.designsystem.theme.White import org.sopt.and.core.extension.noRippleClickable -import org.sopt.and.domain.entity.Program import org.sopt.and.core.preference.PreferenceUtil.Companion.LocalPreference +import org.sopt.and.domain.entity.Program import org.sopt.and.presentation.mypage.component.ProfileLogGroup import org.sopt.and.presentation.mypage.component.ProfilePurchaseGroup import org.sopt.and.presentation.mypage.component.ProfileTopBar -import org.sopt.and.presentation.mypage.state.MyPageUiState +import org.sopt.and.presentation.mypage.contract.MyPageSideEffect +import org.sopt.and.presentation.mypage.contract.MyPageUiEvent +import org.sopt.and.presentation.mypage.contract.MyPageUiState @Composable fun MyPageRoute( @@ -56,6 +58,7 @@ fun MyPageRoute( viewModel: MyPageViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val starredProgram by viewModel.starredState.collectAsStateWithLifecycle() val snackBarHost = remember { SnackbarHostState() } val lifecycleOwner = LocalLifecycleOwner.current @@ -83,22 +86,24 @@ fun MyPageRoute( MyPageScreen( hobby = uiState.hobby, snackBarHost = snackBarHost, - onLogoutButtonClick = viewModel::onLogoutButtonClick, + onLogoutButtonClick = { + viewModel.setEvent(MyPageUiEvent.OnLogoutButtonClick) + }, onProgramPress = { program -> - with(viewModel) { - updatePressedProgram(program) - updateDeleteDialogVisibility(visibility = true) - } + viewModel.setEvent(MyPageUiEvent.OnStarredProgramPressed(program)) }, - uiState = uiState + uiState = uiState, + starredPrograms = starredProgram ) FloatingActionButton( - onClick = { viewModel.updateSearchDialogVisibility(true) }, shape = CircleShape, containerColor = Color.Blue, contentColor = White, + onClick = { + viewModel.setEvent(MyPageUiEvent.OnFAButtonClick) + }, modifier = Modifier .wrapContentSize() .align(Alignment.BottomEnd) @@ -115,8 +120,12 @@ fun MyPageRoute( if (uiState.searchDialogVisibility) { SearchDialog( - onDismissRequest = { viewModel.updateSearchDialogVisibility(false) }, - onItemSelect = viewModel::onInsertProgram, + onDismissRequest = { + viewModel.setEvent(MyPageUiEvent.OnSearchDialogDismissed) + }, + onItemSelect = { program -> + viewModel.setEvent(MyPageUiEvent.OnSearchProgramSelected(program)) + }, ) } if (uiState.deleteDialogVisibility) { @@ -126,8 +135,12 @@ fun MyPageRoute( R.string.dialog_delete_content, uiState.pressedProgram?.title.orEmpty() ), - onDismissRequest = { viewModel.updateDeleteDialogVisibility(visibility = false) }, - onConfirm = viewModel::onConfirmDelete + onDismissRequest = { + viewModel.setEvent(MyPageUiEvent.OnDeleteDialogDismissed) + }, + onConfirm = { + viewModel.setEvent(MyPageUiEvent.OnDeleteProgramConfirmed) + } ) } @@ -137,6 +150,7 @@ fun MyPageRoute( private fun MyPageScreen( hobby: String, uiState: MyPageUiState, + starredPrograms: List, snackBarHost: SnackbarHostState, onLogoutButtonClick: () -> Unit, onProgramPress: (Program) -> Unit, @@ -179,7 +193,7 @@ private fun MyPageScreen( title = stringResource(R.string.mypage_content_title2), subTitle = stringResource(R.string.mypage_content_empty2), onItemPress = onProgramPress, - list = uiState.starredProgram, + list = starredPrograms, modifier = Modifier .fillMaxWidth() .padding(vertical = 10.dp) diff --git a/app/src/main/java/org/sopt/and/presentation/mypage/MyPageSideEffect.kt b/app/src/main/java/org/sopt/and/presentation/mypage/MyPageSideEffect.kt deleted file mode 100644 index b5dbeda..0000000 --- a/app/src/main/java/org/sopt/and/presentation/mypage/MyPageSideEffect.kt +++ /dev/null @@ -1,5 +0,0 @@ -package org.sopt.and.presentation.mypage - -sealed class MyPageSideEffect { - data object OnLogout : MyPageSideEffect() -} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/mypage/MyPageViewModel.kt b/app/src/main/java/org/sopt/and/presentation/mypage/MyPageViewModel.kt index f46f67e..d7275a7 100644 --- a/app/src/main/java/org/sopt/and/presentation/mypage/MyPageViewModel.kt +++ b/app/src/main/java/org/sopt/and/presentation/mypage/MyPageViewModel.kt @@ -1,32 +1,27 @@ package org.sopt.and.presentation.mypage -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.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch +import org.sopt.and.core.viewmodel.BaseViewModel import org.sopt.and.domain.entity.Program import org.sopt.and.domain.repository.MyHobbyRepository import org.sopt.and.domain.repository.StarredProgramRepository -import org.sopt.and.presentation.mypage.state.MyPageInteractionState -import org.sopt.and.presentation.mypage.state.MyPageUiState +import org.sopt.and.presentation.mypage.contract.MyPageSideEffect +import org.sopt.and.presentation.mypage.contract.MyPageUiEvent +import org.sopt.and.presentation.mypage.contract.MyPageUiState import javax.inject.Inject @HiltViewModel class MyPageViewModel @Inject constructor( private val starredProgramRepository: StarredProgramRepository, private val myHobbyRepository: MyHobbyRepository -) : ViewModel() { - private var interactionState = MutableStateFlow(MyPageInteractionState()) - private val starredState: StateFlow> = +) : BaseViewModel() { + + val starredState: StateFlow> = starredProgramRepository.getStarredPrograms() .map { it.map { entity -> Program(title = entity.programName, imgFile = entity.programImage) } } .stateIn( @@ -34,66 +29,66 @@ class MyPageViewModel @Inject constructor( started = SharingStarted.WhileSubscribed(1000), initialValue = emptyList() ) + private val currentStarredState: List + get() = starredState.value - val uiState: StateFlow = combine( - interactionState, starredState - ) { uiState, starredState -> - MyPageUiState().copy( - hobby = uiState.hobby, - searchDialogVisibility = uiState.searchDialogVisibility, - deleteDialogVisibility = uiState.deleteDialogVisibility, - pressedProgram = uiState.pressedProgram, - starredProgram = starredState - ) - }.stateIn( - scope = viewModelScope, - started = SharingStarted.Lazily, - initialValue = MyPageUiState(), - ) - - private var _sideEffect = MutableSharedFlow() - val sideEffect = _sideEffect.asSharedFlow() - - fun getMyHobby(token: String) = viewModelScope.launch { - myHobbyRepository.getMyHobby(token) - .onSuccess { hobby -> - interactionState.update { currentState -> - currentState.copy(hobby = hobby.hobby) + override fun createInitialState(): MyPageUiState = MyPageUiState() + + override suspend fun handleEvent(event: MyPageUiEvent) { + when (event) { + is MyPageUiEvent.OnLogoutButtonClick -> { + setSideEffect(MyPageSideEffect.OnLogout) + } + + is MyPageUiEvent.OnFAButtonClick -> { + setState { copy(searchDialogVisibility = true) } + } + + is MyPageUiEvent.OnSearchDialogDismissed -> { + setState { copy(searchDialogVisibility = false) } + } + + is MyPageUiEvent.OnSearchProgramSelected -> { + insertProgramToLocal(program = event.program) + } + + is MyPageUiEvent.OnStarredProgramPressed -> { + setState { + copy( + pressedProgram = event.program, + deleteDialogVisibility = true + ) } } - } - fun onLogoutButtonClick() = viewModelScope.launch { - _sideEffect.emit(MyPageSideEffect.OnLogout) - } + is MyPageUiEvent.OnDeleteDialogDismissed -> { + setState { copy(deleteDialogVisibility = false) } + } - fun onConfirmDelete() = viewModelScope.launch { - interactionState.value.pressedProgram?.run { - starredProgramRepository.deletedStarredProgram(this) - } - updateDeleteDialogVisibility(false) - } + is MyPageUiEvent.OnDeleteProgramConfirmed -> { + deleteProgramFromLocal() + setState { copy(deleteDialogVisibility = false) } + } - fun updateSearchDialogVisibility(visibility: Boolean) = - interactionState.update { currentState -> - currentState.copy(searchDialogVisibility = visibility) } + } - fun updateDeleteDialogVisibility(visibility: Boolean) = - interactionState.update { currentState -> - currentState.copy( - deleteDialogVisibility = visibility - ) - } + suspend fun getMyHobby(token: String) { + myHobbyRepository.getMyHobby(token) + .onSuccess { hobby -> + setState { copy(hobby = hobby.hobby) } + } + } - fun updatePressedProgram(program: Program) { - interactionState.update { currentState -> - currentState.copy(pressedProgram = program) + private suspend fun deleteProgramFromLocal() { + currentState.pressedProgram?.run { + starredProgramRepository.deletedStarredProgram(this) } } - fun onInsertProgram(program: Program) = viewModelScope.launch { - if (uiState.value.starredProgram.contains(program)) return@launch + private suspend fun insertProgramToLocal(program: Program) { + if (currentStarredState.contains(program)) + return starredProgramRepository.postStarredProgram(program) } diff --git a/app/src/main/java/org/sopt/and/presentation/mypage/contract/MyPageSideEffect.kt b/app/src/main/java/org/sopt/and/presentation/mypage/contract/MyPageSideEffect.kt new file mode 100644 index 0000000..c67281d --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/mypage/contract/MyPageSideEffect.kt @@ -0,0 +1,7 @@ +package org.sopt.and.presentation.mypage.contract + +import org.sopt.and.core.viewmodel.UiSideEffect + +sealed class MyPageSideEffect: UiSideEffect { + data object OnLogout : MyPageSideEffect() +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/mypage/contract/MyPageUiEvent.kt b/app/src/main/java/org/sopt/and/presentation/mypage/contract/MyPageUiEvent.kt new file mode 100644 index 0000000..0fc9ace --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/mypage/contract/MyPageUiEvent.kt @@ -0,0 +1,14 @@ +package org.sopt.and.presentation.mypage.contract + +import org.sopt.and.core.viewmodel.UiEvent +import org.sopt.and.domain.entity.Program + +sealed class MyPageUiEvent: UiEvent { + data object OnLogoutButtonClick: MyPageUiEvent() + data object OnFAButtonClick: MyPageUiEvent() + data class OnStarredProgramPressed(val program: Program): MyPageUiEvent() + data object OnSearchDialogDismissed: MyPageUiEvent() + data class OnSearchProgramSelected(val program: Program): MyPageUiEvent() + data object OnDeleteDialogDismissed: MyPageUiEvent() + data object OnDeleteProgramConfirmed: MyPageUiEvent() +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/mypage/state/MyPageUiState.kt b/app/src/main/java/org/sopt/and/presentation/mypage/contract/MyPageUiState.kt similarity index 69% rename from app/src/main/java/org/sopt/and/presentation/mypage/state/MyPageUiState.kt rename to app/src/main/java/org/sopt/and/presentation/mypage/contract/MyPageUiState.kt index adcd58e..61b39b0 100644 --- a/app/src/main/java/org/sopt/and/presentation/mypage/state/MyPageUiState.kt +++ b/app/src/main/java/org/sopt/and/presentation/mypage/contract/MyPageUiState.kt @@ -1,5 +1,6 @@ -package org.sopt.and.presentation.mypage.state +package org.sopt.and.presentation.mypage.contract +import org.sopt.and.core.viewmodel.UiState import org.sopt.and.domain.entity.Program data class MyPageUiState( @@ -7,6 +8,4 @@ data class MyPageUiState( val searchDialogVisibility: Boolean = false, val deleteDialogVisibility: Boolean = false, val pressedProgram: Program? = null, - val starredProgram: List = emptyList() - -) \ No newline at end of file +): UiState \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/mypage/state/MyPageInteractionState.kt b/app/src/main/java/org/sopt/and/presentation/mypage/state/MyPageInteractionState.kt deleted file mode 100644 index 5d62a8b..0000000 --- a/app/src/main/java/org/sopt/and/presentation/mypage/state/MyPageInteractionState.kt +++ /dev/null @@ -1,10 +0,0 @@ -package org.sopt.and.presentation.mypage.state - -import org.sopt.and.domain.entity.Program - -data class MyPageInteractionState( - val hobby: String = "", - val searchDialogVisibility: Boolean = false, - val deleteDialogVisibility: Boolean = false, - val pressedProgram: Program? = null, -) \ No newline at end of file From 7397c2ed11d68d6d9afb9b0b4a49467271831945 Mon Sep 17 00:00:00 2001 From: boiledegg Date: Fri, 13 Dec 2024 03:20:53 +0900 Subject: [PATCH 6/9] =?UTF-8?q?feat/#14:=20Search=20MVI=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../and/presentation/search/SearchScreen.kt | 34 ++++++++++- .../presentation/search/SearchViewModel.kt | 60 ++++++++++--------- .../search/contract/SearchSideEffect.kt | 7 +++ .../search/contract/SearchUiEvent.kt | 8 +++ .../{state => contract}/SearchUiState.kt | 5 +- 5 files changed, 81 insertions(+), 33 deletions(-) create mode 100644 app/src/main/java/org/sopt/and/presentation/search/contract/SearchSideEffect.kt create mode 100644 app/src/main/java/org/sopt/and/presentation/search/contract/SearchUiEvent.kt rename app/src/main/java/org/sopt/and/presentation/search/{state => contract}/SearchUiState.kt (70%) diff --git a/app/src/main/java/org/sopt/and/presentation/search/SearchScreen.kt b/app/src/main/java/org/sopt/and/presentation/search/SearchScreen.kt index 7979b55..2e73b98 100644 --- a/app/src/main/java/org/sopt/and/presentation/search/SearchScreen.kt +++ b/app/src/main/java/org/sopt/and/presentation/search/SearchScreen.kt @@ -13,20 +13,27 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.flowWithLifecycle import org.sopt.and.R import org.sopt.and.core.designsystem.component.textfield.SearchTextField import org.sopt.and.core.designsystem.theme.Grey500 +import org.sopt.and.core.extension.toast import org.sopt.and.domain.entity.Program import org.sopt.and.presentation.search.component.CategoryButton import org.sopt.and.presentation.search.component.SearchItem import org.sopt.and.presentation.search.component.SearchTabRow -import org.sopt.and.presentation.search.state.SearchUiState +import org.sopt.and.presentation.search.contract.SearchSideEffect +import org.sopt.and.presentation.search.contract.SearchUiEvent +import org.sopt.and.presentation.search.contract.SearchUiState @Composable fun SearchRoute( @@ -34,12 +41,33 @@ fun SearchRoute( viewModel: SearchViewModel = hiltViewModel() ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val lifecycleOwner = LocalLifecycleOwner.current + val context = LocalContext.current + + LaunchedEffect(viewModel.sideEffect, lifecycleOwner) { + viewModel.sideEffect.flowWithLifecycle(lifecycleOwner.lifecycle) + .collect{ sideEffect -> + when(sideEffect) { + is SearchSideEffect.ShowToast -> { + context.toast(sideEffect.message) + } + } + } + } + + LaunchedEffect(true) { + viewModel.getPopularList() + } SearchScreen( uiState = uiState, - onTabClick = viewModel::onTabClick, programList = viewModel.getTabList(), - onTextFieldValueChange = viewModel::onSearchValueChange, + onTabClick = { index -> + viewModel.setEvent(SearchUiEvent.OnTabClicked(index)) + }, + onTextFieldValueChange = { value -> + viewModel.setEvent(SearchUiEvent.OnSearchTextFieldChanged(value)) + }, modifier = modifier ) } diff --git a/app/src/main/java/org/sopt/and/presentation/search/SearchViewModel.kt b/app/src/main/java/org/sopt/and/presentation/search/SearchViewModel.kt index 97476a7..218345d 100644 --- a/app/src/main/java/org/sopt/and/presentation/search/SearchViewModel.kt +++ b/app/src/main/java/org/sopt/and/presentation/search/SearchViewModel.kt @@ -1,46 +1,50 @@ package org.sopt.and.presentation.search -import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update +import org.sopt.and.core.viewmodel.BaseViewModel import org.sopt.and.domain.entity.Program import org.sopt.and.domain.repository.PopularProgramRepository -import org.sopt.and.presentation.search.state.SearchUiState +import org.sopt.and.presentation.search.contract.SearchSideEffect +import org.sopt.and.presentation.search.contract.SearchUiEvent +import org.sopt.and.presentation.search.contract.SearchUiState import javax.inject.Inject @HiltViewModel class SearchViewModel @Inject constructor( private val popularProgramRepository: PopularProgramRepository -) : ViewModel() { - private var _uiState = MutableStateFlow(SearchUiState()) - val uiState = _uiState.asStateFlow() +) : BaseViewModel() { + override fun createInitialState(): SearchUiState = SearchUiState() - init { - getPopularList() - } + override suspend fun handleEvent(event: SearchUiEvent) { + when (event) { + is SearchUiEvent.OnTabClicked -> { + setState { copy(selectedTabIndex = event.index) } + } - private fun getPopularList() = _uiState.update { currentState -> - currentState.copy( - popularSeries = popularProgramRepository.getPopularSeries(), - popularMovies = popularProgramRepository.getPopularMovies() - ) + is SearchUiEvent.OnSearchTextFieldChanged -> { + setState { copy(searchText = event.value) } + } + } } - fun getTabList(): List = - if (uiState.value.selectedTabIndex == 0) _uiState.value.popularSeries - else _uiState.value.popularMovies - - fun onTabClick(index: Int) = _uiState.update { currentState -> - currentState.copy( - selectedTabIndex = index - ) + fun getPopularList() { + setState { + copy( + popularSeries = popularProgramRepository.getPopularSeries(), + popularMovies = popularProgramRepository.getPopularMovies() + ) + } } - fun onSearchValueChange(value: String) = _uiState.update { currentState -> - currentState.copy( - searchText = value - ) + fun getTabList(): List { + val (tabList, toastMessage) = + if (uiState.value.selectedTabIndex == 0) { + Pair(currentState.popularSeries, "인기시리즈") + } else { + Pair(currentState.popularMovies, "인기 영화") + } + + setSideEffect(SearchSideEffect.ShowToast(toastMessage)) + return tabList } } \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/search/contract/SearchSideEffect.kt b/app/src/main/java/org/sopt/and/presentation/search/contract/SearchSideEffect.kt new file mode 100644 index 0000000..0beaa44 --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/search/contract/SearchSideEffect.kt @@ -0,0 +1,7 @@ +package org.sopt.and.presentation.search.contract + +import org.sopt.and.core.viewmodel.UiSideEffect + +sealed interface SearchSideEffect: UiSideEffect { + data class ShowToast(val message: String) : SearchSideEffect +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/search/contract/SearchUiEvent.kt b/app/src/main/java/org/sopt/and/presentation/search/contract/SearchUiEvent.kt new file mode 100644 index 0000000..e0a1177 --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/search/contract/SearchUiEvent.kt @@ -0,0 +1,8 @@ +package org.sopt.and.presentation.search.contract + +import org.sopt.and.core.viewmodel.UiEvent + +sealed class SearchUiEvent: UiEvent { + data class OnTabClicked(val index: Int): SearchUiEvent() + data class OnSearchTextFieldChanged(val value: String): SearchUiEvent() +} \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/search/state/SearchUiState.kt b/app/src/main/java/org/sopt/and/presentation/search/contract/SearchUiState.kt similarity index 70% rename from app/src/main/java/org/sopt/and/presentation/search/state/SearchUiState.kt rename to app/src/main/java/org/sopt/and/presentation/search/contract/SearchUiState.kt index fc703ae..a96ee3c 100644 --- a/app/src/main/java/org/sopt/and/presentation/search/state/SearchUiState.kt +++ b/app/src/main/java/org/sopt/and/presentation/search/contract/SearchUiState.kt @@ -1,5 +1,6 @@ -package org.sopt.and.presentation.search.state +package org.sopt.and.presentation.search.contract +import org.sopt.and.core.viewmodel.UiState import org.sopt.and.domain.entity.Program data class SearchUiState( @@ -7,7 +8,7 @@ data class SearchUiState( val popularMovies: List = emptyList(), val selectedTabIndex: Int = 0, val searchText: String = "" -) +): UiState From 98b232f8b07557a040b2da0186c51bccd86fb066 Mon Sep 17 00:00:00 2001 From: boiledegg Date: Fri, 13 Dec 2024 03:21:59 +0900 Subject: [PATCH 7/9] =?UTF-8?q?del/#14:=20=EB=AF=B8=EC=82=AC=EC=9A=A9=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/org/sopt/and/presentation/mypage/MyPageScreen.kt | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/src/main/java/org/sopt/and/presentation/mypage/MyPageScreen.kt b/app/src/main/java/org/sopt/and/presentation/mypage/MyPageScreen.kt index 9170d50..9d3a6e1 100644 --- a/app/src/main/java/org/sopt/and/presentation/mypage/MyPageScreen.kt +++ b/app/src/main/java/org/sopt/and/presentation/mypage/MyPageScreen.kt @@ -49,7 +49,6 @@ import org.sopt.and.presentation.mypage.component.ProfilePurchaseGroup import org.sopt.and.presentation.mypage.component.ProfileTopBar import org.sopt.and.presentation.mypage.contract.MyPageSideEffect import org.sopt.and.presentation.mypage.contract.MyPageUiEvent -import org.sopt.and.presentation.mypage.contract.MyPageUiState @Composable fun MyPageRoute( @@ -92,7 +91,6 @@ fun MyPageRoute( onProgramPress = { program -> viewModel.setEvent(MyPageUiEvent.OnStarredProgramPressed(program)) }, - uiState = uiState, starredPrograms = starredProgram ) @@ -149,7 +147,6 @@ fun MyPageRoute( @Composable private fun MyPageScreen( hobby: String, - uiState: MyPageUiState, starredPrograms: List, snackBarHost: SnackbarHostState, onLogoutButtonClick: () -> Unit, From 3b6de4f5bdcedff6725bf26370a0726ce1e649fe Mon Sep 17 00:00:00 2001 From: boiledegg Date: Fri, 13 Dec 2024 03:32:47 +0900 Subject: [PATCH 8/9] =?UTF-8?q?feat/#14:=20Home=20MVI=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sopt/and/presentation/home/HomeScreen.kt | 22 ++++++++--- .../and/presentation/home/HomeViewModel.kt | 39 +++++++++---------- .../presentation/home/contract/HomeUiEvent.kt | 7 ++++ .../home/{state => contract}/HomeUiState.kt | 5 ++- 4 files changed, 45 insertions(+), 28 deletions(-) create mode 100644 app/src/main/java/org/sopt/and/presentation/home/contract/HomeUiEvent.kt rename app/src/main/java/org/sopt/and/presentation/home/{state => contract}/HomeUiState.kt (72%) diff --git a/app/src/main/java/org/sopt/and/presentation/home/HomeScreen.kt b/app/src/main/java/org/sopt/and/presentation/home/HomeScreen.kt index ceeb80a..eee5ffd 100644 --- a/app/src/main/java/org/sopt/and/presentation/home/HomeScreen.kt +++ b/app/src/main/java/org/sopt/and/presentation/home/HomeScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -18,7 +19,8 @@ import org.sopt.and.presentation.home.component.HomeTopBar import org.sopt.and.presentation.home.component.HorizontalBannerPager import org.sopt.and.presentation.home.component.ProgramRow import org.sopt.and.presentation.home.component.RankedProgramRow -import org.sopt.and.presentation.home.state.HomeUiState +import org.sopt.and.presentation.home.contract.HomeUiEvent +import org.sopt.and.presentation.home.contract.HomeUiState @Composable fun HomeRoute( @@ -27,10 +29,16 @@ fun HomeRoute( ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + LaunchedEffect(true){ + viewModel.initializeHomeState() + } + HomeScreen( uiState = uiState, modifier = modifier, - onTabClick = viewModel::updateSelectedTabIndex + onTabClick = { index -> + viewModel.setEvent(HomeUiEvent.OnTabSelected(index)) + } ) } @@ -56,10 +64,12 @@ private fun HomeScreen( } item { - HorizontalBannerPager( - imageList = uiState.bannerImgList, - modifier = Modifier.wrapContentHeight() - ) + if(uiState.bannerImgList.isNotEmpty()) { + HorizontalBannerPager( + imageList = uiState.bannerImgList, + modifier = Modifier.wrapContentHeight() + ) + } RankedProgramRow( title = uiState.rankedSeries?.title.orEmpty(), diff --git a/app/src/main/java/org/sopt/and/presentation/home/HomeViewModel.kt b/app/src/main/java/org/sopt/and/presentation/home/HomeViewModel.kt index 51bf680..e574d61 100644 --- a/app/src/main/java/org/sopt/and/presentation/home/HomeViewModel.kt +++ b/app/src/main/java/org/sopt/and/presentation/home/HomeViewModel.kt @@ -1,34 +1,33 @@ package org.sopt.and.presentation.home -import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update +import org.sopt.and.core.viewmodel.BaseViewModel import org.sopt.and.domain.repository.RecommendationRepository -import org.sopt.and.presentation.home.state.HomeUiState +import org.sopt.and.presentation.home.contract.HomeUiEvent +import org.sopt.and.presentation.home.contract.HomeUiState import javax.inject.Inject @HiltViewModel class HomeViewModel @Inject constructor( private val recommendationRepository: RecommendationRepository -) : ViewModel() { - private var _uiState = MutableStateFlow(HomeUiState()) - val uiState = _uiState.asStateFlow() +) : BaseViewModel() { + override fun createInitialState(): HomeUiState = HomeUiState() - init { - initializeHomeState() + override suspend fun handleEvent(event: HomeUiEvent) { + when(event) { + is HomeUiEvent.OnTabSelected -> { + setState { copy(selectedTabIndex = event.index) } + } + } } - private fun initializeHomeState() = _uiState.update { currentState -> - currentState.copy( - bannerImgList = recommendationRepository.getBannerImages(), - recommendations = recommendationRepository.getRecommendations(), - rankedSeries = recommendationRepository.getMostPopularSeries() - ) - } - - fun updateSelectedTabIndex(index: Int) = _uiState.update { currentState -> - currentState.copy(selectedTabIndex = index) + fun initializeHomeState() { + setState { + copy( + bannerImgList = recommendationRepository.getBannerImages(), + recommendations = recommendationRepository.getRecommendations(), + rankedSeries = recommendationRepository.getMostPopularSeries() + ) + } } } \ No newline at end of file diff --git a/app/src/main/java/org/sopt/and/presentation/home/contract/HomeUiEvent.kt b/app/src/main/java/org/sopt/and/presentation/home/contract/HomeUiEvent.kt new file mode 100644 index 0000000..9bd9b36 --- /dev/null +++ b/app/src/main/java/org/sopt/and/presentation/home/contract/HomeUiEvent.kt @@ -0,0 +1,7 @@ +package org.sopt.and.presentation.home.contract + +import org.sopt.and.core.viewmodel.UiEvent + +sealed class HomeUiEvent: UiEvent { + data class OnTabSelected(val index: Int): HomeUiEvent() +} diff --git a/app/src/main/java/org/sopt/and/presentation/home/state/HomeUiState.kt b/app/src/main/java/org/sopt/and/presentation/home/contract/HomeUiState.kt similarity index 72% rename from app/src/main/java/org/sopt/and/presentation/home/state/HomeUiState.kt rename to app/src/main/java/org/sopt/and/presentation/home/contract/HomeUiState.kt index a92c816..44c0496 100644 --- a/app/src/main/java/org/sopt/and/presentation/home/state/HomeUiState.kt +++ b/app/src/main/java/org/sopt/and/presentation/home/contract/HomeUiState.kt @@ -1,5 +1,6 @@ -package org.sopt.and.presentation.home.state +package org.sopt.and.presentation.home.contract +import org.sopt.and.core.viewmodel.UiState import org.sopt.and.domain.entity.HomeRecommendation data class HomeUiState( @@ -7,4 +8,4 @@ data class HomeUiState( val bannerImgList: List = emptyList(), val recommendations: List = emptyList(), val rankedSeries: HomeRecommendation? = null -) +) : UiState From 0016a1dd534aaf3a5daad4d54d1ceb101c423f1d Mon Sep 17 00:00:00 2001 From: boiledegg Date: Fri, 13 Dec 2024 03:34:01 +0900 Subject: [PATCH 9/9] =?UTF-8?q?del/#14:=20=EA=B8=B0=EC=A1=B4=20uistate=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/org/sopt/and/core/state/UiState.kt | 15 --------------- 1 file changed, 15 deletions(-) delete mode 100644 app/src/main/java/org/sopt/and/core/state/UiState.kt 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 e330099..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 msg: String, - ) : UiState -} \ No newline at end of file