TLDR: How could I properly implement an MVVM architecture with LiveData?
I have a fragment class that observe a viewModel exposed livedata:
viewModel.loginResultLiveData.observe
class LoginFragment : Fragment() {
private lateinit var binding: FragmentLoginBinding
private val viewModel by fragmentScopedViewModel { injector.loginViewModel }
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
binding = FragmentLoginBinding.inflate(inflater, container, false)
val username = binding.loginInputField.toString()
val password = binding.passwordInputField.toString()
binding.loginSignInButton.setOnClickListener { viewModel.login(
username,
password
) }
viewModel.loginResultLiveData.observe(viewLifecycleOwner){
when(it){
is LoginResult.Success -> doSmth()
}
}
return binding.root
}
}
View model class simply ask for a mapped livedata object.
class LoginViewModel #Inject internal constructor(
private val loginUseCase: LoginUseCase
) : ViewModel() {
lateinit var loginResultLiveData: MutableLiveData<LoginResult>
fun login(username: String, password: String) {
loginResultLiveData = loginUseCase.login(username, password)
}
}
Model uses use case, to map a result from the original format and also would map errors:
class LoginUseCase #Inject internal constructor(
private val authRepository: AuthEmailRepository
) {
var loginResultLiveData = MutableLiveData<LoginResult>()
fun login(userName: String, password: String): MutableLiveData<LoginResult> {
authRepository.login(userName, password)
.addOnCompleteListener {
if (it.isSuccessful) {
loginResultLiveData.postValue(LoginResult.Success)
} else {
loginResultLiveData.postValue(LoginResult.Fail(it.exception.toString()))
}
}
return loginResultLiveData
}
}
The problem is that only after loginSignInButtonis clicked, the model creates a liveData object. But I'm starting to observe this object immediately after onClickListener is set. Also each time a button is clicked, this would create a new instance of viewModel.loginResultLiveData, which doesn’t make sense.
binding.loginSignInButton.setOnClickListener { viewModel.login(
username,
password
) }
viewModel.loginResultLiveData.observe(viewLifecycleOwner){
when(it){
is LoginResult.Success -> doSmth()
}
}
How could I properly implement MVVM architecture with LiveData in this case?
I could also move logic I now have in LoginUseCaseto ModelView and then have something like this, which avoids the problem described before. But then I cannot delegate mapping/error handling to use case.
class LoginViewModel #Inject internal constructor(
private val loginUseCase: LoginUseCase
) : ViewModel() {
val loginResult: MutableLiveData<LoginResult> = MutableLiveData()
fun login(username: String, password: String) = loginUseCase.login(username, password)
.addOnCompleteListener {
if (it.isSuccessful) {
loginResult.postValue(LoginResult.Success)
} else {
loginResult.postValue(LoginResult.Fail(it.exception.toString()))
}
}
}
You are trying to observe a mutable LiveData that is only initialized after the onClickListener so you won't get it to work, also you have a lateinit property that is only initialized if you call the login method which will throw an exception.
To solve your problem you can have a MediatorLiveData that will observe your other live data and pass the result back to your fragment observer.
You can try the following:
class LoginViewModel #Inject internal constructor(
private val loginUseCase: LoginUseCase
) : ViewModel() {
private var _loginResultLiveData = MediatorLiveData<LoginResult>()
val loginResultLiveData: LiveData<LoginResult> = _loginResultLiveData
fun login(username: String, password: String) {
val loginUseCaseLiveData = loginUseCase.login(username, password)
_loginResultLiveData.addSource(loginUseCaseLiveData) {
_loginResultLiveData.value = it
}
}
}
Related
I am new to dagger hilt (DI), And I Implement dagger hilt (DI) in my project. Now I am trying to inject the ViewModel. It works fine. But in the API result, I am using mutableLivedata for updating value from the view model to view with the observer. The observer listens and fetches the value works fine. But I read the observed data value; it cleared the value. I don't know why it happened. Can anyone help me to find this?
Login Fragment
#AndroidEntryPoint
class LoginFragment : BaseFragment<FragmentLoginBinding>(FragmentLoginBinding::inflate) {
private val viewModel by viewModels<LoginViewModel>()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewActionListeners()
setUpViewModelObserver()
}
private fun setUpViewModelObserver() {
viewModel.users.observe(viewLifecycleOwner) { response ->
AppLog.e("validateResponse", response.toString())
when (response.status) {
Status.SUCCESS -> {
binding.frmLayoutProgress.visible(false)
response.data?.let { users -> AppLog.e("AuthenticationMethod",users.clientResponse.authenticationMethod) }
}
Status.LOADING -> {
binding.frmLayoutProgress.visible(true)
}
Status.ERROR -> {
//Handle Error
binding.frmLayoutProgress.visible(false)
AppLog.e("Error:", response.message.toString())
}
}
}
}
}
If you comment the lineAppLog.e("AuthenticationMethod",users.clientResponse.authenticationMethod) }it return the value if uncomment response returns set to be null.
LoginViewModel
#HiltViewModel
class LoginViewModel #Inject constructor(
private val repository: LoginRepository,
private val networkHelper: NetworkHelper
) : ViewModel() {
val users: MutableLiveData<Resource<ResponseValidateUser>> = MutableLiveData()
fun loadValidateUser(email: String) {
viewModelScope.launch {
users.postValue(Resource.loading(null))
if (networkHelper.isNetworkConnected()) {
repository.getValidateUser(email).let {
if (it.isSuccessful) {
users.postValue(Resource.success(it.body()))
} else users.postValue(Resource.error(it.errorBody().toString(), null))
}
} else users.postValue(Resource.error("No internet connection", null))
}
}
}
LoginRepository
class LoginRepository #Inject constructor(private val apiHelper: AuthApiHelper) {
suspend fun getValidateUser(email: String) = apiHelper.getValidateUser(email)
}
AuthApiHelper
interface AuthApiHelper {
suspend fun getValidateUser(email: String): Response<ResponseValidateUser>
}
ResponseValidateUser
data class ResponseValidateUser(
#SerializedName("clientResponse") val clientResponse: ClientResponse,
#SerializedName("status") val status: String,
#SerializedName("errorMessage") val errorMessage: String
)
I am having a hard time to get my instrumented tests on Android.
Goal: Inject a mocked ViewModel during a Fragment Instrumented test.
Context:
My ViewModel is built using the Hilt Jetpack integrations and the #ViewModelInject annotation as the following:
class OverviewViewModel #ViewModelInject constructor(
private val coroutineScopeProvider: CoroutineScope?,
private val repository: Repository
): ViewModel() {
private val coroutineScope = getViewModelScope(coroutineScopeProvider)
val isLogged = repository.isLogged
val session = repository.session
fun logout() {
coroutineScope.launch {
repository.logout()
}
}
}
fun ViewModel.getViewModelScope(coroutineScope: CoroutineScope?) =
coroutineScope ?: this.viewModelScope
// Need to do that to be able to test the viewModel
#Module
#InstallIn(ActivityComponent::class)
object CoroutineModel {
#Provides
fun provideViewScopeModel(): CoroutineScope? = null
}
My Fragment uses the ViewModel as follows:
#AndroidEntryPoint
class OverviewFragment : Fragment() {
private val viewModel: OverviewViewModel by viewModels()
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
val binding = DataBindingUtil.inflate<FragmentOverviewBinding>(inflater,
R.layout.fragment_overview,container,false)
binding.viewModel = viewModel
binding.lifecycleOwner = viewLifecycleOwner
binding.loginButton.setOnClickListener {
val intent = SessionUtil.getAuthIntent()
startActivity(intent)
}
binding.logoutButton.setOnClickListener {
viewModel.logout()
}
return binding.root
}
}
What I have tried:
I would like to inject a mocked OverviewViewModel so I can isolate the Fragment test checking if the button click events are connected correctly with it.
Here is my test so far:
#HiltAndroidTest
#RunWith(AndroidJUnit4::class)
class OverviewFragmentTest {
val hiltRule = HiltAndroidRule(this)
#get: Rule
val testRules = RuleChain
.outerRule(hiltRule)
.around(ActivityTestRule(MainActivity::class.java))
val mockViewModel = mockkClass(OverviewViewModel::class)
val mockIsLogged = MutableLiveData<Boolean>()
#BindValue #JvmField
val viewModel: OverviewViewModel = mockViewModel
#Before
fun setup () {
clearAllMocks()
hiltRule.inject()
}
#Test
fun Given_nothing_When_clicking_login_button_Then_login_intent_triggers() {
every {viewModel.isLogged} returns mockIsLogged
mockIsLogged.postValue(false)
Intents.init()
every { SessionUtil.getAuthIntent() } returns Intent(Intent.ACTION_VIEW, Uri.parse("https://toto"))
launchFragmentInHiltContainer<OverviewFragment>()
onView(withId(R.id.login_button)).perform(click())
verify {
SessionUtil.getAuthIntent()
}
intended(
hasAction(Intent.ACTION_VIEW)
)
intended(
hasData("https://toto")
)
Intents.release()
}
#Test
fun Given_null_response_When_clicking_logout_button_Then_call_toaster() {
every {viewModel.isLogged} returns mockIsLogged
mockIsLogged.postValue(true)
launchFragmentInHiltContainer<OverviewFragment>()
onView(withId(R.id.logout_button)).perform(click())
verify {
mockViewModel.logout()
}
}
}
Actual: It seems that the fragment still uses the real ViewModel since even when posting a value (e.g. mockIsLogged.postValue(false)), the observer inside the Fragment still logs true (value coming from real model)
I am trying to use the Firebase API in my project but Transformations.map for the variable authenticationState in the View Model does not run. I have been following Google's tutorial here (link goes to the ViewModel of that project).
I want to be able to add the Transformations.map code to the FirebaseUserLiveData file later but I cant seem to figure out why it doesn't run.
FirebaseUserLiveData
class FirebaseUserLiveData: LiveData<FirebaseUser?>() {
private val firebaseAuth = FirebaseAuth.getInstance()
private val authStateListener = FirebaseAuth.AuthStateListener { firebaseAuth ->
value = firebaseAuth.currentUser
}
override fun onActive() {
firebaseAuth.addAuthStateListener { authStateListener }
}
override fun onInactive() {
firebaseAuth.removeAuthStateListener(authStateListener)
}
}
SearchMovieFragmentViewModel
class SearchMovieFragmentViewModel : ViewModel() {
enum class AuthenticationState {
AUTHENTICATED, UNAUTHENTICATED, INVALID_AUTHENTICATION
}
var authenticationState = Transformations.map(FirebaseUserLiveData()) { user ->
Log.d("TEST", "in the state function")
if (user != null) {
AuthenticationState.AUTHENTICATED
} else {
AuthenticationState.UNAUTHENTICATED
}
}
SearchMovieFragment
class SearchMovieFragment : Fragment(), MovieSearchItemViewModel {
companion object {
fun newInstance() = SearchMovieFragment()
}
private lateinit var searchMovieFragmentViewModel: SearchMovieFragmentViewModel
private lateinit var binding: SearchMovieFragmentBinding
private lateinit var movieRecyclerView: RecyclerView
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
binding = DataBindingUtil.inflate(inflater, R.layout.search_movie_fragment, container, false)
searchMovieFragmentViewModel = ViewModelProvider(this).get(SearchMovieFragmentViewModel::class.java)
binding.lifecycleOwner = this
binding.viewmodel = searchMovieFragmentViewModel
binding.signOutButton.setOnClickListener {
AuthUI.getInstance().signOut(requireContext())
}
searchMovieFragmentViewModel.authenticationState.observe(viewLifecycleOwner, Observer { state ->
when (state) {
AUTHENTICATED -> searchMovieFragmentViewModel.signedIn = View.VISIBLE
UNAUTHENTICATED -> searchMovieFragmentViewModel.signedIn = View.GONE
}
})
return binding.root
}
}
Should be .addAuthStateListener(authStateListener) instead of { authStateListener }
That is because you are not keeping the reference of FirebaseUserLiveData() once you start observing it like Transformations.map(FirebaseUserLiveData()) { user ->.
You have to have the reference of the Livedata you are mapping or transferring to another form of Livedata.
It is like a chain of observation, All LiveData in the chain should be observed or should have some kind of observer down the line, The main use-case is to transform some form of livedata to something you want, For Example:
class YourRepository{ // your repo, that connected to a network that keeps up to date some data
val IntegerResource: LiveData<Int> = SomeRetrofitInstance.fetchFromNetwork() //updating some resource from network
}
class YourViewModel{
val repository = YourRepository()
//this will start observe the repository livedata and map it to string resource
var StringResource: Livedata<String> = Transformations.map( repository.IntegerResource ) { integerValue ->
integerValue.toString()
}
My Point is you have to keep alive the LiveData you are transforming. Hope helped.
I have two LiveData, aMVoice1, and aMVoice2.
I hope to check if they are equal.
I know I need to use observe to get the value of a LiveData.
so I think isEqual = (mDetailViewModel.aMVoice1.value==mDetailViewMode2.aMVoice1.value ) is wrong.
But I think there are some problems with fun observeVoice(), how can I fix it?
class FragmentDetail : Fragment() {
private lateinit var binding: LayoutDetailBinding
private val mDetailViewModel by lazy {
...
}
var isEqual=false
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
...
binding.lifecycleOwner = this.viewLifecycleOwner
binding.aDetailViewModel=mDetailViewModel
isEqual = (mDetailViewModel.aMVoice1.value==mDetailViewMode2.aMVoice1.value ) // I don't think it's correct.
observeVoice()
return binding.root
}
fun observeVoice() {
mDetailViewModel.aMVoice1.observe(viewLifecycleOwner){value1->
isEqual = (value1==mDetailViewModel.aMVoice2.value) // mDetailViewModel.aMVoice2.value maybe null
}
}
}
class DetailViewModel(private val mDBVoiceRepository: DBVoiceRepository, private val voiceId1:Int,private val voiceId2:Int) : ViewModel() {
val aMVoice1=mDBVoiceRepository.getVoiceById(voiceId1)
val aMVoice2=mDBVoiceRepository.getVoiceById(voiceId2)
}
class DBVoiceRepository private constructor(private val mDBVoiceDao: DBVoiceDao){
fun getVoiceById(id:Int)=mDBVoiceDao.getVoiceById(id)
}
#Dao
interface DBVoiceDao{
#Query("SELECT * FROM voice_table where id=:id")
fun getVoiceById(id:Int):LiveData<MVoice>
}
data class MVoice(
#PrimaryKey (autoGenerate = true) #ColumnInfo(name = "id") var id: Int = 0,
var name: String = "",
var path: String = ""
)
Added Content
Is it Ok for the following code?
fun observeVoice() {
mDetailViewModel.aMVoice1.observe(viewLifecycleOwner){value1->
mDetailViewModel.aMVoice2.observe(viewLifecycleOwner){value2->
isEqual = (value1==value2)
}
}
}
According to the official documents, the best way to achieve a solution for such cases is to use MediatorLiveData as a LiveData merger. Using it, you can check the equality of values when a new value is posted on either of LiveDatas:
class DetailViewModel(...) : ViewModel() {
val areMVoicesEqual = MediatorLiveData<Boolean>().apply {
addSource(aMVoice1) { postValue(it == aMVoice2.value) }
addSource(aMVoice2) { postValue(it == aMVoice1.value) }
}
}
Then:
fun observeVoice() {
mDetailViewModel.areMVoicesEqual.observe(viewLifecycleOwner){ equality ->
// do whatever you want with `equality`
}
}
Note that Added Content snippet you mentioned is not correct. In fact, in this case, every time a value is being observed on aMVoice1, a new Observer starts to observe on aMVoice2 which is not right.
I am trying out Kotlin Coroutines and Flow for the first time and I am trying to reproduce a certain flow I use on Android with RxJava with an MVI-ish approach, but I am having difficulties getting it right and I am essentially stuck at this point.
The RxJava app looks essentially like this:
MainActivityView.kt
object MainActivityView {
sealed class Event {
object OnViewInitialised : Event()
}
data class State(
val renderEvent: RenderEvent = RenderEvent.None
)
sealed class RenderEvent {
object None : RenderEvent()
class DisplayText(val text: String) : RenderEvent()
}
}
MainActivity.kt
MainActivity has an instance of a PublishSubject with a Event type. Ie MainActivityView.Event.OnViewInitialised, MainActivityView.Event.OnError etc. The initial Event is sent in onCreate() via the subjects's .onNext(Event) call.
#MainActivityScope
class MainActivity : AppCompatActivity(R.layout.activity_main) {
#Inject
lateinit var subscriptions: CompositeDisposable
#Inject
lateinit var viewModel: MainActivityViewModel
#Inject
lateinit var onViewInitialisedSubject: PublishSubject<MainActivityView.Event.OnViewInitialised>
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setupEvents()
}
override fun onDestroy() {
super.onDestroy()
subscriptions.clear()
}
private fun setupEvents() {
if (subscriptions.size() == 0) {
Observable.mergeArray(
onViewInitialisedSubject
.toFlowable(BackpressureStrategy.BUFFER)
.toObservable()
).observeOn(
Schedulers.io()
).compose(
viewModel()
).observeOn(
AndroidSchedulers.mainThread()
).subscribe(
::render
).addTo(
subscriptions
)
onViewInitialisedSubject
.onNext(
MainActivityView
.Event
.OnViewInitialised
)
}
}
private fun render(state: MainActivityView.State) {
when (state.renderEvent) {
MainActivityView.RenderEvent.None -> Unit
is MainActivityView.RenderEvent.DisplayText -> {
mainActivityTextField.text = state.renderEvent.text
}
}
}
}
MainActivityViewModel.kt
These Event's are then picked up by a MainActivityViewModel class which is invoked by .compose(viewModel()) which then transform the received Event into a sort of a new State via ObservableTransformer<Event, State>. The viewmodel returns a new state with a renderEvent in it, which can then be acted upon in the MainActivity again via render(state: MainActivityView.State)function.
#MainActivityScope
class MainActivityViewModel #Inject constructor(
private var state: MainActivityView.State
) {
operator fun invoke(): ObservableTransformer<MainActivityView.Event, MainActivityView.State> = onEvent
private val onEvent = ObservableTransformer<MainActivityView.Event,
MainActivityView.State> { upstream: Observable<MainActivityView.Event> ->
upstream.publish { shared: Observable<MainActivityView.Event> ->
Observable.mergeArray(
shared.ofType(MainActivityView.Event.OnViewInitialised::class.java)
).compose(
eventToViewState
)
}
}
private val eventToViewState = ObservableTransformer<MainActivityView.Event, MainActivityView.State> { upstream ->
upstream.flatMap { event ->
when (event) {
MainActivityView.Event.OnViewInitialised -> onViewInitialisedEvent()
}
}
}
private fun onViewInitialisedEvent(): Observable<MainActivityView.State> {
val renderEvent = MainActivityView.RenderEvent.DisplayText(text = "hello world")
state = state.copy(renderEvent = renderEvent)
return state.asObservable()
}
}
Could I achieve sort of the same flow with coroutines/Flow/Channels? Possibly a bit simplified even?
EDIT:
I have since found a solution that works for me, I haven't found any issues thus far. However this solution uses ConflatedBroadcastChannel<T> which eventually will be deprecated, it will likely be possible to replace it with (at the time of writing) not yet released SharedFlow api (more on that here.
The way it works is that the Activity and viewmodel shares
a ConflatedBroadcastChannel<MainActivity.Event> which is used to send or offer events from the Activity (or an adapter). The viewmodel reduce the event to a new State which is then emitted. The Activity is collecting on the Flow<State> returned by viewModel.invoke(), and ultimately renders the emitted State.
MainActivityView.kt
object MainActivityView {
sealed class Event {
object OnViewInitialised : Event()
data class OnButtonClicked(val idOfItemClicked: Int) : Event()
}
data class State(
val renderEvent: RenderEvent = RenderEvent.Idle
)
sealed class RenderEvent {
object Idle : RenderEvent()
data class DisplayText(val text: String) : RenderEvent()
}
}
MainActivity.kt
class MainActivity : AppCompatActivity(R.layout.activity_main) {
#Inject
lateinit var viewModel: MainActivityViewModel
#Inject
lateinit eventChannel: ConflatedBroadcastChannel<MainActivityView.Event>
private var isInitialised: Boolean = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
init()
}
private fun init() {
if (!isInitialised) {
lifecycleScope.launch {
viewModel()
.flowOn(
Dispatchers.IO
).collect(::render)
}
eventChannel
.offer(
MainActivityView.Event.OnViewInitialised
)
isInitialised = true
}
}
private suspend fun render(state: MainActivityView.State): Unit =
when (state.renderEvent) {
MainActivityView.RenderEvent.Idle -> Unit
is MainActivityView.RenderEvent.DisplayText ->
renderDisplayText(text = state.renderEvent.text)
}
private val renderDisplayText(text: String) {
// render text
}
}
MainActivityViewModel.kt
class MainActivityViewModel constructor(
private var state: MainActivityView.State = MainActivityView.State(),
private val eventChannel: ConflatedBroadcastChannel<MainActivityView.Event>,
) {
suspend fun invoke(): Flow<MainActivityView.State> =
eventChannel
.asFlow()
.flatMapLatest { event: MainActivityView.Event ->
reduce(event)
}
private fun reduce(event: MainActivityView.Event): Flow<MainActivityView.State> =
when (event) {
MainActivityView.Event.OnViewInitialised -> onViewInitialisedEvent()
MainActivityView.Event.OnButtonClicked -> onButtonClickedEvent(event.idOfItemClicked)
}
private fun onViewInitialisedEvent(): Flow<MainActivityView.State> = flow
val renderEvent = MainActivityView.RenderEvent.DisplayText(text = "hello world")
state = state.copy(renderEvent = renderEvent)
emit(state)
}
private fun onButtonClickedEvent(idOfItemClicked: Int): Flow<MainActivityView.State> = flow
// do something to handle click
println("item clicked: $idOfItemClicked")
emit(state)
}
}
Similiar questions:
publishsubject-with-kotlin-coroutines-flow
Your MainActivity can look something like this.
#MainActivityScope
class MainActivity : AppCompatActivity(R.layout.activity_main) {
#Inject
lateinit var subscriptions: CompositeDisposable
#Inject
lateinit var viewModel: MainActivityViewModel
#Inject
lateinit var onViewInitialisedChannel: BroadcastChannel<MainActivityView.Event.OnViewInitialised>
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setupEvents()
}
override fun onDestroy() {
super.onDestroy()
subscriptions.clear()
}
private fun setupEvents() {
if (subscriptions.size() == 0) {
onViewInitialisedChannel.asFlow()
.buffer()
.flowOn(Dispatchers.IO)
.onEach(::render)
.launchIn(GlobalScope)
onViewInitialisedChannel
.offer(
MainActivityView
.Event
.OnViewInitialised
)
}
}
private fun render(state: MainActivityView.State) {
when (state.renderEvent) {
MainActivityView.RenderEvent.None -> Unit
is MainActivityView.RenderEvent.DisplayText -> {
mainActivityTextField.text = state.renderEvent.text
}
}
}
}
I think what you're looking for is the Flow version of compose and ObservableTransformer and as far as I can tell there isn't one. What you can use instead is the let operator and do something like this:
MainActivity:
yourFlow
.let(viewModel::invoke)
.onEach(::render)
.launchIn(lifecycleScope) // or viewLifecycleOwner.lifecycleScope if you're in a fragment
ViewModel:
operator fun invoke(viewEventFlow: Flow<Event>): Flow<State> = viewEventFlow.flatMapLatest { event ->
when (event) {
Event.OnViewInitialised -> flowOf(onViewInitialisedEvent())
}
}
As far as sharing a flow I would watch these issues:
https://github.com/Kotlin/kotlinx.coroutines/issues/2034
https://github.com/Kotlin/kotlinx.coroutines/issues/2047
Dominic's answer might work for replacing the publish subjects but I think the coroutines team is moving away from BroadcastChannel and intends to deprecate it in the near future.
kotlinx-coroutines-core provides a transform function.
https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/transform.html
it isn't quite the same as what we are used to in RxJava but should be usable for achieving the same result.