Skip to content

Commit

Permalink
Merge pull request #37 from respawn-app/2.3.0-rc
Browse files Browse the repository at this point in the history
2.3.0: Saved state module
  • Loading branch information
Nek-12 authored Jan 4, 2024
2 parents 6eb1a05 + 265659e commit c0512a8
Show file tree
Hide file tree
Showing 49 changed files with 1,159 additions and 78 deletions.
20 changes: 14 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,19 +32,21 @@ FlowMVI is a Kotlin Multiplatform MVI library based on coroutines that has a few
flowmvi = "< Badge above 👆🏻 >"

[dependencies]
flowmvi-core = { module = "pro.respawn.flowmvi:core", version.ref = "flowmvi" } # multiplatform
flowmvi-core = { module = "pro.respawn.flowmvi:core", version.ref = "flowmvi" } # core KMP code
flowmvi-test = { module = "pro.respawn.flowmvi:test", version.ref = "flowmvi" } # test DSL

flowmvi-compose = { module = "pro.respawn.flowmvi:compose", version.ref = "flowmvi" } # compose multiplatform
flowmvi-android = { module = "pro.respawn.flowmvi:android", version.ref = "flowmvi" } # common android
flowmvi-view = { module = "pro.respawn.flowmvi:android-view", version.ref = "flowmvi" } # view-based android
flowmvi-savedstate = { module = "pro.respawn.flowmvi:savedstate", version.ref = "flowmvi" } # KMP state preservation
```
### Kotlin DSL
```kotlin
dependencies {
val flowmvi = "< Badge above 👆🏻 >"
commonMainImplementation("pro.respawn.flowmvi:core:$flowmvi")
commonMainImplementation("pro.respawn.flowmvi:compose:$flowmvi")
commonMainImplementation("pro.respawn.flowmvi:savedstate:$flowmvi")
commonTestImplementation("pro.respawn.flowmvi:test:$flowmvi")

androidMainImplementation("pro.respawn.flowmvi:android:$flowmvi")
Expand All @@ -54,12 +56,14 @@ dependencies {

## Features:

Rich, plugin-based store DSL:
Rich store DSL with dozens of useful pre-made plugins:

```kotlin
sealed interface CounterState : MVIState {
data object Loading : CounterState
data class Error(val e: Exception) : CounterState

@Serializable
data class DisplayingCounter(
val timer: Int,
val counter: Int,
Expand Down Expand Up @@ -90,10 +94,14 @@ class CounterContainer(
timeTravelPlugin(), // unit test stores and track changes
)

saveState { // persist and restore state
get = { repo.restoreStateFromFile() }
set = { repo.saveStateToFile(this) }
}
// one-liner for persisting and restoring compressed state to/from files,
// bundles, or anywhere
serializeState(
dir = repo.cacheDir,
json = Json,
serializer = DisplayingCounter.serializer(),
recover = ThrowRecover
)

val undoRedoPlugin = undoRedo(maxQueueSize = 10) // undo and redo any changes

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ internal data class ConsumerScopeImpl<S : MVIState, in I : MVIIntent, out A : MV
@PublishedApi
internal val state: MutableState<S> = mutableStateOf(store.state)

override fun intent(intent: I) = store.send(intent)
override fun intent(intent: I) = store.intent(intent)
override suspend fun emit(intent: I) = store.emit(intent)

@Composable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import java.io.Serializable
* Your state must be [Parcelable] to use this.
* @see savedStatePlugin
*/
@Deprecated("If you want to save state, use the new `savedstate` module dependency")
@FlowMVIDSL
public fun <S, I : MVIIntent, A : MVIAction> parcelizeStatePlugin(
key: String,
Expand All @@ -34,6 +35,7 @@ public fun <S, I : MVIIntent, A : MVIAction> parcelizeStatePlugin(
* Your state must be [Serializable] to use this
* @see savedStatePlugin
*/
@Deprecated("If you want to save state, use the new `savedstate` module dependency")
@FlowMVIDSL
public fun <S, I : MVIIntent, A : MVIAction> serializeStatePlugin(
key: String,
Expand All @@ -50,6 +52,7 @@ public fun <S, I : MVIIntent, A : MVIAction> serializeStatePlugin(
* Your state must be [Parcelable] to use this.
* @see savedStatePlugin
*/
@Deprecated("If you want to save state, use the new `savedstate` module dependency")
@FlowMVIDSL
public fun <S, I : MVIIntent, A : MVIAction> StoreBuilder<S, I, A>.parcelizeState(
handle: SavedStateHandle,
Expand Down
3 changes: 3 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ plugins {
id("com.android.application")
kotlin("android")
id("kotlin-parcelize")
alias(libs.plugins.serialization)
}

private val PluginPrefix = "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination"
Expand Down Expand Up @@ -41,9 +42,11 @@ dependencies {
implementation(projects.android)
implementation(projects.compose)
implementation(projects.androidView)
implementation(projects.savedstate)

implementation(libs.bundles.koin)
implementation(libs.koin.android.compose)
implementation(libs.kotlin.serialization.json)

implementation(libs.androidx.core)

Expand Down
15 changes: 9 additions & 6 deletions app/src/main/kotlin/pro/respawn/flowmvi/sample/CounterModels.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,25 @@ package pro.respawn.flowmvi.sample

import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
import pro.respawn.flowmvi.api.MVIAction
import pro.respawn.flowmvi.api.MVIIntent
import pro.respawn.flowmvi.api.MVIState
import pro.respawn.flowmvi.dsl.LambdaIntent

sealed interface CounterState : MVIState, Parcelable {
sealed interface CounterState : MVIState {

@Parcelize
data object Loading : CounterState

@Parcelize
data class Error(val e: Exception) : CounterState

@Serializable
@Parcelize
data class DisplayingCounter(
val timer: Int,
val counter: Int = 0,
val param: String,
) : CounterState
val counter: Int,
val input: String,
) : CounterState, Parcelable
}

typealias CounterLambdaIntent = LambdaIntent<CounterState, CounterAction>
Expand All @@ -30,6 +30,9 @@ sealed interface CounterIntent : MVIIntent {
data object ClickedCounter : CounterIntent
data object ClickedUndo : CounterIntent
data object ClickedBack : CounterIntent

@JvmInline
value class InputChanged(val value: String) : CounterIntent
}

sealed interface CounterAction : MVIAction {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package pro.respawn.flowmvi.sample

import android.app.Application
import org.koin.android.ext.koin.androidContext
import org.koin.core.context.startKoin
import pro.respawn.flowmvi.sample.di.appModule

Expand All @@ -11,6 +12,7 @@ class MVIApplication : Application() {

startKoin {
modules(appModule)
androidContext(applicationContext)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,16 @@ package pro.respawn.flowmvi.sample.compose
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize.Max
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.Button
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Scaffold
import androidx.compose.material.ScaffoldState
import androidx.compose.material.Text
Expand All @@ -33,6 +39,7 @@ import pro.respawn.flowmvi.sample.CounterAction.ShowLambdaMessage
import pro.respawn.flowmvi.sample.CounterIntent
import pro.respawn.flowmvi.sample.CounterIntent.ClickedBack
import pro.respawn.flowmvi.sample.CounterIntent.ClickedCounter
import pro.respawn.flowmvi.sample.CounterIntent.InputChanged
import pro.respawn.flowmvi.sample.CounterState
import pro.respawn.flowmvi.sample.CounterState.DisplayingCounter
import pro.respawn.flowmvi.sample.CounterState.Error
Expand Down Expand Up @@ -84,6 +91,21 @@ private fun IntentReceiver<CounterIntent>.ComposeScreenContent(
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
OutlinedTextField(
value = state.input,
onValueChange = { intent(InputChanged(it)) },
modifier = Modifier
.fillMaxWidth()
.widthIn(max = 600.dp),
label = { Text(stringResource(R.string.counter_input_label)) }
)
Text(
stringResource(R.string.counter_input_hint),
style = MaterialTheme.typography.caption,
modifier = Modifier.width(Max),
)
}
Text(
text = stringResource(id = R.string.timer_template, state.timer),
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
package pro.respawn.flowmvi.sample.compose

import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import pro.respawn.flowmvi.api.Container
import pro.respawn.flowmvi.api.PipelineContext
import pro.respawn.flowmvi.dsl.store
import pro.respawn.flowmvi.dsl.updateState
import pro.respawn.flowmvi.plugins.disallowRestartPlugin
import pro.respawn.flowmvi.plugins.manageJobs
import pro.respawn.flowmvi.plugins.platformLoggingPlugin
import pro.respawn.flowmvi.plugins.recover
import pro.respawn.flowmvi.plugins.reduce
import pro.respawn.flowmvi.plugins.registerOrReplace
import pro.respawn.flowmvi.plugins.undoRedo
import pro.respawn.flowmvi.plugins.whileSubscribed
import pro.respawn.flowmvi.sample.CounterAction
Expand All @@ -20,9 +24,12 @@ import pro.respawn.flowmvi.sample.CounterIntent
import pro.respawn.flowmvi.sample.CounterIntent.ClickedBack
import pro.respawn.flowmvi.sample.CounterIntent.ClickedCounter
import pro.respawn.flowmvi.sample.CounterIntent.ClickedUndo
import pro.respawn.flowmvi.sample.CounterIntent.InputChanged
import pro.respawn.flowmvi.sample.CounterState
import pro.respawn.flowmvi.sample.CounterState.DisplayingCounter
import pro.respawn.flowmvi.sample.repository.CounterRepository
import pro.respawn.flowmvi.savedstate.api.ThrowRecover
import pro.respawn.flowmvi.savedstate.plugins.serializeState
import pro.respawn.flowmvi.util.typed
import kotlin.random.Random

Expand All @@ -31,32 +38,52 @@ private typealias Ctx = PipelineContext<CounterState, CounterIntent, CounterActi
class CounterContainer(
private val repo: CounterRepository,
private val param: String,
private val json: Json,
context: Context,
) : Container<CounterState, CounterIntent, CounterAction> {

private val cacheDir = context.cacheDir.resolve("state").path

override val store = store(CounterState.Loading) {
name = "Counter"
name = "CounterContainer"
install(
platformLoggingPlugin(),
disallowRestartPlugin() // store does not restart when it is in a viewmodel
)
serializeState(
dir = cacheDir,
json = json,
serializer = DisplayingCounter.serializer(),
recover = ThrowRecover
)
val undoRedo = undoRedo(10)
val jobManager = manageJobs()
recover {
if (it is IllegalArgumentException)
action(ShowErrorMessage(it.message))
else updateState {
jobManager.cancelAndJoin("timer")
CounterState.Error(it)
}
null
}
whileSubscribed {
repo.getTimer()
.onEach { produceState(it) }
.consume(Dispatchers.Default)
launch {
repo.getTimer()
.onEach { produceState(it) }
.consume(Dispatchers.Default)
}.apply {
registerOrReplace(jobManager, "timer")
join()
}
}
reduce {
when (it) {
is ClickedUndo -> undoRedo.undo()
is ClickedBack -> action(GoBack)
is InputChanged -> updateState<DisplayingCounter, _> {
copy(input = it.value)
}
is ClickedCounter -> launch {
require(Random.nextBoolean()) { "Oops, there was an error in a job" }
undoRedo(
Expand All @@ -79,6 +106,10 @@ class CounterContainer(
private suspend fun Ctx.produceState(timer: Int) = updateState {
// remember that you have to merge states when you are running produceState
val current = typed<DisplayingCounter>()
DisplayingCounter(timer, current?.counter ?: 0, param)
DisplayingCounter(
timer = timer,
counter = current?.counter ?: 0,
input = current?.input ?: param
)
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package pro.respawn.flowmvi.sample.di

import kotlinx.serialization.json.Json
import org.koin.androidx.viewmodel.dsl.viewModelOf
import org.koin.core.module.dsl.factoryOf
import org.koin.core.module.dsl.singleOf
Expand All @@ -14,4 +15,5 @@ val appModule = module {

factoryOf(::CounterContainer)
storeViewModel<CounterContainer>()
single { Json { ignoreUnknownKeys = true } }
}
8 changes: 4 additions & 4 deletions app/src/main/kotlin/pro/respawn/flowmvi/sample/di/KoinExt.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import androidx.lifecycle.ViewModelStoreOwner
import androidx.lifecycle.viewmodel.CreationExtras
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
import org.koin.androidx.compose.defaultExtras
import org.koin.androidx.compose.getViewModel
import org.koin.androidx.compose.koinViewModel
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.compose.getKoinScope
import org.koin.compose.currentKoinScope
import org.koin.core.module.Module
import org.koin.core.parameter.ParametersDefinition
import org.koin.core.qualifier.qualifier
Expand All @@ -29,6 +29,6 @@ inline fun <reified T : Container<S, I, A>, S : MVIState, I : MVIIntent, A : MVI
},
key: String? = null,
extras: CreationExtras = defaultExtras(viewModelStoreOwner),
scope: Scope = getKoinScope(),
scope: Scope = currentKoinScope(),
noinline parameters: ParametersDefinition? = null,
): StoreViewModel<S, I, A> = getViewModel(qualifier<T>(), viewModelStoreOwner, key, extras, scope, parameters)
): StoreViewModel<S, I, A> = koinViewModel(qualifier<T>(), viewModelStoreOwner, key, extras, scope, parameters)
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ class CounterActivity :
}
with(tvParam) {
isVisible = true
text = state.param
text = state.input
}
with(tvTimer) {
isVisible = true
Expand Down
Loading

0 comments on commit c0512a8

Please sign in to comment.