rickclephas / KMP-ObservableViewModel

Library to use AndroidX/Kotlin ViewModels with SwiftUI
MIT License
569 stars 29 forks source link

Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates. #58

Closed sangampokharel closed 6 months ago

sangampokharel commented 6 months ago
Screenshot 2024-02-14 at 1 18 28 PM
rickclephas commented 6 months ago

Hi. Could you possibly share the relevant code or a reproduction sample?

sangampokharel commented 6 months ago

yeah sure, The problem i am facing right now is that state is not being observed contiously in IOS its fluctuating sometimes it works as expected and sometimes it doesnot.Example code of login/logout senario when login is pressed sometimes i dont see loading, or sometimes the loginstate is not updating something like that. Please help !

I guess its because of that warning Publishsing the UI changes from background thread

Android:

BaseViewModel

open class BaseKMMViewModel : KMMViewModel() { private val _snackBarVisibleState = MutableStateFlow(viewModelScope, false) val snackBarVisibleState = _snackBarVisibleState.asStateFlow()

private val _showLoading = MutableStateFlow(viewModelScope, false)
val showLoading = _showLoading.asStateFlow()

private val _showLoadingDialog = MutableStateFlow(viewModelScope, false)
val showLoadingDialog = _showLoadingDialog.asStateFlow()

private val _showMessage = MutableStateFlow(viewModelScope, "")
val showMessage = _showMessage.asStateFlow()

private val _showMessageDialog = MutableStateFlow(viewModelScope, "")
val showMessageDialog = _showMessageDialog.asStateFlow()

private val _logout = MutableStateFlow(viewModelScope, false)
val logout = _logout.asStateFlow()

fun hideSnackBar() {
    _snackBarVisibleState.value = false
}

fun showSnackBar() {
    _snackBarVisibleState.value = true
}

fun showMessage(msg: String) {
    showSnackBar()
    _showMessage.value = msg
}

private fun showMessageDialog(msg: String?) {
    _showMessageDialog.value = msg ?: ApiConstants.defaultErrorMsg
}

fun hideMessageDialog() {
    _showMessageDialog.value = ""
}

fun showLoading() {
    _showLoading.value = true
}

fun hideLoading() {
    _showLoading.value = false
}

fun showLoadingDialog() {
    _showLoadingDialog.value = true
}

fun hideLoadingDialog() {
    _showLoadingDialog.value = false
}

fun showError(error: Exception?) {
    when (error) {

        is UnAuthorizedError -> {
            _logout.value=true
        }

        is IOException -> {
            showMessageDialog("No internet")
        }

        else ->
            showMessageDialog(error?.message ?: ApiConstants.defaultErrorMsg)
    }

}

suspend fun getToken(): String? {
    return if (isTokenValid())
        loginInfo?.accessToken
    else {
       getNewToken()
    }
}

private suspend fun getNewToken(): String? {
    return coroutineScope {
        when (val result =
            LoginRepository.doLogin(
                Login(
                    grantType = ApiConstants.refreshToken,
                    type= ApiConstants.Driver,
                    refreshToken = loginInfo?.refreshToken
                )
            )) {
            is Response.Success<*> -> {
                val loginResponse = result.data as? LoginResponse
                loginResponse?.loginTime = Clock.System.now().toEpochMilliseconds()
                loginInfo = loginResponse
                loginInfo?.accessToken
            }

            is Response.Error<*> -> {
                hideLoading()
                _logout.value=true
                null
            }
        }
    }
}

private fun isTokenValid() =
    (((loginInfo?.expiresIn?.times(1000))?.plus(loginInfo?.loginTime?: 0L))
        ?: 0L) > Clock.System.now()
        .toEpochMilliseconds()

}

=================

LoginViewModel

class LoginViewModel : BaseKMMViewModel() { private val _loginState = MutableStateFlow(viewModelScope, false) var loginState = _loginState.asStateFlow() private fun validateLoginData(login: Login?): Boolean { if (login?.account.isNullOrEmpty() && login?.password.isNullOrEmpty()) { showSnackBar() showMessage("Phone Number and Email shouldn't be empty.") hideLoading() return false }

    if (login?.account.isNullOrEmpty()) {
        showSnackBar()
        showMessage("Phone Number shouldn't be empty.")
        hideLoading()
        return false
    }

    if (login?.password.isNullOrEmpty()) {
        showSnackBar()
        showMessage("Password shouldn't be empty.")
        hideLoading()
        return false
    }

    if ((login?.account?.length ?: 0) < 10) {
        showSnackBar()
        showMessage("Phone Number shouldn't be less than 10 digit.")
        hideLoading()
        return false
    }

    if (login?.account?.startsWith("9") == false) {
        showSnackBar()
        showMessage("The Phone Number format is invalid.")
        hideLoading()
        return false
    }

    return true
}

fun doLogin(login: Login?) {
    if (validateLoginData(login)) {
        viewModelScope.coroutineScope.launch(Dispatchers.IO) {
            showLoading()
            when (val result = LoginRepository.doLogin(login)) {
                is Response.Success<*> -> {
                    hideLoading()
                    val loginResponse=result.data as? LoginResponse
                    loginResponse?.loginTime=Clock.System.now().toEpochMilliseconds()
                    KeyValueStorageImp.loginRequest = login
                    KeyValueStorageImp.loginInfo = loginResponse
                    _loginState.value = true
                }

                is Response.Error<*> -> {
                    hideLoading()
                    showError(result.error as Exception?)
                }
            }
        }
    }
}

}

========== Swift UI = ========= I am observing like this

import SwiftUI import shared import KMMViewModelSwiftUI import SwiftUISnackbar import FirebaseDatabaseInternal struct LoginView: View { @EnvironmentObject var appStateVM:AppStateViewModel @StateViewModel var viewModel = LoginViewModel() @StateViewModel var profileVM = ProfileViewModel()

private var isLoggedIn: Binding<Bool> {
    Binding { viewModel.loginState.value as! Bool } set: { _ in        }
}

private var isLoading:Binding<Bool> {
    Binding { viewModel.showLoading.value as! Bool } set: { _ in }
}

private var isProfileLoading:Binding<Bool>{
    Binding {
        profileVM.showLoading.value as! Bool
    } set: { _ in }
}

private var profileStateData:Binding<Profile>{
    Binding {
        profileVM.profileDetailState.value as? Profile ?? Profile(id: nil, firstName: nil, lastName: nil, email: nil, username: nil, phoneNumber: nil, imageName: nil, totalDelivery: nil, licenseNumber: nil, address: nil)
    } set: { _ in }
}

// only with error
private var showMessage:Binding<String> {
    Binding { viewModel.showMessage.value as! String } set: { _ in }
}

private var showMessageDialog:Binding<String> {
    Binding { viewModel.showMessageDialog.value as! String } set: { _ in  }
}

private var snackBarVisible :Binding<Bool> {
    Binding { viewModel.snackBarVisibleState.value as! Bool } set: { _ in  }
}

private func handleValidation() {
    viewModel.doLogin(login: Login(account: phone, password: password, grantType: "password", type: "driver", refreshToken: nil))
}

var body: some View {
   ZStack{
   // other view here
   CustomButton(title: "login", handleAction: {
                    handleValidation()
                })
                .padding([.horizontal],24)
   }

.onChange(of: showMessageDialog.wrappedValue) { newValue in print("Error (newValue)") if !newValue.isEmpty { isAlertShown = true

        }
    }
    .onChange(of:profileStateData.wrappedValue){ newValue in
        print("is logged In \(newValue)")
        // store it to fireabse
        let userId = newValue.id ?? 0
        saveDataToFirebase(userId: Int(truncating: userId))
    }
    .onChange(of:viewModel.loginState.value as! Bool){ newValue in
        print("is logged In \(newValue)")
        if newValue {
            profileVM.getProfile()
        }

    }
}
rickclephas commented 6 months ago

Thanks that helps a lot! In the doLogin function you are launching a job on the IO dispatcher:

viewModelScope.coroutineScope.launch(Dispatchers.IO)

All the state updates inside that job won't be performed on the main thread.

Please try and move the dispatcher to the LoginRepository instead.

sangampokharel commented 6 months ago

Thank you so much...It fixed the issue !