diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b8fcf161..73461e4f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Enable fine-grained control of dispatching via `Plugin.defaultDispatcher`, `RuntimeConfig.defaultDispatcher`, `Transition.ExecutionType` and `Effect.Type`. - Remove `RxStream` type alias. - Replace `implementation` function with a value +- Removed `FlowFactory` and `Flow` ## [0.7.1] - June 28, 2022 - **Breaking**: Rename `FragmentBindingBuilder` to `FragmentStoreBuilder` diff --git a/formula-android-tests/src/test/java/com/instacart/formula/FragmentAndroidEventTest.kt b/formula-android-tests/src/test/java/com/instacart/formula/FragmentAndroidEventTest.kt index b21688879..517aaa05d 100644 --- a/formula-android-tests/src/test/java/com/instacart/formula/FragmentAndroidEventTest.kt +++ b/formula-android-tests/src/test/java/com/instacart/formula/FragmentAndroidEventTest.kt @@ -5,6 +5,7 @@ import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import com.instacart.formula.android.Feature +import com.instacart.formula.android.FeatureFactory import com.instacart.formula.android.ViewFactory import com.instacart.formula.android.events.ActivityResult import com.instacart.formula.test.TestFragmentActivity @@ -29,18 +30,21 @@ class FragmentAndroidEventTest { initialContract = TestLifecycleKey() }, contracts = { - - bind { _, _ -> - Feature( - state = activityResults().flatMap { - activityResults.add(it) - Observable.empty() - }, - viewFactory = ViewFactory.fromLayout(R.layout.test_empty_layout) { - featureView { } - } - ) + val featureFactory = object : FeatureFactory { + override fun initialize(dependencies: Unit, key: TestLifecycleKey): Feature { + return Feature( + state = activityResults().flatMap { + activityResults.add(it) + Observable.empty() + }, + viewFactory = ViewFactory.fromLayout(R.layout.test_empty_layout) { + featureView { } + } + ) + } } + + bind(featureFactory) } ) } @@ -48,7 +52,8 @@ class FragmentAndroidEventTest { }, cleanUp = { activityResults.clear() - }) + } + ) private val activityRule = ActivityScenarioRule(TestFragmentActivity::class.java) diff --git a/formula-android/src/main/java/com/instacart/formula/android/DisposableScope.kt b/formula-android/src/main/java/com/instacart/formula/android/DisposableScope.kt deleted file mode 100644 index 9232b71b9..000000000 --- a/formula-android/src/main/java/com/instacart/formula/android/DisposableScope.kt +++ /dev/null @@ -1,31 +0,0 @@ -package com.instacart.formula.android - -/** - * Defines a component that can be disposed. This enables us to clean up - * once the component is not needed anymore. - * - * ``` - * // An example of how to create a disposable component. - * fun createComponent(): DisposableScope { - * val component = Component() - * component.service.start() - * return DisposableScope(component, onDispose = { - * component.service.stop() - * }) - * } - * - * val component = createComponent() - * - * .. do something with the component - * - * // clean up - * component.dispose() - * ``` - */ -class DisposableScope( - val component: Component, - private val onDispose: () -> Unit -) { - - fun dispose() = onDispose() -} diff --git a/formula-android/src/main/java/com/instacart/formula/android/Flow.kt b/formula-android/src/main/java/com/instacart/formula/android/Flow.kt deleted file mode 100644 index 06473735b..000000000 --- a/formula-android/src/main/java/com/instacart/formula/android/Flow.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.instacart.formula.android - -import com.instacart.formula.android.internal.Bindings - -/** - * A flow defines a group of features that share a same [FlowComponent]. - */ -data class Flow @PublishedApi internal constructor( - internal val bindings: Bindings -) { - companion object { - /** - * Utility function to build a flow. - */ - inline fun build( - crossinline init: FragmentStoreBuilder.() -> Unit - ): Flow { - val bindings = FragmentStoreBuilder.build(init) - return Flow(bindings) - } - } -} \ No newline at end of file diff --git a/formula-android/src/main/java/com/instacart/formula/android/FlowFactory.kt b/formula-android/src/main/java/com/instacart/formula/android/FlowFactory.kt deleted file mode 100644 index 0ddda1be4..000000000 --- a/formula-android/src/main/java/com/instacart/formula/android/FlowFactory.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.instacart.formula.android - -/** - * A flow factory enables to group multiple fragments and share state, routers, action handlers - * and other dependencies between them. A shared [FlowComponent] will be instantiated when user - * enters one of the features defined within [createFlow]. It will be passed to each - * [FeatureFactory] as a dependency and will be disposed when user exits the last feature defined - * in this flow. - * - * ```kotlin - * class AuthFlowFactory : FlowFactory { - * - * interface Dependencies { - * fun analyticsService(): AnalyticsService - * fun apiService(): ApiService - * fun authRouter(): AuthRouter - * } - * - * override fun createComponent(dependencies: Dependencies): DisposableScope { - * val flowComponent = AuthFlowComponent(dependencies) - * flowComponent.initialize() - * return DisposableScope(flowComponent) { - * flowComponent.dispose() - * } - * } - * - * override fun createFlow(): Flow { - * return Flow.build { - * bind(AuthRootFeatureFactory()) - * bind(LoginFeatureFactory()) - * bind(SignUpFeatureFactory()) - * bind(SingleSignOnFeatureFactory()) - * } - * } - * } - * ``` - * - * @param Dependencies A class or an interface provided by the parent that contains dependencies needed by this flow. - * @param FlowComponent A component that is initialized when user enters this flow and is shared - * between all the screens within flow. Component will be destroyed when user exits the flow - */ -interface FlowFactory { - - /** - * Using [dependencies] passed by the parent, this function creates a component used by - * all features within this flow. This component can be used to share state, routers, - * action handlers and other dependencies. - */ - fun createComponent(dependencies: Dependencies): DisposableScope - - /** - * Creates a [Flow] object which contains a sequence of related screens a user may navigate - * between to perform a task. A shared [FlowComponent] is passed to individual feature factories - * to help initialize the state management and view rendering logic. - * - * ```kotlin - * override fun createFlow(): Flow { - * return Flow.build { - * bind(AuthRootFeatureFactory()) - * bind(LoginFeatureFactory()) - * bind(SignUpFeatureFactory()) - * bind(SingleSignOnFeatureFactory()) - * } - * } - * ``` - */ - fun createFlow(): Flow -} \ No newline at end of file diff --git a/formula-android/src/main/java/com/instacart/formula/android/FragmentFlowStore.kt b/formula-android/src/main/java/com/instacart/formula/android/FragmentFlowStore.kt index d979577c0..7302e0437 100644 --- a/formula-android/src/main/java/com/instacart/formula/android/FragmentFlowStore.kt +++ b/formula-android/src/main/java/com/instacart/formula/android/FragmentFlowStore.kt @@ -1,24 +1,18 @@ package com.instacart.formula.android -import com.instacart.formula.Evaluation -import com.instacart.formula.Formula import com.instacart.formula.RuntimeConfig -import com.instacart.formula.Snapshot -import com.instacart.formula.android.internal.Binding import com.instacart.formula.android.events.FragmentLifecycleEvent -import com.instacart.formula.android.internal.FeatureObservableAction +import com.instacart.formula.android.internal.FragmentFlowStoreFormula import com.instacart.formula.android.utils.MainThreadDispatcher -import com.instacart.formula.rxjava3.RxAction import com.instacart.formula.rxjava3.toObservable -import com.jakewharton.rxrelay3.PublishRelay import io.reactivex.rxjava3.core.Observable /** * A FragmentFlowStore is responsible for managing the state of multiple [FragmentKey] instances. */ class FragmentFlowStore @PublishedApi internal constructor( - private val root: Binding -) : Formula() { + private val formula: FragmentFlowStoreFormula<*>, +) { companion object { inline fun init( crossinline init: FragmentStoreBuilder.() -> Unit @@ -30,124 +24,24 @@ class FragmentFlowStore @PublishedApi internal constructor( rootComponent: Component, crossinline contracts: FragmentStoreBuilder.() -> Unit ): FragmentFlowStore { - val factory: (Unit) -> DisposableScope = { - DisposableScope(component = rootComponent, onDispose = {}) - } - val bindings = FragmentStoreBuilder.build(contracts) - val root = Binding.composite(factory, bindings) - return FragmentFlowStore(root) + val formula = FragmentFlowStoreFormula(rootComponent, bindings) + return FragmentFlowStore(formula) } } - - private val lifecycleEvents = PublishRelay.create() - private val visibleContractEvents = PublishRelay.create() - private val hiddenContractEvents = PublishRelay.create() - - private val lifecycleEventStream = RxAction.fromObservable { lifecycleEvents } - private val visibleContractEventStream = RxAction.fromObservable { visibleContractEvents } - private val hiddenContractEventStream = RxAction.fromObservable { hiddenContractEvents } - internal fun onLifecycleEffect(event: FragmentLifecycleEvent) { - lifecycleEvents.accept(event) + formula.onLifecycleEffect(event) } internal fun onVisibilityChanged(contract: FragmentId, visible: Boolean) { - if (visible) { - visibleContractEvents.accept(contract) - } else { - hiddenContractEvents.accept(contract) - } - } - - override fun initialState(input: FragmentEnvironment): FragmentFlowState = FragmentFlowState() - - override fun Snapshot.evaluate(): Evaluation { - val rootInput = Binding.Input( - environment = input, - component = Unit, - activeFragments = state.activeIds, - onInitializeFeature = context.onEvent { event -> - val features = state.features.plus(event.id to event) - transition(state.copy(features = features)) - } - ) - root.bind(context, rootInput) - - return Evaluation( - output = state, - actions = context.actions { - lifecycleEventStream.onEvent { event -> - val fragmentId = event.fragmentId - when (event) { - is FragmentLifecycleEvent.Removed -> { - val updated = state.copy( - activeIds = state.activeIds.minus(fragmentId), - states = state.states.minus(fragmentId), - features = state.features.minus(fragmentId) - ) - transition(updated) - } - is FragmentLifecycleEvent.Added -> { - if (!state.activeIds.contains(fragmentId)) { - if (root.binds(fragmentId.key)) { - val updated = state.copy(activeIds = state.activeIds.plus(fragmentId)) - transition(updated) - } else { - val updated = state.copy( - activeIds = state.activeIds.plus(fragmentId), - features = state.features.plus(fragmentId to FeatureEvent.MissingBinding(fragmentId)) - ) - transition(updated) - } - } else { - none() - } - } - } - } - - visibleContractEventStream.onEvent { - if (state.visibleIds.contains(it)) { - // TODO: should we log this duplicate visibility event? - none() - } else { - transition(state.copy(visibleIds = state.visibleIds.plus(it))) - } - } - - hiddenContractEventStream.onEvent { - transition(state.copy(visibleIds = state.visibleIds.minus(it))) - } - - state.features.entries.forEach { entry -> - val fragmentId = entry.key - val feature = (entry.value as? FeatureEvent.Init)?.feature - if (feature != null) { - val action = FeatureObservableAction( - fragmentEnvironment = input, - fragmentId = fragmentId, - feature = feature, - ) - action.onEvent { - if (state.activeIds.contains(fragmentId)) { - val keyState = FragmentState(fragmentId.key, it) - transition(state.copy(states = state.states.plus(fragmentId to keyState))) - } else { - none() - } - } - } - } - } - ) + formula.onVisibilityChanged(contract, visible) } internal fun state(environment: FragmentEnvironment): Observable { val config = RuntimeConfig( defaultDispatcher = MainThreadDispatcher(), ) - return toObservable(environment, config) + return formula.toObservable(environment, config) } } diff --git a/formula-android/src/main/java/com/instacart/formula/android/FragmentStoreBuilder.kt b/formula-android/src/main/java/com/instacart/formula/android/FragmentStoreBuilder.kt index 8f2c1bc5a..f3e906233 100644 --- a/formula-android/src/main/java/com/instacart/formula/android/FragmentStoreBuilder.kt +++ b/formula-android/src/main/java/com/instacart/formula/android/FragmentStoreBuilder.kt @@ -1,45 +1,26 @@ package com.instacart.formula.android -import com.instacart.formula.android.internal.Binding -import com.instacart.formula.android.internal.Bindings -import com.instacart.formula.android.internal.FunctionUtils import com.instacart.formula.android.internal.FeatureBinding +import com.instacart.formula.android.internal.MappedFeatureFactory import java.lang.IllegalStateException import kotlin.reflect.KClass /** - * A class used by [FragmentFlowStore] and [Flow] to register [fragment keys][FragmentKey] and their + * A class used by [FragmentFlowStore] to register [fragment keys][FragmentKey] and their * feature factories. */ class FragmentStoreBuilder { companion object { - @PublishedApi internal inline fun build( init: FragmentStoreBuilder.() -> Unit - ): Bindings { + ): List> { return FragmentStoreBuilder().apply(init).build() } } private val types = mutableSetOf>() - private val bindings: MutableList> = mutableListOf() - - /** - * Binds a [feature factory][FeatureFactory] for a specific [key][type]. - * - * @param type The class which describes the [key][Key]. - * @param featureFactory Feature factory that provides state observable and view rendering logic. - * @param toDependencies Maps [Component] to feature factory [dependencies][Dependencies]. - */ - fun bind( - type : KClass, - featureFactory: FeatureFactory, - toDependencies: (Component) -> Dependencies - ) = apply { - val binding = FeatureBinding(type.java, featureFactory, toDependencies) - bind(binding as Binding) - } + private val bindings: MutableList> = mutableListOf() /** * Binds a [feature factory][FeatureFactory] for a specific [key][type]. @@ -49,13 +30,14 @@ class FragmentStoreBuilder { */ fun bind( type : KClass, - featureFactory: FeatureFactory + featureFactory: FeatureFactory, ) = apply { - bind(type, featureFactory, FunctionUtils.identity()) + val binding = FeatureBinding(type.java, featureFactory) + bind(type.java, binding) } /** - * A convenience inline function that binds a feature factory for a specific [key][Key]. + * Binds a feature factory for a [Key]. * * @param featureFactory Feature factory that provides state observable and view rendering logic. */ @@ -65,22 +47,6 @@ class FragmentStoreBuilder { bind(Key::class, featureFactory) } - /** - * A convenience inline function that binds a feature factory for a specific [key][Key]. - * - * @param featureFactory Feature factory that provides state observable and view rendering logic. - */ - inline fun bind( - crossinline initFeature: (Component, Key) -> Feature, - ) = apply { - val factory = object : FeatureFactory { - override fun initialize(dependencies: Component, key: Key): Feature { - return initFeature(dependencies, key) - } - } - bind(Key::class, factory) - } - /** * A convenience inline function that binds a feature factory for a specific [key][Key]. * @@ -91,44 +57,23 @@ class FragmentStoreBuilder { featureFactory: FeatureFactory, noinline toDependencies: (Component) -> Dependencies ) = apply { - bind(Key::class, featureFactory, toDependencies) - } - - /** - * Binds a flow factory. - */ - fun bind(flowFactory: FlowFactory) = apply { - val binding = Binding.composite(flowFactory) - bind(binding) - } - - /** - * Binds a flow factory. - */ - fun bind( - flowFactory: FlowFactory, - toDependencies: (Component) -> Dependencies - ) = apply { - val binding = Binding.composite(flowFactory, toDependencies) - bind(binding) + val mapped = MappedFeatureFactory( + delegate = featureFactory, + toDependencies = toDependencies, + ) + bind(Key::class, mapped) } @PublishedApi - internal fun build(): Bindings { - return Bindings( - types = types, - bindings = bindings - ) + internal fun build(): List> { + return bindings } - private fun bind(binding: Binding) = apply { - binding.types().forEach { - if (types.contains(it)) { - throw IllegalStateException("Binding for $it already exists") - } - types += it + private fun bind(type: Class<*>, binding: FeatureBinding) = apply { + if (types.contains(type)) { + throw IllegalStateException("Binding for $type already exists") } - + types += type bindings += binding } } diff --git a/formula-android/src/main/java/com/instacart/formula/android/internal/Binding.kt b/formula-android/src/main/java/com/instacart/formula/android/internal/Binding.kt deleted file mode 100644 index efb4aa7fc..000000000 --- a/formula-android/src/main/java/com/instacart/formula/android/internal/Binding.kt +++ /dev/null @@ -1,63 +0,0 @@ -package com.instacart.formula.android.internal - -import com.instacart.formula.FormulaContext -import com.instacart.formula.android.FeatureEvent -import com.instacart.formula.android.FlowFactory -import com.instacart.formula.android.FragmentEnvironment -import com.instacart.formula.android.FragmentId - -/** - * Defines how specific keys bind to the state management associated - */ -@PublishedApi -internal abstract class Binding { - companion object { - fun composite( - flowFactory: FlowFactory, - ): Binding { - return composite( - flowFactory::createComponent, - flowFactory.createFlow().bindings - ) - } - - fun composite( - flowFactory: FlowFactory, - toDependencies: (ParentComponent) -> Dependencies, - ): Binding { - return composite( - scopeFactory = { component -> - val dependencies = toDependencies(component) - flowFactory.createComponent(dependencies) - }, - flowFactory.createFlow().bindings - ) - } - - @PublishedApi internal fun composite( - scopeFactory: ComponentFactory, - bindings: Bindings - ): Binding { - return CompositeBinding(scopeFactory, bindings.types, bindings.bindings) - } - } - - data class Input( - val environment: FragmentEnvironment, - val component: Component, - val activeFragments: List, - val onInitializeFeature: (FeatureEvent) -> Unit, - ) - - internal abstract fun types(): Set> - - /** - * Returns true if this binding handles this [key] - */ - internal abstract fun binds(key: Any): Boolean - - /** - * Listens for active key changes and triggers [Input.onStateChanged] events. - */ - internal abstract fun bind(context: FormulaContext<*, *>, input: Input) -} diff --git a/formula-android/src/main/java/com/instacart/formula/android/internal/Bindings.kt b/formula-android/src/main/java/com/instacart/formula/android/internal/Bindings.kt deleted file mode 100644 index f43d42819..000000000 --- a/formula-android/src/main/java/com/instacart/formula/android/internal/Bindings.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.instacart.formula.android.internal - -internal class Bindings( - val types: Set>, - val bindings: List> -) diff --git a/formula-android/src/main/java/com/instacart/formula/android/internal/ComponentFactory.kt b/formula-android/src/main/java/com/instacart/formula/android/internal/ComponentFactory.kt deleted file mode 100644 index 21fca5287..000000000 --- a/formula-android/src/main/java/com/instacart/formula/android/internal/ComponentFactory.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.instacart.formula.android.internal - -import com.instacart.formula.android.DisposableScope - -/** - * Component factory creates a child component from a parent component. - */ -internal typealias ComponentFactory = (ParentComponent) -> DisposableScope diff --git a/formula-android/src/main/java/com/instacart/formula/android/internal/CompositeBinding.kt b/formula-android/src/main/java/com/instacart/formula/android/internal/CompositeBinding.kt deleted file mode 100644 index 1116b198c..000000000 --- a/formula-android/src/main/java/com/instacart/formula/android/internal/CompositeBinding.kt +++ /dev/null @@ -1,84 +0,0 @@ -package com.instacart.formula.android.internal - -import com.instacart.formula.Action -import com.instacart.formula.Evaluation -import com.instacart.formula.Formula -import com.instacart.formula.FormulaContext -import com.instacart.formula.Snapshot -import com.instacart.formula.android.DisposableScope - -/** - * Defines how a group of keys should be bound to their integrations. - * - * @param ParentComponent A component associated with the parent. Often this will map to the parent dagger component. - * @param ScopedComponent A component that is initialized when user enters this flow and is shared between - * all the screens within the flow. Component will be destroyed when user exists the flow. - */ -internal class CompositeBinding( - private val scopeFactory: ComponentFactory, - private val types: Set>, - private val bindings: List> -) : Binding() { - - data class State( - val component: DisposableScope? = null - ) - - private val formula = object : Formula, State, Unit>() { - override fun key(input: Input): Any? = this - - override fun initialState(input: Input): State { - return State() - } - - override fun Snapshot, State>.evaluate(): Evaluation { - val component = state.component - if (component != null) { - val childInput = Input( - environment = input.environment, - component = component.component, - activeFragments = input.activeFragments, - onInitializeFeature = input.onInitializeFeature, - ) - bindings.forEachIndices { - it.bind(context, childInput) - } - } - return Evaluation( - output = Unit, - actions = context.actions { - val isInScope = input.activeFragments.any { binds(it.key) } - Action.onData(isInScope).onEvent { - if (isInScope && component == null) { - transition(State(component = scopeFactory.invoke(input.component))) - } else if (!isInScope && component != null) { - transition(State()) { - component.dispose() - } - } else { - none() - } - } - - Action.onTerminate().onEvent { - transition { component?.dispose() } - } - } - ) - } - } - - - override fun types(): Set> = types - - override fun binds(key: Any): Boolean { - bindings.forEachIndices { - if (it.binds(key)) return true - } - return false - } - - override fun bind(context: FormulaContext<*, *>, input: Input) { - context.child(formula, input) - } -} diff --git a/formula-android/src/main/java/com/instacart/formula/android/internal/FeatureBinding.kt b/formula-android/src/main/java/com/instacart/formula/android/internal/FeatureBinding.kt index 4c4db8672..fd4e7732e 100644 --- a/formula-android/src/main/java/com/instacart/formula/android/internal/FeatureBinding.kt +++ b/formula-android/src/main/java/com/instacart/formula/android/internal/FeatureBinding.kt @@ -1,67 +1,12 @@ package com.instacart.formula.android.internal -import com.instacart.formula.Action -import com.instacart.formula.Evaluation -import com.instacart.formula.Formula -import com.instacart.formula.FormulaContext -import com.instacart.formula.Snapshot import com.instacart.formula.android.FeatureFactory import com.instacart.formula.android.FragmentKey -import com.instacart.formula.android.FeatureEvent /** - * Defines how a specific key should be bound to its [FeatureFactory], + * Defines how a specific key should be bound to its [FeatureFactory] */ -internal class FeatureBinding( - private val type: Class, - private val feature: FeatureFactory, - private val toDependencies: (Component) -> Dependencies -) : Binding() { - - private val formula = object : Formula, Unit, Unit>() { - override fun key(input: Input): Any = type - - override fun initialState(input: Input) = Unit - - override fun Snapshot, Unit>.evaluate(): Evaluation { - return Evaluation( - output = state, - actions = context.actions { - input.activeFragments.forEachIndices { fragmentId -> - val key = fragmentId.key - if (binds(key)) { - Action.onData(fragmentId).onEvent { - transition { - try { - val dependencies = toDependencies(input.component) - val feature = input.environment.fragmentDelegate.initializeFeature( - fragmentId = fragmentId, - factory = feature, - dependencies = dependencies, - key = key as Key, - ) - input.onInitializeFeature(FeatureEvent.Init(fragmentId, feature)) - } catch (e: Exception) { - input.onInitializeFeature(FeatureEvent.Failure(fragmentId, e)) - } - } - } - } - } - } - ) - } - } - - override fun types(): Set> { - return setOf(type) - } - - override fun binds(key: Any): Boolean { - return type.isInstance(key) - } - - override fun bind(context: FormulaContext<*, *>, input: Input) { - context.child(formula, input) - } -} +class FeatureBinding( + val type: Class, + val feature: FeatureFactory, +) diff --git a/formula-android/src/main/java/com/instacart/formula/android/internal/FragmentFlowStoreFormula.kt b/formula-android/src/main/java/com/instacart/formula/android/internal/FragmentFlowStoreFormula.kt new file mode 100644 index 000000000..7b2bfb3c6 --- /dev/null +++ b/formula-android/src/main/java/com/instacart/formula/android/internal/FragmentFlowStoreFormula.kt @@ -0,0 +1,135 @@ +package com.instacart.formula.android.internal + +import com.instacart.formula.Evaluation +import com.instacart.formula.Formula +import com.instacart.formula.Snapshot +import com.instacart.formula.android.FeatureEvent +import com.instacart.formula.android.FeatureFactory +import com.instacart.formula.android.FragmentEnvironment +import com.instacart.formula.android.FragmentFlowState +import com.instacart.formula.android.FragmentId +import com.instacart.formula.android.FragmentKey +import com.instacart.formula.android.FragmentState +import com.instacart.formula.android.events.FragmentLifecycleEvent +import com.instacart.formula.rxjava3.RxAction +import com.jakewharton.rxrelay3.PublishRelay + +@PublishedApi +internal class FragmentFlowStoreFormula( + private val component: Component, + private val bindings: List>, +) : Formula(){ + private val lifecycleEvents = PublishRelay.create() + private val visibleContractEvents = PublishRelay.create() + private val hiddenContractEvents = PublishRelay.create() + + private val lifecycleEventStream = RxAction.fromObservable { lifecycleEvents } + private val visibleContractEventStream = RxAction.fromObservable { visibleContractEvents } + private val hiddenContractEventStream = RxAction.fromObservable { hiddenContractEvents } + + fun onLifecycleEffect(event: FragmentLifecycleEvent) { + lifecycleEvents.accept(event) + } + + fun onVisibilityChanged(contract: FragmentId, visible: Boolean) { + if (visible) { + visibleContractEvents.accept(contract) + } else { + hiddenContractEvents.accept(contract) + } + } + + override fun initialState(input: FragmentEnvironment): FragmentFlowState = FragmentFlowState() + + override fun Snapshot.evaluate(): Evaluation { + return Evaluation( + output = state, + actions = context.actions { + lifecycleEventStream.onEvent { event -> + val fragmentId = event.fragmentId + when (event) { + is FragmentLifecycleEvent.Removed -> { + val updated = state.copy( + activeIds = state.activeIds.minus(fragmentId), + states = state.states.minus(fragmentId), + features = state.features.minus(fragmentId) + ) + transition(updated) + } + is FragmentLifecycleEvent.Added -> { + if (!state.activeIds.contains(fragmentId)) { + val feature = initFeature(input, fragmentId) + val updated = state.copy( + activeIds = state.activeIds.plus(fragmentId), + features = state.features.plus(feature.id to feature) + ) + transition(updated) + } else { + none() + } + } + } + } + + visibleContractEventStream.onEvent { + if (state.visibleIds.contains(it)) { + // TODO: should we log this duplicate visibility event? + none() + } else { + transition(state.copy(visibleIds = state.visibleIds.plus(it))) + } + } + + hiddenContractEventStream.onEvent { + transition(state.copy(visibleIds = state.visibleIds.minus(it))) + } + + state.features.entries.forEach { entry -> + val fragmentId = entry.key + val feature = (entry.value as? FeatureEvent.Init)?.feature + if (feature != null) { + val action = FeatureObservableAction( + fragmentEnvironment = input, + fragmentId = fragmentId, + feature = feature, + ) + action.onEvent { + if (state.activeIds.contains(fragmentId)) { + val keyState = FragmentState(fragmentId.key, it) + transition(state.copy(states = state.states.plus(fragmentId to keyState))) + } else { + none() + } + } + } + } + } + ) + } + + private fun initFeature( + environment: FragmentEnvironment, + fragmentId: FragmentId, + ): FeatureEvent { + val initialized = try { + bindings.firstNotNullOfOrNull { binding -> + if (binding.type.isInstance(fragmentId.key)) { + val featureFactory = binding.feature as FeatureFactory + val feature = environment.fragmentDelegate.initializeFeature( + fragmentId = fragmentId, + factory = featureFactory, + dependencies = component, + key = fragmentId.key, + ) + FeatureEvent.Init(fragmentId, feature) + } else { + null + } + } + } catch (e: Exception) { + FeatureEvent.Failure(fragmentId, e) + } + + return initialized ?: FeatureEvent.MissingBinding(fragmentId) + } +} \ No newline at end of file diff --git a/formula-android/src/main/java/com/instacart/formula/android/internal/FunctionUtils.kt b/formula-android/src/main/java/com/instacart/formula/android/internal/FunctionUtils.kt deleted file mode 100644 index 14c8adf26..000000000 --- a/formula-android/src/main/java/com/instacart/formula/android/internal/FunctionUtils.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.instacart.formula.android.internal - -internal object FunctionUtils { - fun identity(): (C) -> C { - return { it } - } -} \ No newline at end of file diff --git a/formula-android/src/main/java/com/instacart/formula/android/internal/MappedFeatureFactory.kt b/formula-android/src/main/java/com/instacart/formula/android/internal/MappedFeatureFactory.kt new file mode 100644 index 000000000..ca061e7c2 --- /dev/null +++ b/formula-android/src/main/java/com/instacart/formula/android/internal/MappedFeatureFactory.kt @@ -0,0 +1,18 @@ +package com.instacart.formula.android.internal + +import com.instacart.formula.android.Feature +import com.instacart.formula.android.FeatureFactory +import com.instacart.formula.android.FragmentKey + +@PublishedApi +internal class MappedFeatureFactory( + private val delegate: FeatureFactory, + private val toDependencies: (Component) -> Dependencies, +) : FeatureFactory { + override fun initialize(dependencies: Component, key: Key): Feature { + return delegate.initialize( + dependencies = toDependencies(dependencies), + key = key, + ) + } +} \ No newline at end of file diff --git a/formula-android/src/test/java/com/instacart/formula/android/FlowFactoryTest.kt b/formula-android/src/test/java/com/instacart/formula/android/FlowFactoryTest.kt deleted file mode 100644 index dd4bbc29d..000000000 --- a/formula-android/src/test/java/com/instacart/formula/android/FlowFactoryTest.kt +++ /dev/null @@ -1,64 +0,0 @@ -package com.instacart.formula.android - -import com.google.common.truth.Truth -import com.instacart.formula.android.fakes.NoOpFeatureFactory -import com.instacart.formula.android.fakes.TestAccountFragmentKey -import com.instacart.formula.android.fakes.TestLoginFragmentKey -import com.instacart.formula.android.fakes.TestSignUpFragmentKey -import com.instacart.formula.android.internal.Binding -import org.junit.Test - -class FlowFactoryTest { - - class AuthFlowFactory : FlowFactory { - override fun createComponent(dependencies: Unit): DisposableScope { - return DisposableScope(Unit) {} - } - - override fun createFlow(): Flow { - return Flow.build { - bind(NoOpFeatureFactory()) - bind(NoOpFeatureFactory()) - } - } - } - - class EmptyFlowFactory : FlowFactory { - override fun createComponent(dependencies: Dependencies): DisposableScope { - return DisposableScope(Unit, {}) - } - - override fun createFlow(): Flow { - return Flow.build { } - } - } - - @Test - fun `binds only declared contracts`() { - - val binding = Binding.composite(AuthFlowFactory()) - Truth.assertThat(binding.binds(TestLoginFragmentKey())).isTrue() - Truth.assertThat(binding.binds(TestSignUpFragmentKey())).isTrue() - - Truth.assertThat(binding.binds(TestAccountFragmentKey())).isFalse() - } - - @Test - fun `bind flow factory with Any dependency type`() { - val store = FragmentFlowStore.init("Component") { - // If it compiles, it's a success - bind(EmptyFlowFactory()) - } - } - - @Test - fun `bind flow factory with to dependencies defined`() { - val flowFactoryWithStringDependencies = EmptyFlowFactory() - val store = FragmentFlowStore.init(100) { - // If it compiles, it's a success - bind(flowFactoryWithStringDependencies) { component -> - component.toString() - } - } - } -} \ No newline at end of file diff --git a/formula-android/src/test/java/com/instacart/formula/android/FragmentFlowStoreTest.kt b/formula-android/src/test/java/com/instacart/formula/android/FragmentFlowStoreTest.kt index 0a48aad15..516566f9f 100644 --- a/formula-android/src/test/java/com/instacart/formula/android/FragmentFlowStoreTest.kt +++ b/formula-android/src/test/java/com/instacart/formula/android/FragmentFlowStoreTest.kt @@ -4,14 +4,10 @@ import android.os.Looper import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat import com.instacart.formula.android.fakes.DetailKey -import com.instacart.formula.android.fakes.FakeAuthFlowFactory import com.instacart.formula.android.fakes.FakeComponent import com.instacart.formula.android.fakes.MainKey import com.instacart.formula.android.events.FragmentLifecycleEvent import com.instacart.formula.android.fakes.NoOpViewFactory -import com.instacart.formula.android.fakes.TestAccountFragmentKey -import com.instacart.formula.android.fakes.TestLoginFragmentKey -import com.instacart.formula.android.fakes.TestSignUpFragmentKey import io.reactivex.rxjava3.observers.TestObserver import org.junit.Test import org.junit.runner.RunWith @@ -27,89 +23,17 @@ class FragmentFlowStoreTest { var exception: Throwable? = null try { FragmentFlowStore.init(FakeComponent()) { - bind(FakeAuthFlowFactory()) - bind(FakeAuthFlowFactory()) + bind(TestFeatureFactory()) + bind(TestFeatureFactory()) } } catch (t: Throwable) { exception = t } assertThat(exception?.message).isEqualTo( - "Binding for class com.instacart.formula.android.fakes.TestLoginFragmentKey already exists" + "Binding for class com.instacart.formula.android.fakes.MainKey already exists" ) } - @Test fun `component is shared between flow features`() { - val appComponent = FakeComponent() - val store = createStore(appComponent) - store - .state(FragmentEnvironment()) - .test() - .apply { - store.onLifecycleEffect(TestLoginFragmentKey().asAddedEvent()) - store.onLifecycleEffect(TestSignUpFragmentKey().asAddedEvent()) - } - - val components = appComponent.initialized.map { it.first } - assertThat(components).hasSize(2) - assertThat(components[0]).isEqualTo(components[1]) - } - - @Test fun `component is disposed once flow exits`() { - val appComponent = FakeComponent() - val store = createStore(appComponent) - store - .state(FragmentEnvironment()) - .test() - .apply { - store.onLifecycleEffect(TestLoginFragmentKey().asAddedEvent()) - store.onLifecycleEffect(TestSignUpFragmentKey().asAddedEvent()) - } - .apply { - assertThat(appComponent.initialized).hasSize(2) - } - .apply { - store.onLifecycleEffect(TestSignUpFragmentKey().asRemovedEvent()) - store.onLifecycleEffect(TestLoginFragmentKey().asRemovedEvent()) - } - .apply { - assertThat(appComponent.initialized).hasSize(0) - } - } - - @Test fun `component is alive if we enter another feature`() { - val appComponent = FakeComponent() - val store = createStore(appComponent) - store - .state(FragmentEnvironment()) - .test() - .apply { - store.onLifecycleEffect(TestLoginFragmentKey().asAddedEvent()) - store.onLifecycleEffect(TestSignUpFragmentKey().asAddedEvent()) - store.onLifecycleEffect(TestAccountFragmentKey().asAddedEvent()) - } - .apply { - assertThat(appComponent.initialized).hasSize(2) - } - } - - @Test fun `unsubscribe disposes of component`() { - val appComponent = FakeComponent() - val store = createStore(appComponent) - store - .state(FragmentEnvironment()) - .test() - .apply { - store.onLifecycleEffect(TestLoginFragmentKey().asAddedEvent()) - } - .apply { - assertThat(appComponent.initialized).hasSize(1) - } - .dispose() - .apply { - assertThat(appComponent.initialized).hasSize(0) - } - } - @Test fun `subscribed to state until removed from backstack`() { val master = MainKey(1) val detail = DetailKey(1) @@ -257,8 +181,6 @@ class FragmentFlowStoreTest { fun createStore(component: FakeComponent): FragmentFlowStore { return FragmentFlowStore.init(component) { - bind(FakeAuthFlowFactory()) - bind(TestFeatureFactory()) bind(TestFeatureFactory()) } diff --git a/formula-android/src/test/java/com/instacart/formula/android/fakes/FakeAuthFlowFactory.kt b/formula-android/src/test/java/com/instacart/formula/android/fakes/FakeAuthFlowFactory.kt deleted file mode 100644 index a1e7ee53b..000000000 --- a/formula-android/src/test/java/com/instacart/formula/android/fakes/FakeAuthFlowFactory.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.instacart.formula.android.fakes - -import com.instacart.formula.android.Flow -import com.instacart.formula.android.FlowFactory -import com.instacart.formula.android.DisposableScope -import com.instacart.formula.android.Feature -import com.instacart.formula.android.FeatureFactory -import com.instacart.formula.android.FragmentKey -import io.reactivex.rxjava3.core.Observable - -class FakeAuthFlowFactory : FlowFactory { - class Component( - val onInitialized: (Component, FragmentKey) -> Unit - ) - - override fun createComponent(dependencies: FakeComponent): DisposableScope { - return dependencies.createAuthFlowComponent() - } - - override fun createFlow(): Flow { - return Flow.build { - bind(TestFeatureFactory()) - bind(TestFeatureFactory()) - } - } - - class TestFeatureFactory : FeatureFactory { - override fun initialize(dependencies: Component, key: FragmentKeyT): Feature { - dependencies.onInitialized(dependencies, key) - return Feature( - state = Observable.empty(), - viewFactory = NoOpViewFactory() - ) - } - } -} \ No newline at end of file diff --git a/formula-android/src/test/java/com/instacart/formula/android/fakes/FakeComponent.kt b/formula-android/src/test/java/com/instacart/formula/android/fakes/FakeComponent.kt index 3b86543ee..b25f80d30 100644 --- a/formula-android/src/test/java/com/instacart/formula/android/fakes/FakeComponent.kt +++ b/formula-android/src/test/java/com/instacart/formula/android/fakes/FakeComponent.kt @@ -1,25 +1,14 @@ package com.instacart.formula.android.fakes import com.instacart.formula.android.FragmentKey -import com.instacart.formula.android.DisposableScope import com.jakewharton.rxrelay3.PublishRelay import io.reactivex.rxjava3.core.Observable class FakeComponent { - val initialized = mutableListOf>() val updateRelay: PublishRelay> = PublishRelay.create() fun state(key: FragmentKey): Observable { val updates = updateRelay.filter { it.first == key }.map { it.second } return updates.startWithItem("${key.tag}-state") } - - fun createAuthFlowComponent(): DisposableScope { - val component = FakeAuthFlowFactory.Component(onInitialized = { component, key -> - initialized.add(component to key) - }) - return DisposableScope(component, { - initialized.clear() - }) - } } \ No newline at end of file diff --git a/formula-android/src/test/java/com/instacart/formula/android/fakes/TestAccountFragmentKey.kt b/formula-android/src/test/java/com/instacart/formula/android/fakes/TestAccountFragmentKey.kt deleted file mode 100644 index 0c669cda6..000000000 --- a/formula-android/src/test/java/com/instacart/formula/android/fakes/TestAccountFragmentKey.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.instacart.formula.android.fakes - -import com.instacart.formula.android.FragmentKey -import kotlinx.parcelize.Parcelize - -@Parcelize -data class TestAccountFragmentKey( - override val tag: String = "account fragment", -) : FragmentKey diff --git a/formula-android/src/test/java/com/instacart/formula/android/fakes/TestLoginFragmentKey.kt b/formula-android/src/test/java/com/instacart/formula/android/fakes/TestLoginFragmentKey.kt deleted file mode 100644 index d01fbde6e..000000000 --- a/formula-android/src/test/java/com/instacart/formula/android/fakes/TestLoginFragmentKey.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.instacart.formula.android.fakes - -import com.instacart.formula.android.FragmentKey -import kotlinx.parcelize.Parcelize - -@Parcelize -data class TestLoginFragmentKey( - override val tag: String = "login fragment", -) : FragmentKey diff --git a/formula-android/src/test/java/com/instacart/formula/android/fakes/TestSignUpFragmentKey.kt b/formula-android/src/test/java/com/instacart/formula/android/fakes/TestSignUpFragmentKey.kt deleted file mode 100644 index 0e3a2b44c..000000000 --- a/formula-android/src/test/java/com/instacart/formula/android/fakes/TestSignUpFragmentKey.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.instacart.formula.android.fakes - -import com.instacart.formula.android.FragmentKey -import kotlinx.parcelize.Parcelize - -@Parcelize -data class TestSignUpFragmentKey( - override val tag: String = "sign up fragment", -) : FragmentKey diff --git a/formula-android/src/test/java/com/instacart/formula/android/internal/FunctionUtilsTest.kt b/formula-android/src/test/java/com/instacart/formula/android/internal/FunctionUtilsTest.kt deleted file mode 100644 index 6ed280f0c..000000000 --- a/formula-android/src/test/java/com/instacart/formula/android/internal/FunctionUtilsTest.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.instacart.formula.android.internal - -import com.google.common.truth.Truth -import org.junit.Test - -class FunctionUtilsTest { - - @Test - fun `identity is optimized to return the same value`() { - val stringIdentity = FunctionUtils.identity() - val intIdentity = FunctionUtils.identity() - Truth.assertThat(stringIdentity).isEqualTo(intIdentity) - } -} \ No newline at end of file