How to make LiveData observe only once in Android [Kotlin] - android

I have a situation where I want the livedata to be observed only once in the app. The problem is that I am working on the authentication for an app using some Node.js backend.
As I am sending the values to receive the response from the backend it's working fine till now. I observe that response and based on that I make changes to my fragment ( that is if the response received is true then move to next fragment, otherwise if it is false show a toast message ).
Now the problem is that :
Case 1: I opened the app, entered the right credentials and pressed the button, received true response from the server and goes to the next fragment.
Case 2: I opened the app, but entered the wrong credentials, I received a false from server and based on that the Toast is shown.
Case 3 (The issue): I opened the app, entered the wrong credentials and then without closing the fragment screen entered the right credentials by editing them, the app crashes and at the same time I receive multiple responses from the server via LiveData.
My observation: Looking more into that I found that the LiveData is attached to the fragment/activity and therefore it shows the last state. So as in case 3 the the last state was receiving the false value from backend it was used again and we were shown the error instead of going to the next screen.
Can anyone guide me how to solve this. Thanks
Some code that might be needed:
binding.btnContinue.setOnClickListener {
val number = binding.etMobileNumber.text.toString().toLong()
Timber.d("Number: $number")
authRiderViewModel.authDriver(number)
checkNumber()
}
Function which checks the number :
private fun checkNumber() {
authRiderViewModel.response.observe(viewLifecycleOwner, Observer {
Timber.d("Response: $it")
if (it!!.success == true) {
val action = LoginFragmentDirections.actionLoginFragmentToOtpFragment()
findNavController().navigate(action)
Timber.d("${it.message}")
} else {
Toast.makeText(requireContext(), "Number not registered", Toast.LENGTH_SHORT).show()
binding.etMobileNumber.setText("")
}
})
}
ViewModel code:
private val _response = MutableLiveData<AuthResponse>()
val response: LiveData<AuthResponse>
get() = _response
fun authDriver(number: Long) = viewModelScope.launch {
Timber.d("Number: $number")
myRepo.authDriver(number).let {
_response.postValue(it)
}
}
P.S I have tried using something called SingleLiveEvent but it doesn't seem to work.

I would create a separate class that tracks the UI state you need and update it when the state is consumed. Something like the following. I don't really know what the parameter is for authDriver, so this is a more generic example.
sealed interface AuthState {
object NotYetRequested: AuthState
object AwaitingResponse: AuthState
class ResponseReceived(val response: AuthResponse): AuthState {
var isHandled = false
private set
fun markHandled() {
isHandled = true
}
}
}
// In ViewModel:
private val _authState = MutableLiveData<AuthState>().also {
it.value = AuthState.NotYetRequested
}
val authState: LiveData<AuthState> get() = _authState
fun requestAuthentication() = viewModelScope.launch {
_authState.value = AuthState.AwaitingResponse
val response = myRepo.authenticate()
_authState.value = AuthState.ResponseReceived(response)
}
// In Fragment:
viewModel.authState.observe(viewLifecycleOwner) { authState ->
when (authState) {
AuthState.NotYetRequested -> ShowUiRequestingAuthentication()
AuthStateAwaitingResponse -> ShowIndeterminateProgressUi()
is AuthStateResponseReceived -> when {
authState.isHandled -> {} // do nothing? depends on your setup, might need to navigate to next screen if handled response is successful
authState.response.isSuccessful -> {
goToNextScreen()
authState.markHandled()
}
else -> {
showErrorToast()
ShowUiRequestingAuthentication()
authState.markHandled()
}
}
}
}

Related

Navigation is being called every time in Jetpack Compose

I'm implementing registration in my application and, after filling in the respective fields, I click on a button that will make a registration request to the API. Meanwhile, I place a Loading View and when I receive the successful response, I execute the navigation to the OnBoarding screen. The issue is that the navController is always running the navigation and doing the navigation and popUp several times, when it should only do it once. I always get this warning on logs: Ignoring popBackStack to destination 29021787 as it was not found on the current back stack and I am not able to do any click or focus in the OnBoardingScreen.
My code:
val uiState by registerViewModel.uiState.collectAsState()
when (uiState) {
is BaseViewState.Data -> {
navController.navigate(NavigationItem.OnBoarding.route) {
popUpTo(NavigationItem.Register.route) {
inclusive = true
}
}
}
is BaseViewState.Loading -> LoadingView()
is BaseViewState.Error -> BannerView()
else -> {}
}
On button click I call the viewModel like this:
registerViewModel.onTriggerEvent(
RegisterEvent.CreateUser(
usernameInputState.value.text,
emailInputState.value.text,
passwordInputState.value.text
)
)
And, in ViewModel, I do my request like this:
override fun onTriggerEvent(eventType: RegisterEvent) {
when (eventType) {
is RegisterEvent.CreateUser -> createUser(eventType.username, eventType.email, eventType.password)
}
}
private fun createUser(username: String, email: String, password: String) = safeLaunch {
setState(BaseViewState.Loading)
execute(createUser(CreateUser.Params(username, email, password))) {
setState(BaseViewState.Data(RegisterViewState(it)))
}
}
I guess it should be caused by recomposition, because I put a breakpoint on first when scenario and it stops here multiple times, but only one on ViewModel. How can I fix this?
This issue is here
is BaseViewState.Data -> {
navController.navigate(NavigationItem.OnBoarding.route) {
popUpTo(NavigationItem.Register.route) {
inclusive = true
}
}
}
Every time you call navController.navigate NavHost will keep on passing through this block, executing an endless loop.
I suggest having the navigate call from a LaunchedEffect with a key (like this),
LaunchedEffect(key1 = "some key") {
navController.navigate(…)
}
or creating a separate structure namely "Events" where they are emitted as SharedFlow and observed via a Unit keyed LaunchedEffect
LaunchedEffect(Unit) {
viewModel.event.collectLatest {
when (it) {
is UiEvent.Navigate -> {
navController.navigate(…)
}
}
}
}

Why does Flow (kotlinx.coroutines.flow) not working with Retry even though I manually set as null in Android?

So basically, on the snackbar action button, I want to Retry API call if user click on Retry.
I have used core MVVM architecture with Flow. I even used Flow between Viewmodel and view as well. Please note that I was already using livedata between view and ViewModel, but now the requirement has been changed and I have to use Flow only. Also I'm not using and shared or state flow, that is not required.
Code:
Fragment:
private fun apiCall() {
viewModel.fetchUserReviewData()
}
private fun setObservers() {
lifecycleScope.launch {
viewModel.userReviewData?.collect {
LogUtils.d("Hello it: " + it.code)
setLoadingState(it.state)
when (it.status) {
Resource.Status.ERROR -> showErrorSnackBarLayout(-1, it.message, {
// Retry action button logic
viewModel.userReviewData = null
apiCall()
})
}
}
}
Viewmodel:
var userReviewData: Flow<Resource<ReviewResponse>>? = emptyFlow<Resource<ReviewResponse>>()
fun fetchUserReviewData() {
LogUtils.d("Hello fetchUserReviewData: " + userReviewData)
userReviewData = flow {
emit(Resource.loading(true))
repository.getUserReviewData().collect {
emit(it)
}
}
}
EDIT in ViewModel:
// var userReviewData = MutableStateFlow<Resource<ReviewResponse>>(Resource.loading(false))
var userReviewData = MutableSharedFlow<Resource<ReviewResponse>>()
fun fetchUserReviewData() {
viewModelScope.launch {
userReviewData.emit(Resource.loading(true))
repository.getUserReviewData().collect {
userReviewData.emit(it)
}
}
}
override fun onCreate() {}
}
EDIT in Activity:
private fun setObservers() {
lifecycleScope.launchWhenStarted {
viewModel.userReviewData.collect {
setLoadingState(it.state)
when (it.status) {
Resource.Status.SUCCESS ->
if (it.data != null) {
val reviewResponse: ReviewResponse = it.data
if (!AppUtils.isNull(reviewResponse)) {
setReviewData(reviewResponse.data)
}
}
Resource.Status.ERROR -> showErrorSnackBarLayout(it.code, it.message) {
viewModel.fetchUserReviewData()
}
}
}
}
}
Now, I have only single doubt, should I use state one or shared one? I saw Phillip Lackener video and understood the difference, but still thinking what to use!
The thing is we only support Portrait orientation, but what in future requirement comes? In that case I think I have to use state one so that it can survive configuration changes! Don't know what to do!
Because of the single responsibility principle, the ViewModel alone should be updating its flow to show the latest requested data, rather than having to cancel the ongoing request and resubscribe to a new one from the Fragment side.
Here is one way you could do it. Use a MutableSharedFlow for triggering fetch requests and flatMapLatest to restart the downstream flow on a new request.
A Channel could also be used as a trigger, but it's a little more concise with MutableSharedFlow.
//In ViewModel
private val fetchRequest = MutableSharedFlow<Unit>(replay = 1, BufferOverflow.DROP_OLDEST)
var userReviewData = fetchRequest.flatMapLatest {
flow {
emit(Resource.loading(true))
emitAll(repository.getUserReviewData())
}
}.shareIn(viewModelScope, SharingStarted.WhlieSubscribed(5000), 1)
fun fetchUserReviewData() {
LogUtils.d("Hello fetchUserReviewData: " + userReviewData)
fetchRequest.tryEmit(Unit)
}
Your existing Fragment code above should work with this, but you no longer need the ?. null-safe call since the flow is not nullable.
However, if the coroutine does anything to views, you should use viewLifecycle.lifecycleScope instead of just lifecycleScope.

StateFlow don't emit when the new value same last value

I have a login form. I use StateFlow to send LoginResult (after call API) from ViewModel to Activity. In the Activity, I will show an error dialog if login failed.
It works well for the first time but from the second time I login failed, the error dialog won't show again. I tested both .value and .emit on StateFlow
private val _loginResult = MutableStateFlow(LoginResult())
val loginResult: StateFlow<LoginResult> = _loginResult
fun login(email: String, password: String) {
viewModelScope.launch {
when (val result = loginRepository.login(email, password)) {
is Result.Fail-> {
_loginResult.value = LoginResult(error = "Login failed")
// _loginResult.emit(LoginResult(error = "Login failed")) same issue
}
...
}
}
}
For this case, I use SharedFlow so my Activity still able to collect the new value even it same the last value
private val _loginResult = MutableSharedFlow<LoginResult>()
val loginResult: SharedFlow<LoginResult> = _loginResult
...
_loginResult.emit(LoginResult(error = "Login failed"))
Another possible solution but it's not good is change my current data class LoginResult(...) to class LoginResult. Then every new instance of LoginResult will different
That's by default. What you could do is to emit one value when the login is in progress, before emitting the result. That way you would have a sequence like:
in_progress -> error -> in_progress -> error
Apps usually display a spinner or something when the in_progress or loading or whatever is emitted.

android - kotlin - mvvm - posting data to webservice

I want to post some data to webservice and get the result . this is my code :
fab.setOnClickListener {
viewModel.newBimeGozar(name)
.observe(this#BimeGozarAct, Observer {
dialogbimegozarNew?.hidePg()
})
}
this is my viewmodel :
class BimeNewViewModel:ViewModel() {
private val repository=BimeNewRepository()
fun newBimeGozar(name: String): MutableLiveData<StatModel> {
return repository.newBimeGozar(name)
}
this is my repository :
fun newBimeShode(
name: String
): MutableLiveData<StatModel> {
scope.launch {
val request = api.newBimeShode(name)
withContext(Dispatchers.Main) {
try {
val response = request.await()
regBimeshodeLiveData.value = response
} catch (e: HttpException) {
Log.v("this", e.message);
} catch (e: Throwable) {
Log.v("this", e.message);
}
}
}
return regBimeshodeLiveData;
}
it works fine but there is a problem . I think the observer keeps running and if the result's answer is an error and user press fab button again , it creates a new observer and after this , it returns two value , the first value is the first run and the second value is the second run
how can I fix this ? what is the correct way for submitting forms ?
If your problem is because of LiveData, you should use SingleLiveEvent like as follow
// For first article
val _liveData = MutableLiveData<Event<StatModel>>()
// For second article
val _liveData = SingleLiveEvent<StatModel>()
If you do not know SingleLiveEvent, you can find it here and here.
If your problem is because of your ui element, I think the best solution is to disable the submit button after submitting for the first time.

Wait for coroutine to finish to handle user click

I'm making a system of "sessions" where the user can launch, finish and view his session.
The user go through a first fragment to create his session and then go into a fragment "in session".
If he return to the main menu before finishing his session, I want him to go directly to "in session" without going through the "new session" fragment.
All session data are stored into a local database and I use Kotlin coroutines to fetch data from the db (see code example below)
It's my first time using coroutine, and I will admit it's a bit fuzzy
for me, all the help is welcome
The problem is that when the user press the bouton to navigate, the coroutine finish after the verification to see if there is a current session, that lead to verify a null object or the previous session of the current session, and so navigate to a the "new session" fragment
What I need is a way to wait for the coroutine to finish and then
handle the button click
All the code wrote here is contain inside inside the viewModel.
This is how I setup the Job/Scope
private var viewModelJob = Job()
private val uiScope = CoroutineScope(Dispatchers.Main + viewModelJob)
And this is how I launch the coroutine:
private fun initializeLastSession() {
uiScope.launch {
lastSession.value = getLastSessionFromDatabase()
}
}
private suspend fun getLastSessionFromDatabase(): Session? {
return withContext(Dispatchers.IO) {
var session = database.getLastSession()
session
}
}
The verification is made inside this function
fun isSessionActive(): Boolean {
//Simplified
if (lastSession.value = null) {
return false
} else if (...) {
return true
} else {
return false
}
This last function "isSessionActive" is called from an if statement from the fragment itlsef, when the user press the navigation button.
If it's true then it navigate to "InSession", else in "newSession"
I've seen multiple way of waiting for a coroutine to finish but none match the way I launch it, and even less have a solution that has worked for me.
Would you allow me with a simple example unrelated to your code? But strongly related to the problem:
uiScope.launch{
withContext(Dispatchers.IO){
val dataFromDatabase = getSomeDataFromDatabase()
if (dataFromDatabase.notEmpty()){ //or something
withContext(Dispatchers.Main){
//send data to fragment here :)
}
}
}
}
EDIT:
Since you stated you are in the ViewModel, you don't need to return any value, you need to observe that changed value:
//on top of your ViewModel class:
val yourVariableName: MutableLiveData<Boolean> = MutableLiveData()
//than in your method:
uiScope.launch{
withContext(Dispatchers.IO){
val dataFromDatabase = getSomeDataFromDatabase()
if (dataFromDatabase.notEmpty()){ //or something
withContext(Dispatchers.Main){
if (lastSession.value = null) {
yourVariableName.value = false
} else if (...) {
yourVariableName.value = true
} else {
yourVariableName.value = false
}
}
}
}
}
And than in your fragment:
//after you have successfully instantiated the `ViewModel`:
mViewModel.yourVariableName.observe(this , Observer{ valueYouAreObserving->
// and here you have the value true ore false
Log.d("Tag", $valueYouAreObserving)
})

Categories

Resources