diff --git a/basic-feature/src/androidTest/java/eu/krzdabrowski/starter/basicfeature/tests/RocketsRouteTest.kt b/basic-feature/src/androidTest/java/eu/krzdabrowski/starter/basicfeature/tests/RocketsRouteTest.kt index cce030e..c488396 100644 --- a/basic-feature/src/androidTest/java/eu/krzdabrowski/starter/basicfeature/tests/RocketsRouteTest.kt +++ b/basic-feature/src/androidTest/java/eu/krzdabrowski/starter/basicfeature/tests/RocketsRouteTest.kt @@ -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 diff --git a/basic-feature/src/main/java/eu/krzdabrowski/starter/basicfeature/presentation/RocketsViewModel.kt b/basic-feature/src/main/java/eu/krzdabrowski/starter/basicfeature/presentation/RocketsViewModel.kt index 1f2f3ce..3168a69 100644 --- a/basic-feature/src/main/java/eu/krzdabrowski/starter/basicfeature/presentation/RocketsViewModel.kt +++ b/basic-feature/src/main/java/eu/krzdabrowski/starter/basicfeature/presentation/RocketsViewModel.kt @@ -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 @@ -87,11 +86,9 @@ class RocketsViewModel @Inject constructor( emit(Loading) } - private fun rocketClicked(uri: String): Flow { + private fun rocketClicked(uri: String): Flow = flow { if (uri.startsWith(HTTP_PREFIX) || uri.startsWith(HTTPS_PREFIX)) { - publishEvent(OpenWebBrowserWithDetails(uri)) + setEvent(OpenWebBrowserWithDetails(uri)) } - - return emptyFlow() } } diff --git a/basic-feature/src/main/java/eu/krzdabrowski/starter/basicfeature/presentation/composable/RocketsScreen.kt b/basic-feature/src/main/java/eu/krzdabrowski/starter/basicfeature/presentation/composable/RocketsScreen.kt index 125c0b8..20119e3 100644 --- a/basic-feature/src/main/java/eu/krzdabrowski/starter/basicfeature/presentation/composable/RocketsScreen.kt +++ b/basic-feature/src/main/java/eu/krzdabrowski/starter/basicfeature/presentation/composable/RocketsScreen.kt @@ -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( diff --git a/basic-feature/src/test/java/eu/krzdabrowski/starter/basicfeature/presentation/RocketsViewModelTest.kt b/basic-feature/src/test/java/eu/krzdabrowski/starter/basicfeature/presentation/RocketsViewModelTest.kt index db49b99..dc7e348 100644 --- a/basic-feature/src/test/java/eu/krzdabrowski/starter/basicfeature/presentation/RocketsViewModelTest.kt +++ b/basic-feature/src/test/java/eu/krzdabrowski/starter/basicfeature/presentation/RocketsViewModelTest.kt @@ -170,7 +170,7 @@ class RocketsViewModelTest { objectUnderTest.acceptIntent(RocketClicked(testUri)) // Then - objectUnderTest.event.test { + objectUnderTest.getEvents().test { assertEquals( expected = OpenWebBrowserWithDetails(testUri), actual = awaitItem(), @@ -189,7 +189,7 @@ class RocketsViewModelTest { objectUnderTest.acceptIntent(RocketClicked(testUri)) // Then - objectUnderTest.event.test { + objectUnderTest.getEvents().test { expectNoEvents() } } diff --git a/core/src/main/AndroidManifest.xml b/core/src/main/AndroidManifest.xml index cf7f6a1..29058c8 100644 --- a/core/src/main/AndroidManifest.xml +++ b/core/src/main/AndroidManifest.xml @@ -6,7 +6,7 @@ diff --git a/core/src/main/java/eu/krzdabrowski/starter/core/BaseViewModel.kt b/core/src/main/java/eu/krzdabrowski/starter/core/BaseViewModel.kt deleted file mode 100644 index b9c82f7..0000000 --- a/core/src/main/java/eu/krzdabrowski/starter/core/BaseViewModel.kt +++ /dev/null @@ -1,93 +0,0 @@ -package eu.krzdabrowski.starter.core - -import android.os.Parcelable -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import eu.krzdabrowski.starter.core.coroutines.flatMapConcurrently -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.asFlow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.emitAll -import kotlinx.coroutines.flow.merge -import kotlinx.coroutines.flow.onSubscription -import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.flow.scan -import kotlinx.coroutines.launch -import timber.log.Timber - -private const val SAVED_UI_STATE_KEY = "savedUiStateKey" - -abstract class BaseViewModel( - savedStateHandle: SavedStateHandle, - initialState: UI_STATE, -) : ViewModel() { - private val intentsFlowListenerStarted = CompletableDeferred() - private val changesPartialStateFlowListenerStarted = CompletableDeferred() - - private val intentsFlow = MutableSharedFlow() - private val changesPartialStateFlow = MutableSharedFlow() - - val uiState = savedStateHandle.getStateFlow(SAVED_UI_STATE_KEY, initialState) - - private val eventChannel = Channel(Channel.BUFFERED) - val event = eventChannel.receiveAsFlow() - - init { - viewModelScope.launch { - merge( - userIntents(), - nonUserChanges(), - ) - .scan(uiState.value, ::reduceUiState) - .catch { Timber.e(it) } - .collect { - savedStateHandle[SAVED_UI_STATE_KEY] = it - } - } - } - - private fun userIntents(): Flow = - intentsFlow - .onSubscription { intentsFlowListenerStarted.complete(Unit) } - .flatMapConcurrently( - transform = ::mapIntents, - ) - - private fun nonUserChanges(): Flow = - changesPartialStateFlow - .onSubscription { changesPartialStateFlowListenerStarted.complete(Unit) } - - fun acceptIntent(intent: INTENT) { - viewModelScope.launch { - intentsFlowListenerStarted.await() - intentsFlow.emit(intent) - } - } - - protected fun acceptChanges(vararg nonUserChangesFlows: Flow) { - viewModelScope.launch { - changesPartialStateFlowListenerStarted.await() - changesPartialStateFlow.emitAll( - // to flatten Flow with queue behaviour like in userIntents() Flow but without ::mapIntents - nonUserChangesFlows.asFlow().flatMapConcurrently { it }, - ) - } - } - - protected fun publishEvent(event: EVENT) { - viewModelScope.launch { - eventChannel.send(event) - } - } - - protected abstract fun mapIntents(intent: INTENT): Flow - - protected abstract fun reduceUiState( - previousState: UI_STATE, - partialState: PARTIAL_UI_STATE, - ): UI_STATE -} diff --git a/core/src/main/java/eu/krzdabrowski/starter/core/MainActivity.kt b/core/src/main/java/eu/krzdabrowski/starter/core/presentation/MainActivity.kt similarity index 97% rename from core/src/main/java/eu/krzdabrowski/starter/core/MainActivity.kt rename to core/src/main/java/eu/krzdabrowski/starter/core/presentation/MainActivity.kt index 351b012..041fd22 100644 --- a/core/src/main/java/eu/krzdabrowski/starter/core/MainActivity.kt +++ b/core/src/main/java/eu/krzdabrowski/starter/core/presentation/MainActivity.kt @@ -1,4 +1,4 @@ -package eu.krzdabrowski.starter.core +package eu.krzdabrowski.starter.core.presentation import android.os.Bundle import androidx.activity.ComponentActivity @@ -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 diff --git a/core/src/main/java/eu/krzdabrowski/starter/core/presentation/mvi/BaseViewModel.kt b/core/src/main/java/eu/krzdabrowski/starter/core/presentation/mvi/BaseViewModel.kt new file mode 100644 index 0000000..efb7b81 --- /dev/null +++ b/core/src/main/java/eu/krzdabrowski/starter/core/presentation/mvi/BaseViewModel.kt @@ -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( + savedStateHandle: SavedStateHandle, + initialState: UI_STATE, +) : ViewModel(), + IntentDelegate by IntentDelegateImpl(), + InternalChangesDelegate by InternalChangesDelegateImpl(), + EventDelegate 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) { + viewModelScope.launch { + setInternalChanges(*internalChangesFlows) + } + } + + protected abstract fun mapIntents(intent: INTENT): Flow + + protected abstract fun reduceUiState( + previousState: UI_STATE, + partialState: PARTIAL_UI_STATE, + ): UI_STATE +} diff --git a/core/src/main/java/eu/krzdabrowski/starter/core/presentation/mvi/EventDelegate.kt b/core/src/main/java/eu/krzdabrowski/starter/core/presentation/mvi/EventDelegate.kt new file mode 100644 index 0000000..e8abb36 --- /dev/null +++ b/core/src/main/java/eu/krzdabrowski/starter/core/presentation/mvi/EventDelegate.kt @@ -0,0 +1,8 @@ +package eu.krzdabrowski.starter.core.presentation.mvi + +import kotlinx.coroutines.flow.Flow + +interface EventDelegate { + fun getEvents(): Flow + suspend fun setEvent(event: EVENT) +} diff --git a/core/src/main/java/eu/krzdabrowski/starter/core/presentation/mvi/EventDelegateImpl.kt b/core/src/main/java/eu/krzdabrowski/starter/core/presentation/mvi/EventDelegateImpl.kt new file mode 100644 index 0000000..368edea --- /dev/null +++ b/core/src/main/java/eu/krzdabrowski/starter/core/presentation/mvi/EventDelegateImpl.kt @@ -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 : EventDelegate { + + private val eventChannel = Channel(Channel.BUFFERED) + + override fun getEvents(): Flow = eventChannel.receiveAsFlow() + + override suspend fun setEvent(event: EVENT) { + eventChannel.send(event) + } +} diff --git a/core/src/main/java/eu/krzdabrowski/starter/core/presentation/mvi/IntentDelegate.kt b/core/src/main/java/eu/krzdabrowski/starter/core/presentation/mvi/IntentDelegate.kt new file mode 100644 index 0000000..c65d97c --- /dev/null +++ b/core/src/main/java/eu/krzdabrowski/starter/core/presentation/mvi/IntentDelegate.kt @@ -0,0 +1,11 @@ +package eu.krzdabrowski.starter.core.presentation.mvi + +import kotlinx.coroutines.flow.Flow + +interface IntentDelegate { + fun getIntents( + mapOperation: (INTENT) -> Flow, + ): Flow + + suspend fun setIntent(intent: INTENT) +} diff --git a/core/src/main/java/eu/krzdabrowski/starter/core/presentation/mvi/IntentDelegateImpl.kt b/core/src/main/java/eu/krzdabrowski/starter/core/presentation/mvi/IntentDelegateImpl.kt new file mode 100644 index 0000000..be0e56f --- /dev/null +++ b/core/src/main/java/eu/krzdabrowski/starter/core/presentation/mvi/IntentDelegateImpl.kt @@ -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 : IntentDelegate { + + private val intentsFlowListenerStarted = CompletableDeferred() + private val intentsFlow = MutableSharedFlow() + + override fun getIntents( + mapOperation: (INTENT) -> Flow, + ): Flow = intentsFlow + .onSubscription { intentsFlowListenerStarted.complete(Unit) } + .flatMapConcurrently( + transform = mapOperation, + ) + + override suspend fun setIntent(intent: INTENT) { + intentsFlowListenerStarted.await() + intentsFlow.emit(intent) + } +} diff --git a/core/src/main/java/eu/krzdabrowski/starter/core/presentation/mvi/InternalChangesDelegate.kt b/core/src/main/java/eu/krzdabrowski/starter/core/presentation/mvi/InternalChangesDelegate.kt new file mode 100644 index 0000000..9c8077e --- /dev/null +++ b/core/src/main/java/eu/krzdabrowski/starter/core/presentation/mvi/InternalChangesDelegate.kt @@ -0,0 +1,9 @@ +package eu.krzdabrowski.starter.core.presentation.mvi + +import kotlinx.coroutines.flow.Flow + +interface InternalChangesDelegate { + fun getInternalChanges(): Flow + + suspend fun setInternalChanges(vararg internalChangesFlows: Flow) +} diff --git a/core/src/main/java/eu/krzdabrowski/starter/core/presentation/mvi/InternalChangesDelegateImpl.kt b/core/src/main/java/eu/krzdabrowski/starter/core/presentation/mvi/InternalChangesDelegateImpl.kt new file mode 100644 index 0000000..9ff7194 --- /dev/null +++ b/core/src/main/java/eu/krzdabrowski/starter/core/presentation/mvi/InternalChangesDelegateImpl.kt @@ -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 : InternalChangesDelegate { + + private val internalChangesPartialStateFlowListenerStarted = CompletableDeferred() + private val internalChangesPartialStateFlow = MutableSharedFlow() + + override fun getInternalChanges(): Flow = + internalChangesPartialStateFlow + .onSubscription { internalChangesPartialStateFlowListenerStarted.complete(Unit) } + + override suspend fun setInternalChanges(vararg internalChangesFlows: Flow) { + internalChangesPartialStateFlowListenerStarted.await() + internalChangesPartialStateFlow.emitAll( + // to flatten Flow with queue behaviour like in userIntents() Flow but without ::mapIntents + internalChangesFlows.asFlow().flatMapConcurrently { it }, + ) + } +}