Skip to content

Commit

Permalink
Separate IntentDelegate, InternalChangesDelegate and EventDelegate fr…
Browse files Browse the repository at this point in the history
…om BaseViewModel to prefer delegation over inheritance
  • Loading branch information
krzdabrowski committed Apr 2, 2024
1 parent da4698c commit a310d86
Show file tree
Hide file tree
Showing 14 changed files with 168 additions and 105 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import eu.krzdabrowski.starter.basicfeature.data.generateTestRocketsFromDomain
import eu.krzdabrowski.starter.basicfeature.presentation.composable.RocketsRoute
import eu.krzdabrowski.starter.core.MainActivity
import eu.krzdabrowski.starter.core.presentation.MainActivity
import eu.krzdabrowski.starter.core.utils.getHiltTestViewModel
import org.junit.Before
import org.junit.Rule
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,8 @@ import eu.krzdabrowski.starter.basicfeature.presentation.RocketsUiState.PartialS
import eu.krzdabrowski.starter.basicfeature.presentation.RocketsUiState.PartialState.Fetched
import eu.krzdabrowski.starter.basicfeature.presentation.RocketsUiState.PartialState.Loading
import eu.krzdabrowski.starter.basicfeature.presentation.mapper.toPresentationModel
import eu.krzdabrowski.starter.core.BaseViewModel
import eu.krzdabrowski.starter.core.presentation.mvi.BaseViewModel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
Expand Down Expand Up @@ -87,11 +86,9 @@ class RocketsViewModel @Inject constructor(
emit(Loading)
}

private fun rocketClicked(uri: String): Flow<PartialState> {
private fun rocketClicked(uri: String): Flow<PartialState> = flow {
if (uri.startsWith(HTTP_PREFIX) || uri.startsWith(HTTPS_PREFIX)) {
publishEvent(OpenWebBrowserWithDetails(uri))
setEvent(OpenWebBrowserWithDetails(uri))
}

return emptyFlow()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import kotlinx.coroutines.flow.Flow
fun RocketsRoute(
viewModel: RocketsViewModel = hiltViewModel(),
) {
HandleEvents(viewModel.event)
HandleEvents(viewModel.getEvents())
val uiState by viewModel.uiState.collectAsStateWithLifecycle()

RocketsScreen(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ class RocketsViewModelTest {
objectUnderTest.acceptIntent(RocketClicked(testUri))

// Then
objectUnderTest.event.test {
objectUnderTest.getEvents().test {
assertEquals(
expected = OpenWebBrowserWithDetails(testUri),
actual = awaitItem(),
Expand All @@ -189,7 +189,7 @@ class RocketsViewModelTest {
objectUnderTest.acceptIntent(RocketClicked(testUri))

// Then
objectUnderTest.event.test {
objectUnderTest.getEvents().test {
expectNoEvents()
}
}
Expand Down
2 changes: 1 addition & 1 deletion core/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

<application>
<activity
android:name=".MainActivity"
android:name=".presentation.MainActivity"
android:exported="true"
android:screenOrientation="portrait"
android:windowSoftInputMode="adjustResize">
Expand Down
93 changes: 0 additions & 93 deletions core/src/main/java/eu/krzdabrowski/starter/core/BaseViewModel.kt

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package eu.krzdabrowski.starter.core
package eu.krzdabrowski.starter.core.presentation

import android.os.Bundle
import androidx.activity.ComponentActivity
Expand All @@ -15,6 +15,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.navigation.compose.rememberNavController
import dagger.hilt.android.AndroidEntryPoint
import eu.krzdabrowski.starter.core.R
import eu.krzdabrowski.starter.core.design.AndroidStarterTheme
import eu.krzdabrowski.starter.core.navigation.NavigationDestination
import eu.krzdabrowski.starter.core.navigation.NavigationFactory
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package eu.krzdabrowski.starter.core.presentation.mvi

import android.os.Parcelable
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.scan
import kotlinx.coroutines.launch
import timber.log.Timber

private const val SAVED_UI_STATE_KEY = "savedUiStateKey"

abstract class BaseViewModel<UI_STATE : Parcelable, PARTIAL_UI_STATE, EVENT, INTENT>(
savedStateHandle: SavedStateHandle,
initialState: UI_STATE,
) : ViewModel(),
IntentDelegate<INTENT, PARTIAL_UI_STATE> by IntentDelegateImpl(),
InternalChangesDelegate<PARTIAL_UI_STATE> by InternalChangesDelegateImpl(),
EventDelegate<EVENT> by EventDelegateImpl() {

val uiState = savedStateHandle.getStateFlow(
key = SAVED_UI_STATE_KEY,
initialValue = initialState,
)

init {
viewModelScope.launch {
merge(
getIntents(::mapIntents),
getInternalChanges(),
)
.scan(uiState.value, ::reduceUiState)
.catch { Timber.e(it) }
.collect {
savedStateHandle[SAVED_UI_STATE_KEY] = it
}
}
}

fun acceptIntent(intent: INTENT) {
viewModelScope.launch {
setIntent(intent)
}
}

protected fun acceptChanges(vararg internalChangesFlows: Flow<PARTIAL_UI_STATE>) {
viewModelScope.launch {
setInternalChanges(*internalChangesFlows)
}
}

protected abstract fun mapIntents(intent: INTENT): Flow<PARTIAL_UI_STATE>

protected abstract fun reduceUiState(
previousState: UI_STATE,
partialState: PARTIAL_UI_STATE,
): UI_STATE
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package eu.krzdabrowski.starter.core.presentation.mvi

import kotlinx.coroutines.flow.Flow

interface EventDelegate<EVENT> {
fun getEvents(): Flow<EVENT>
suspend fun setEvent(event: EVENT)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package eu.krzdabrowski.starter.core.presentation.mvi

import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.receiveAsFlow

class EventDelegateImpl<EVENT> : EventDelegate<EVENT> {

private val eventChannel = Channel<EVENT>(Channel.BUFFERED)

override fun getEvents(): Flow<EVENT> = eventChannel.receiveAsFlow()

override suspend fun setEvent(event: EVENT) {
eventChannel.send(event)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package eu.krzdabrowski.starter.core.presentation.mvi

import kotlinx.coroutines.flow.Flow

interface IntentDelegate<INTENT, PARTIAL_UI_STATE> {
fun getIntents(
mapOperation: (INTENT) -> Flow<PARTIAL_UI_STATE>,
): Flow<PARTIAL_UI_STATE>

suspend fun setIntent(intent: INTENT)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package eu.krzdabrowski.starter.core.presentation.mvi

import eu.krzdabrowski.starter.core.coroutines.flatMapConcurrently
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.onSubscription

class IntentDelegateImpl<INTENT, PARTIAL_UI_STATE> : IntentDelegate<INTENT, PARTIAL_UI_STATE> {

private val intentsFlowListenerStarted = CompletableDeferred<Unit>()
private val intentsFlow = MutableSharedFlow<INTENT>()

override fun getIntents(
mapOperation: (INTENT) -> Flow<PARTIAL_UI_STATE>,
): Flow<PARTIAL_UI_STATE> = intentsFlow
.onSubscription { intentsFlowListenerStarted.complete(Unit) }
.flatMapConcurrently(
transform = mapOperation,
)

override suspend fun setIntent(intent: INTENT) {
intentsFlowListenerStarted.await()
intentsFlow.emit(intent)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package eu.krzdabrowski.starter.core.presentation.mvi

import kotlinx.coroutines.flow.Flow

interface InternalChangesDelegate<PARTIAL_UI_STATE> {
fun getInternalChanges(): Flow<PARTIAL_UI_STATE>

suspend fun setInternalChanges(vararg internalChangesFlows: Flow<PARTIAL_UI_STATE>)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package eu.krzdabrowski.starter.core.presentation.mvi

import eu.krzdabrowski.starter.core.coroutines.flatMapConcurrently
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.onSubscription

class InternalChangesDelegateImpl<PARTIAL_UI_STATE> : InternalChangesDelegate<PARTIAL_UI_STATE> {

private val internalChangesPartialStateFlowListenerStarted = CompletableDeferred<Unit>()
private val internalChangesPartialStateFlow = MutableSharedFlow<PARTIAL_UI_STATE>()

override fun getInternalChanges(): Flow<PARTIAL_UI_STATE> =
internalChangesPartialStateFlow
.onSubscription { internalChangesPartialStateFlowListenerStarted.complete(Unit) }

override suspend fun setInternalChanges(vararg internalChangesFlows: Flow<PARTIAL_UI_STATE>) {
internalChangesPartialStateFlowListenerStarted.await()
internalChangesPartialStateFlow.emitAll(
// to flatten Flow with queue behaviour like in userIntents() Flow but without ::mapIntents
internalChangesFlows.asFlow().flatMapConcurrently { it },
)
}
}

0 comments on commit a310d86

Please sign in to comment.