Skip to content

Commit

Permalink
Merge pull request #26 from respawn-app/2.0.0-beta08
Browse files Browse the repository at this point in the history
2.0.0-beta08
  • Loading branch information
Nek-12 committed Sep 20, 2023
2 parents 49eeae6 + 5027b42 commit ebacf71
Show file tree
Hide file tree
Showing 14 changed files with 246 additions and 84 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -44,15 +44,15 @@ public interface ConsumerScope<in I : MVIIntent, out A : MVIAction> : IntentRece
*/
@Composable
@FlowMVIDSL
public fun consume(onAction: suspend CoroutineScope.(action: A) -> Unit)
public fun consume(onAction: (suspend CoroutineScope.(action: A) -> Unit)?)

/**
* Collect [MVIState]s emitted by the [Store]
* Does not subscribe to [MVIAction]s, unlike the other overload
*/
@Composable
@FlowMVIDSL
public fun consume()
public fun consume(): Unit = consume(null)
}

/**
Expand Down Expand Up @@ -94,7 +94,7 @@ internal data class ConsumerScopeImpl<S : MVIState, in I : MVIIntent, out A : MV
override suspend fun emit(intent: I) = store.emit(intent)

@Composable
private inline fun consumeInternal(noinline onAction: (suspend CoroutineScope.(action: A) -> Unit)?) {
override fun consume(onAction: (suspend CoroutineScope.(action: A) -> Unit)?) {
val owner = LocalLifecycleOwner.current
val block by rememberUpdatedState(onAction)
LaunchedEffect(owner, this) {
Expand All @@ -107,12 +107,6 @@ internal data class ConsumerScopeImpl<S : MVIState, in I : MVIIntent, out A : MV
}
}
}

@Composable
override fun consume(onAction: suspend CoroutineScope.(action: A) -> Unit) = consumeInternal(onAction)

@Composable
override fun consume() = consumeInternal(null)
}

private object EmptyScope : ConsumerScope<MVIIntent, MVIAction> {
Expand All @@ -121,10 +115,7 @@ private object EmptyScope : ConsumerScope<MVIIntent, MVIAction> {
override suspend fun emit(intent: MVIIntent) = Unit

@Composable
override fun consume(onAction: suspend CoroutineScope.(action: MVIAction) -> Unit) = Unit

@Composable
override fun consume() = Unit
override fun consume(onAction: (suspend CoroutineScope.(action: MVIAction) -> Unit)?) = Unit
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package pro.respawn.flowmvi.android.compose
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.Lifecycle
import kotlinx.coroutines.CoroutineScope
import pro.respawn.flowmvi.api.MVIAction
import pro.respawn.flowmvi.api.MVIIntent
import pro.respawn.flowmvi.api.MVIState
Expand Down Expand Up @@ -42,3 +43,21 @@ public inline fun <S : MVIState, I : MVIIntent, A : MVIAction> MVIComposable(
val state by scope.state
content(scope, state)
}

/**
* An overload of [MVIComposable] that accepts a [consume] block to automatically
* subscribe to the [store] upon invocation.
* @see MVIComposable
*/
@Composable
public inline fun <S : MVIState, I : MVIIntent, A : MVIAction> MVIComposable(
store: Store<S, I, A>,
lifecycleState: Lifecycle.State = Lifecycle.State.STARTED,
noinline consume: (suspend CoroutineScope.(A) -> Unit)?,
@BuilderInference content: @Composable ConsumerScope<I, A>.(state: S) -> Unit,
) {
val scope = rememberConsumerScope(store, lifecycleState)
val state by scope.state
scope.consume(consume)
content(scope, state)
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,10 @@ 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.ClickedUndo
import pro.respawn.flowmvi.sample.CounterState
import pro.respawn.flowmvi.sample.CounterState.DisplayingCounter
import pro.respawn.flowmvi.sample.CounterState.Error
import pro.respawn.flowmvi.sample.CounterState.Loading
import pro.respawn.flowmvi.sample.R
import pro.respawn.flowmvi.sample.compose.theme.MVISampleTheme
import pro.respawn.flowmvi.sample.di.storeViewModel
Expand Down Expand Up @@ -82,6 +83,8 @@ private fun Scope.ComposeScreenContent(
contentAlignment = Alignment.Center
) {
when (state) {
is Loading -> CircularProgressIndicator()
is Error -> Text(state.e.message.toString())
is DisplayingCounter -> Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
Expand All @@ -94,28 +97,17 @@ private fun Scope.ComposeScreenContent(
Text(
text = stringResource(id = R.string.counter_template, state.counter),
)

Text(text = state.param)

Button(onClick = { intent(ClickedCounter) }) {
Text(text = stringResource(id = R.string.counter_button_label))
}
Button(onClick = { intent(ClickedUndo) }) {
Text(text = stringResource(id = R.string.counter_undo_label))
}
Button(onClick = { intent(ClickedBack) }) {
Text(text = stringResource(id = R.string.counter_back_label))
}
}
is CounterState.Loading -> CircularProgressIndicator()
is CounterState.Error -> Text(state.e.message.toString())
}
}
}

private class PreviewProvider : StateProvider<CounterState>(
DisplayingCounter(1, 2, "param"),
CounterState.Loading,
Loading,
)

@Composable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ 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
Expand Down Expand Up @@ -36,7 +37,10 @@ class CounterContainer(

override val store = store(CounterState.Loading) {
name = "Counter"
install(platformLoggingPlugin())
install(
platformLoggingPlugin(),
disallowRestartPlugin() // store does not restart when it is in a viewmodel
)
val manager = manageJobs()
val undoRedo = undoRedo(10)
whileSubscribed {
Expand Down
2 changes: 0 additions & 2 deletions app/src/main/res/layout/activity_basic.xml
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,4 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible" />


</androidx.constraintlayout.widget.ConstraintLayout>
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package pro.respawn.flowmvi.plugins

import kotlinx.atomicfu.atomic
import kotlinx.atomicfu.update
import pro.respawn.flowmvi.api.MVIAction
import pro.respawn.flowmvi.api.MVIIntent
import pro.respawn.flowmvi.api.MVIState
import pro.respawn.flowmvi.api.PipelineContext
import pro.respawn.flowmvi.dsl.StoreBuilder
import kotlin.properties.ReadOnlyProperty
import kotlin.reflect.KProperty

private const val AccessBeforeCachingMessage = """
Cached value was accessed before the store was started.
Please make sure you access the property only after onStart() and before onStop() have been called
and the plugin that caches the value was installed before first access.
"""

/**
* A plugin that allows to cache the value of a property, scoping it to the [pro.respawn.flowmvi.api.Store]'s lifecycle.
* This plugin will clear the value when [PipelineContext] is canceled and call the [init] block each time a store is
* started to initialize it again.
*
* This plugin is similar to [lazy] but where value is also bound to the [pro.respawn.flowmvi.api.Store]'s lifecycle.
* This plugin can be used to get access to the [PipelineContext] and execute suspending operations in to
* initialize the value.
*
* The [init] block can be called **multiple times** in rare cases of concurrent access.
* In practice, this should not happen
*
* This plugin is useful with legacy APIs that rely on the [kotlinx.coroutines.CoroutineScope]
* to be present during the lifetime of a value, such as paging, and can be used to obtain the value in plugins such
* as [whileSubscribedPlugin] without recreating it.
*
* The cached value **must not be accessed** before [pro.respawn.flowmvi.api.StorePlugin.onStart] and after
* [pro.respawn.flowmvi.api.StorePlugin.onStop] have been called, where it will be uninitialized.
* The [init] block is evaluated in [pro.respawn.flowmvi.api.StorePlugin.onStart] in the order the
* cache plugin was installed.
*
* This plugin should **not be used** to run operations in [PipelineContext]. Use [initPlugin] for that.
*
* Access the delegated property as follows:
*
* ```kotlin
* // in store's scope
* val pagedItems: Flow<PagingData<Item>> by cache { // this: PipelineContext ->
* repo.pagedItems().cachedIn(this)
* }
* ```
*
* @see cache
* @see cachePlugin
*/
public class CachePlugin<out T, S : MVIState, I : MVIIntent, A : MVIAction> internal constructor(
name: String? = null,
private val init: suspend PipelineContext<S, I, A>.() -> T,
) : AbstractStorePlugin<S, I, A>(name), ReadOnlyProperty<Any?, T> {

private data object UNINITIALIZED {

override fun toString() = "Uncached value"
}

private var _value = atomic<Any?>(UNINITIALIZED)

/**
* Returns true if the cached value is present
*/
public val isCached: Boolean get() = _value.value !== UNINITIALIZED

override suspend fun PipelineContext<S, I, A>.onStart(): Unit = _value.update { init() }

override fun onStop(e: Exception?): Unit = _value.update { UNINITIALIZED }

override fun getValue(thisRef: Any?, property: KProperty<*>): T =
@Suppress("UNCHECKED_CAST")
requireNotNull(_value.value as? T) { AccessBeforeCachingMessage }
}

/**
* Creates and returns a new [CachePlugin] without installing it.
* @see CachePlugin
*/
public fun <T, S : MVIState, I : MVIIntent, A : MVIAction> cachePlugin(
name: String? = null,
init: suspend PipelineContext<S, I, A>.() -> T,
): CachePlugin<T, S, I, A> = CachePlugin(name, init)

/**
* Creates and installs a new [CachePlugin], returning a delegate that can be used to get access to the property that
* was cached. Please consult the documentation of the parent class to understand how to use this plugin.
*
* @return A [ReadOnlyProperty] granting access to the value returned from [init]
* @see cachePlugin
*/
public fun <T, S : MVIState, I : MVIIntent, A : MVIAction> StoreBuilder<S, I, A>.cache(
name: String? = null,
init: suspend PipelineContext<S, I, A>.() -> T,
): ReadOnlyProperty<Any?, T> = cachePlugin(name, init).also { install(it) }
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package pro.respawn.flowmvi.plugins

import kotlinx.atomicfu.atomic
import kotlinx.atomicfu.getAndUpdate
import pro.respawn.flowmvi.api.MVIAction
import pro.respawn.flowmvi.api.MVIIntent
import pro.respawn.flowmvi.api.MVIState
import pro.respawn.flowmvi.api.StorePlugin
import pro.respawn.flowmvi.dsl.StoreBuilder
import pro.respawn.flowmvi.dsl.plugin

private const val DisallowRestartMessage = """
Store was disallowed to restart but was restarted. Please remove disallowRestartPlugin() or do not reuse the store.
"""

private const val DisallowRestartPluginName = "DisallowRestartPlugin"

/**
* Disallow restart plugin will allow the store to be [pro.respawn.flowmvi.api.Store.start]ed only once.
* It will throw on any subsequent invocations of [StorePlugin.onStart].
* You can use this when you are sure that you are not going to restart your store.
* I.e. you are using the scope with which you launch the store only once, such as viewModelScope on Android.
*
* There is no need to install this plugin multiple times so the plugin has a unique [StorePlugin.name].
*/
public fun <S : MVIState, I : MVIIntent, A : MVIAction> disallowRestartPlugin(): StorePlugin<S, I, A> = plugin {
name = DisallowRestartPluginName
val started = atomic(false)
onStart {
check(!started.getAndUpdate { true }) { DisallowRestartMessage }
}
}

/**
* Installs a new [disallowRestartPlugin]. Please consult the docs of the parent function to learn more.
* This plugin can only be installed only once.
*/
public fun <S : MVIState, I : MVIIntent, A : MVIAction> StoreBuilder<S, I, A>.disallowRestart(): Unit =
install(disallowRestartPlugin())
5 changes: 3 additions & 2 deletions docs/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
sdk.dir=...
release=false
```
* Make sure you these installed:
* Make sure you have these installed:
* Android Studio latest Canary or Beta, depending on the current project's AGP (yes, we're on the edge).
* Kotlin Multiplatform suite (run `kdoctor` to verify proper setup)
* Detekt plugin
Expand All @@ -26,4 +26,5 @@
* If you submit a PR that changes behavior or adds a new plugin, please add tests for it.
* All contributions are welcome, including your plugin ideas or plugins you used in your project.
* We're especially looking for people who use FlowMVI in an iOS-compatible KMP project because we would like to include
the adapters and solutions people to the core library to improve overall experience of library users out-of-the-box.
the adapters and solutions people came up with
to the core library to improve overall experience of library users out-of-the-box.
32 changes: 17 additions & 15 deletions docs/android.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,15 @@ specify your qualifiers. The most basic setup for Koin will look like this:

```kotlin
inline fun <reified T : Container<*, *, *>> Module.storeViewModel() {
viewModel(qualifier<T>()) { params -> StoreViewModel(get<T> { params }.store) }

// using type qualifiers, can also use named() and companion objects
viewModel(qualifier<T>()) { params -> StoreViewModel(get<T> { params }) }
}

@Composable
inline fun <reified T : Container<S, I, A>, S : MVIState, I : MVIIntent, A : MVIAction> storeViewModel(
noinline params: ParametersDefinition? = null,
) = getViewModel<StoreViewModel<S, I, A>>(qualifier<T>(), parameters = params)


val appModule = module {
singleOf(::CounterRepository)
factoryOf(::CounterContainer)
Expand Down Expand Up @@ -195,43 +199,41 @@ for a more elaborate example.

## View

For a View-based project, inheritance rules. Just implement `MVIView` in your Fragment and subscribe to events.
For a View-based project, inheritance rules.

```kotlin
class CounterFragment : Fragment(), MVIView<CounterState, CounterIntent, CounterAction> {
class CounterFragment : Fragment() {

private val binding by viewBinding<CounterFragmentBinding>()
override val container by viewModel<CounterViewModel>()
private val store by viewModel<CounterViewModel>()

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

subscribe() // one-liner for store subscription. Lifecycle-aware and efficient.
subscribe(container, ::consume, ::render)

with(binding) {
tvCounter.setOnClickListener(container::onClickCounter) // let's say we are using MVVM+ style.
tvCounter.setOnClickListener(store::onClickCounter) // let's say we are using MVVM+ style.
}
}

override fun render(state: ScreenState) = with(binding) {
private fun render(state: ScreenState) = with(binding) {
with(state) {
tvCounter.text = counter.toString()
/* ... update ALL views every time ... */
/* ... update ALL views! ... */
}
}

override fun consume(action: ScreenAction) = when (action) {
private fun consume(action: ScreenAction) = when (action) {
is ShowMessage -> Snackbar.make(binding.root, action.message, Snackbar.LENGTH_SHORT).show()
}
}
```

* Subscribe in `Fragment.onViewCreated` or `Activity.onCreate`. The library will handle lifecycle for you.
* Subscribe in `Fragment.onViewCreated` or `Activity.onCreate`. The library will handle the lifecycle for you.
* Make sure your `render` function is pure, and `consume` function does not loop itself with intents.
* Always update **all views** in `render`, for **any state change**, to circumvent the problems of old-school stateful
view-based Android API.
* You are not required to extend `MVIView` - you can use `subscribe` anyway, just provide
the `ViewLifecycleOwner.lifecycleScope` or `Activity.lifecycleScope` as a scope.

See [Sample app](https://github.com/respawn-app/FlowMVI/blob/master/app/src/main/kotlin/pro/respawn/flowmvi/sample/view/BasicActivity.kt)
for a more elaborate example.
Loading

0 comments on commit ebacf71

Please sign in to comment.