diff --git a/.idea/compiler.xml b/.idea/compiler.xml index fb7f4a8..b589d56 100644 --- a/.idea/compiler.xml +++ b/.idea/compiler.xml @@ -1,6 +1,6 @@ - + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index 5c30b7b..26b0f5e 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,4 +1,3 @@ - - + diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index e7fc0e8..acd5749 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Mon May 30 19:20:45 BST 2022 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-all.zip distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME diff --git a/kompot/build.gradle b/kompot/build.gradle index 577ad67..e9e30b9 100644 --- a/kompot/build.gradle +++ b/kompot/build.gradle @@ -13,6 +13,8 @@ android { buildToolsVersion androidBuildToolsVersion targetSdkVersion androidTargetSdkVersion minSdkVersion androidMinSdkVersion + + testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' } sourceSets { @@ -39,10 +41,15 @@ android { } } +tasks.withType(Test) { + javaLauncher.set(javaToolchains.launcherFor { + languageVersion.set(JavaLanguageVersion.of(11)) + }) +} + dependencies { api "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion" api "com.google.dagger:dagger:$daggerVersion" - api "com.revolut.recyclerkit:rxdiffadapter:$recyclerKitRxDiffAdapterVersion" api "com.revolut.recyclerkit:delegates:$recyclerKitDelegatesVersion" api "androidx.constraintlayout:constraintlayout:$constraintLayoutVersion" api "androidx.coordinatorlayout:coordinatorlayout:$coordinatorLayoutVersion" @@ -66,6 +73,8 @@ dependencies { testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:$mockitoKotlinVersion" testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion" testImplementation "junit:junit:$junitVersion" + testImplementation "androidx.test:core-ktx:$androidxTestVersion" + testImplementation project(':kompot_coroutines_test') testImplementation("org.robolectric:robolectric:$robolectricVersion") { exclude group: "com.google.auto.service", module: "auto-service" diff --git a/kompot/gradle.properties b/kompot/gradle.properties index 3b9a921..a63b8fd 100644 --- a/kompot/gradle.properties +++ b/kompot/gradle.properties @@ -1,21 +1,5 @@ -# -# Copyright (C) 2022 Revolut -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - POM_ARTIFACT_ID=kompot -VERSION_NAME=0.0.2 +VERSION_NAME=0.0.3 POM_NAME=kompot POM_PACKAGING=aar GROUP=com.revolut.kompot \ No newline at end of file diff --git a/kompot/kompot_dependencies_versions.gradle b/kompot/kompot_dependencies_versions.gradle index 91cd254..e242ce6 100644 --- a/kompot/kompot_dependencies_versions.gradle +++ b/kompot/kompot_dependencies_versions.gradle @@ -1,8 +1,8 @@ ext { - androidBuildToolsVersion = '30.0.3' - androidMinSdkVersion = 23 + androidBuildToolsVersion = '33.0.2' + androidMinSdkVersion = 24 androidTargetSdkVersion = 31 - androidCompileSdkVersion = 31 + androidCompileSdkVersion = 33 kotlinVersion = '1.6.10' @@ -10,15 +10,15 @@ ext { daggerVersion = '2.35.1' - recyclerKitRxDiffAdapterVersion = '1.0.7' - recyclerKitDecorationsVersion = '1.0.8' - recyclerKitDelegatesVersion = '1.0.10' + recyclerKitDecorationsVersion = '1.1.0' + recyclerKitDelegatesVersion = '1.1.2' - timberVersion = '4.7.1' + timberVersion = '5.0.1' androidxCoreVersion = '1.7.0' appCompatVersion = '1.1.0' recyclerVersion = '1.1.0' + androidxTestVersion = '1.5.0' constraintLayoutVersion = '1.1.3' coordinatorLayoutVersion = '1.1.0' @@ -27,7 +27,7 @@ ext { junitVersion = '4.12' junitJupiterVersion = '5.4.2' junitVintageVersion = '5.4.2' - mockitoVersion = '3.9.0' + mockitoVersion = '5.1.1' mockitoKotlinVersion = '2.2.0' robolectricVersion = '4.5' diff --git a/kompot/src/main/kotlin/com/revolut/kompot/FeaturesRegistry.kt b/kompot/src/main/kotlin/com/revolut/kompot/FeaturesRegistry.kt index f3dd741..fffa660 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/FeaturesRegistry.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/FeaturesRegistry.kt @@ -17,10 +17,13 @@ package com.revolut.kompot import android.content.Context +import com.revolut.kompot.common.ControllerDescriptor +import com.revolut.kompot.common.ControllerHolder +import com.revolut.kompot.common.IOData import com.revolut.kompot.common.NavigationDestination +import com.revolut.kompot.common.NavigationRequest import com.revolut.kompot.navigable.Controller import com.revolut.kompot.navigable.flow.BaseFlowModel -import javax.inject.Inject interface FeaturesRegistry { fun clearFeatures(context: Context, signOut: Boolean) @@ -37,9 +40,17 @@ interface FeaturesRegistry { destination: NavigationDestination, flowModel: BaseFlowModel<*, *, *> ): Controller + + fun provideControllerOrThrow( + descriptor: ControllerDescriptor<*>, + ): ControllerHolder + + suspend fun getDestinationOrThrow( + request: NavigationRequest + ): NavigationDestination } -class DefaultFeaturesRegistry @Inject constructor() : FeaturesRegistry { +class DefaultFeaturesRegistry : FeaturesRegistry { private val featureGateways: MutableList = mutableListOf() private val featureHolders: MutableList = mutableListOf() @@ -74,25 +85,25 @@ class DefaultFeaturesRegistry @Inject constructor() : FeaturesRegistry { override fun getControllerOrThrow( destination: NavigationDestination, flowModel: BaseFlowModel<*, *, *> - ): Controller { - featureGateways.forEach { gateway -> - val controller = gateway.getController(destination, flowModel) - if (controller != null) { - return controller - } + ): Controller = + featureGateways.firstNotNullOfOrNull { gateway -> + gateway.getController(destination, flowModel) + } ?: error("Controller for $destination not found") + + override fun provideControllerOrThrow(descriptor: ControllerDescriptor<*>): ControllerHolder = + featureGateways.firstNotNullOfOrNull { gateway -> + gateway.provideController(descriptor) + } ?: error("Controller for $descriptor not found") + + override fun interceptDestination(destination: NavigationDestination): NavigationDestination? = + featureGateways.firstNotNullOfOrNull { gateway -> + gateway.interceptDestination(destination) } - throw IllegalStateException("Controller for $destination not found") - } - override fun interceptDestination(destination: NavigationDestination): NavigationDestination? { - featureGateways.forEach { gateway -> - val replacedDestination = gateway.interceptDestination(destination) - if (replacedDestination != null) { - return replacedDestination - } - } - return null - } + override suspend fun getDestinationOrThrow(request: NavigationRequest): NavigationDestination = + featureGateways.firstNotNullOfOrNull { gateway -> + gateway.getDestination(request) + } ?: error("Destination for $request not found") } interface FeatureApi @@ -106,6 +117,9 @@ interface FeatureGateway : FeatureHolder { fun interceptDestination(destination: NavigationDestination): NavigationDestination? = null + fun provideController(descriptor: ControllerDescriptor): ControllerHolder? = null + + suspend fun getDestination(request: NavigationRequest): NavigationDestination? = null } interface FeatureHolder { diff --git a/kompot/src/main/kotlin/com/revolut/kompot/KompotPlugin.kt b/kompot/src/main/kotlin/com/revolut/kompot/KompotPlugin.kt index d41b1cb..22a220a 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/KompotPlugin.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/KompotPlugin.kt @@ -16,13 +16,27 @@ package com.revolut.kompot +import com.revolut.kompot.lifecycle.ControllerLifecycleCallbacks import com.revolut.kompot.navigable.Controller import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import java.util.concurrent.CopyOnWriteArrayList object KompotPlugin { + internal val controllerShownSharedFlow = MutableSharedFlow(extraBufferCapacity = 16, onBufferOverflow = BufferOverflow.DROP_OLDEST) + internal val controllerLifecycleCallbacks = CopyOnWriteArrayList() + + @Deprecated("Use registerControllerLifecycleCallbacks with ControllerLifecycleCallbacks.onControllerAttached") + fun controllerShowingStream(): Flow = controllerShownSharedFlow.distinctUntilChanged() + + fun registerControllerLifecycleCallbacks(callbacks: ControllerLifecycleCallbacks) { + controllerLifecycleCallbacks.add(callbacks) + } - fun controllerShowingStream(): Flow = controllerShownSharedFlow + fun unregisterControllerLifecycleCallbacks(callbacks: ControllerLifecycleCallbacks) { + controllerLifecycleCallbacks.remove(callbacks) + } } \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/common/ControllerProvider.kt b/kompot/src/main/kotlin/com/revolut/kompot/common/ControllerProvider.kt new file mode 100644 index 0000000..bd3f141 --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/common/ControllerProvider.kt @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.common + +import com.revolut.kompot.navigable.vc.ViewController + +interface ControllerDescriptor { + + fun resolve(viewController: ViewController): ControllerHolder = ControllerHolder(viewController) +} + +data class ControllerRequest(val descriptor: ControllerDescriptor<*>) : Event() +data class ControllerHolder internal constructor(val controller: ViewController<*>) : EventResult \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/common/EmptyControllerContract.kt b/kompot/src/main/kotlin/com/revolut/kompot/common/EmptyControllerContract.kt new file mode 100644 index 0000000..c757890 --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/common/EmptyControllerContract.kt @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.common + +import com.revolut.kompot.navigable.screen.BaseScreenModel +import com.revolut.kompot.navigable.screen.ScreenStates +import com.revolut.kompot.navigable.screen.StateMapper + +class EmptyStateMapper : StateMapper { + override fun mapState(domainState: ScreenStates.EmptyDomain) = ScreenStates.EmptyUI +} + +class EmptyListStateMapper : StateMapper { + override fun mapState(domainState: ScreenStates.EmptyDomain) = ScreenStates.EmptyUIList +} + +class EmptyScreenModel : BaseScreenModel(EmptyStateMapper()) { + override val initialState = ScreenStates.EmptyDomain +} + +class EmptyListScreenModel : BaseScreenModel(EmptyListStateMapper()) { + override val initialState = ScreenStates.EmptyDomain +} \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/common/ErrorInterceptionEvent.kt b/kompot/src/main/kotlin/com/revolut/kompot/common/ErrorInterceptionEvent.kt new file mode 100644 index 0000000..befc9c1 --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/common/ErrorInterceptionEvent.kt @@ -0,0 +1,21 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.common + +data class ErrorInterceptionEvent(val cause: Throwable): Event() + +object ErrorInterceptedEventResult : EventResult \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/common/NavigationDispatcher.kt b/kompot/src/main/kotlin/com/revolut/kompot/common/NavigationDispatcher.kt index b7bc3b9..2508990 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/common/NavigationDispatcher.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/common/NavigationDispatcher.kt @@ -22,11 +22,13 @@ import android.content.Intent import android.net.Uri import android.os.Parcelable import com.revolut.kompot.ExperimentalBottomDialogStyle +import com.revolut.kompot.ExperimentalKompotApi +import com.revolut.kompot.navigable.Controller import com.revolut.kompot.navigable.TransitionAnimation import com.revolut.kompot.navigable.flow.Flow +import com.revolut.kompot.navigable.flow.scroller.ScrollerFlow import com.revolut.kompot.navigable.screen.Screen import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize import kotlinx.parcelize.RawValue interface NavigationDestination @@ -43,6 +45,7 @@ abstract class InternalDestination( ) : NavigationDestination, Parcelable { @IgnoredOnParcel open val animation: TransitionAnimation? = null + @IgnoredOnParcel open val addCurrentStepToBackStack: Boolean = true } @@ -50,19 +53,33 @@ abstract class InternalDestination( sealed class ModalDestination : NavigationDestination { data class ExplicitScreen( val screen: Screen, - val style: Style = Style.FULLSCREEN, + val style: Style = Style.FULLSCREEN_FADE, val onResult: ((T) -> Unit)? = null ) : ModalDestination() data class ExplicitFlow( val flow: Flow, - val style: Style = Style.FULLSCREEN, + val style: Style = Style.FULLSCREEN_FADE, + val onResult: ((T) -> Unit)? = null + ) : ModalDestination() + + @OptIn(ExperimentalKompotApi::class) + data class ExplicitScrollerFlow( + val flow: ScrollerFlow, + val style: Style = Style.FULLSCREEN_FADE, val onResult: ((T) -> Unit)? = null ) : ModalDestination() + data class CallbackController( + val controller: Controller, + val style: Style = Style.FULLSCREEN_FADE, + ) : ModalDestination() + enum class Style { - FULLSCREEN, + FULLSCREEN_FADE, + FULLSCREEN_IMMEDIATE, POPUP, + FULLSCREEN_SLIDE_FROM_BOTTOM, @ExperimentalBottomDialogStyle BOTTOM_DIALOG, diff --git a/kompot/src/main/kotlin/com/revolut/kompot/common/NavigationRequest.kt b/kompot/src/main/kotlin/com/revolut/kompot/common/NavigationRequest.kt new file mode 100644 index 0000000..785d15c --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/common/NavigationRequest.kt @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.common + +interface NavigationRequest + +data class NavigationRequestEvent( + val request: NavigationRequest +) : Event() + +data class NavigationRequestResult( + val requestResolver: suspend () -> NavigationDestination +) : EventResult \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/di/flow/BaseFlowComponent.kt b/kompot/src/main/kotlin/com/revolut/kompot/di/flow/BaseFlowComponent.kt index 9ba4fc6..1cde8ad 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/di/flow/BaseFlowComponent.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/di/flow/BaseFlowComponent.kt @@ -17,9 +17,15 @@ package com.revolut.kompot.di.flow import com.revolut.kompot.navigable.flow.BaseFlow +import com.revolut.kompot.navigable.flow.FlowExtensionsInjector +import com.revolut.kompot.navigable.flow.FlowModelExtensionsInjector import dagger.BindsInstance -interface BaseFlowComponent : ParentFlowComponent { +interface BaseFlowComponent : + ControllerComponent, + FlowExtensionsInjector, + FlowModelExtensionsInjector { + interface Builder { @BindsInstance diff --git a/kompot/src/main/kotlin/com/revolut/kompot/di/flow/BaseFlowModule.kt b/kompot/src/main/kotlin/com/revolut/kompot/di/flow/BaseFlowModule.kt index 0ef50f6..0ad251c 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/di/flow/BaseFlowModule.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/di/flow/BaseFlowModule.kt @@ -20,6 +20,7 @@ import com.revolut.kompot.di.scope.FlowQualifier import com.revolut.kompot.di.scope.FlowScope import com.revolut.kompot.navigable.Controller import com.revolut.kompot.navigable.ControllerExtension +import com.revolut.kompot.navigable.ControllerModelExtension import com.revolut.kompot.navigable.flow.BaseFlow import dagger.Binds import dagger.multibindings.Multibinds @@ -34,4 +35,9 @@ interface BaseFlowModule { @FlowScope @FlowQualifier fun provideControllerExtensions(): Set + + @Multibinds + @FlowScope + @FlowQualifier + fun provideControllerModelExtensions(): Set } \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/di/flow/ControllerComponent.kt b/kompot/src/main/kotlin/com/revolut/kompot/di/flow/ControllerComponent.kt new file mode 100644 index 0000000..3e61296 --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/di/flow/ControllerComponent.kt @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.di.flow + +interface ControllerComponent \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/di/flow/ParentFlow.kt b/kompot/src/main/kotlin/com/revolut/kompot/di/flow/ParentFlow.kt index 0290f5d..bccfab9 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/di/flow/ParentFlow.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/di/flow/ParentFlow.kt @@ -17,5 +17,6 @@ package com.revolut.kompot.di.flow interface ParentFlow { - val component: ParentFlowComponent + val component: ControllerComponent + val hasBackStack: Boolean } \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/di/flow/scroller/BaseScrollerFlowComponent.kt b/kompot/src/main/kotlin/com/revolut/kompot/di/flow/scroller/BaseScrollerFlowComponent.kt index 6f4da74..c3fae05 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/di/flow/scroller/BaseScrollerFlowComponent.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/di/flow/scroller/BaseScrollerFlowComponent.kt @@ -16,13 +16,16 @@ package com.revolut.kompot.di.flow.scroller -import com.revolut.kompot.ExperimentalKompotApi -import com.revolut.kompot.di.flow.ParentFlowComponent +import com.revolut.kompot.di.flow.ControllerComponent +import com.revolut.kompot.navigable.flow.FlowExtensionsInjector +import com.revolut.kompot.navigable.flow.FlowModelExtensionsInjector import com.revolut.kompot.navigable.flow.scroller.BaseScrollerFlow import dagger.BindsInstance -@ExperimentalKompotApi -interface BaseScrollerFlowComponent : ParentFlowComponent { +interface BaseScrollerFlowComponent : + ControllerComponent, + FlowExtensionsInjector, + FlowModelExtensionsInjector { interface Builder { @BindsInstance diff --git a/kompot/src/main/kotlin/com/revolut/kompot/di/flow/scroller/BaseScrollerFlowModule.kt b/kompot/src/main/kotlin/com/revolut/kompot/di/flow/scroller/BaseScrollerFlowModule.kt index f30ae55..c649f82 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/di/flow/scroller/BaseScrollerFlowModule.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/di/flow/scroller/BaseScrollerFlowModule.kt @@ -16,20 +16,22 @@ package com.revolut.kompot.di.flow.scroller -import com.revolut.kompot.ExperimentalKompotApi import com.revolut.kompot.di.scope.FlowQualifier import com.revolut.kompot.di.scope.FlowScope import com.revolut.kompot.navigable.Controller import com.revolut.kompot.navigable.ControllerExtension +import com.revolut.kompot.navigable.ControllerModelExtension import com.revolut.kompot.navigable.flow.scroller.BaseScrollerFlow import dagger.Binds import dagger.multibindings.Multibinds -@ExperimentalKompotApi interface BaseScrollerFlowModule { @[Binds FlowScope FlowQualifier] fun provideController(flow: BaseScrollerFlow<*, *, *>): Controller @[Multibinds FlowScope FlowQualifier] fun provideControllerExtensions(): Set + + @[Multibinds FlowScope FlowQualifier] + fun provideControllerModelExtensions(): Set } \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/di/screen/BaseScreenComponent.kt b/kompot/src/main/kotlin/com/revolut/kompot/di/screen/BaseScreenComponent.kt index 4cee2c6..ecb96d5 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/di/screen/BaseScreenComponent.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/di/screen/BaseScreenComponent.kt @@ -18,9 +18,10 @@ package com.revolut.kompot.di.screen import com.revolut.kompot.navigable.screen.ScreenExtensionsInjector import com.revolut.kompot.navigable.screen.BaseScreen +import com.revolut.kompot.navigable.screen.ScreenModelExtensionsInjector import dagger.BindsInstance -interface BaseScreenComponent : ScreenExtensionsInjector { +interface BaseScreenComponent : ScreenExtensionsInjector, ScreenModelExtensionsInjector { interface Builder { @BindsInstance diff --git a/kompot/src/main/kotlin/com/revolut/kompot/di/screen/BaseScreenModule.kt b/kompot/src/main/kotlin/com/revolut/kompot/di/screen/BaseScreenModule.kt index 87f655d..4274614 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/di/screen/BaseScreenModule.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/di/screen/BaseScreenModule.kt @@ -20,6 +20,7 @@ import com.revolut.kompot.di.scope.ScreenQualifier import com.revolut.kompot.di.scope.ScreenScope import com.revolut.kompot.navigable.Controller import com.revolut.kompot.navigable.ControllerExtension +import com.revolut.kompot.navigable.ControllerModelExtension import com.revolut.kompot.navigable.screen.BaseScreen import dagger.Binds import dagger.multibindings.Multibinds @@ -33,4 +34,8 @@ interface BaseScreenModule { @Multibinds @ScreenScope fun provideControllerExtensions(): Set + + @Multibinds + @ScreenScope + fun provideControllerModelExtensions(): Set } \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/di/screen/EmptyComponents.kt b/kompot/src/main/kotlin/com/revolut/kompot/di/screen/EmptyComponents.kt new file mode 100644 index 0000000..b063406 --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/di/screen/EmptyComponents.kt @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.di.screen + +import com.revolut.kompot.di.flow.BaseFlowComponent +import com.revolut.kompot.di.flow.scroller.BaseScrollerFlowComponent +import com.revolut.kompot.navigable.ControllerExtension +import com.revolut.kompot.navigable.ControllerModelExtension + +object EmptyScreenComponent : BaseScreenComponent { + override fun getControllerExtensions(): Set = setOf() + override fun getControllerModelExtensions(): Set = setOf() +} + +object EmptyFlowComponent : BaseFlowComponent { + override fun getControllerExtensions(): Set = setOf() + override fun getControllerModelExtensions(): Set = setOf() +} + +object EmptyScrollerFlowComponent : BaseScrollerFlowComponent { + override fun getControllerExtensions(): Set = setOf() + override fun getControllerModelExtensions(): Set = setOf() +} \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/dialog/DialogDisplayer.kt b/kompot/src/main/kotlin/com/revolut/kompot/dialog/DialogDisplayer.kt index 9ab69d7..6c2eb74 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/dialog/DialogDisplayer.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/dialog/DialogDisplayer.kt @@ -33,6 +33,13 @@ class DialogDisplayer( fun hideAllDialogs() = delegates.forEach { it.hideDialog() } + fun hideDialog(dialogModel: DialogModel<*>) { + delegates + .find { displayer -> displayer.canHandle(dialogModel) } + ?.hideDialog() + ?: throw IllegalStateException("No displayer delegate found for ${dialogModel.javaClass}") + } + fun onAttach() { delegates.forEach(DialogDisplayerDelegate<*>::onAttach) } diff --git a/kompot/src/main/kotlin/com/revolut/kompot/dialog/DialogDisplayerDelegate.kt b/kompot/src/main/kotlin/com/revolut/kompot/dialog/DialogDisplayerDelegate.kt index 014ba9c..10848de 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/dialog/DialogDisplayerDelegate.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/dialog/DialogDisplayerDelegate.kt @@ -34,7 +34,6 @@ abstract class DialogDisplayerDelegate> { onBufferOverflow = BufferOverflow.DROP_OLDEST ) - @Suppress("UNCHECKED_CAST") fun showDialog(dialogModel: DialogModel<*>): Flow { showDialogInternal(dialogModel as Model) return startObservingResult().map { it as Result } diff --git a/kompot/src/main/kotlin/com/revolut/kompot/entry_point/KompotDelegate.kt b/kompot/src/main/kotlin/com/revolut/kompot/entry_point/KompotDelegate.kt index b1dc630..e19f969 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/entry_point/KompotDelegate.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/entry_point/KompotDelegate.kt @@ -36,42 +36,69 @@ import com.revolut.kompot.navigable.cache.DefaultControllersCache import com.revolut.kompot.navigable.hooks.ControllerHook import com.revolut.kompot.navigable.hooks.HooksProvider import com.revolut.kompot.navigable.root.RootFlow +import com.revolut.kompot.utils.KompotIllegalLifecycleException internal class KompotDelegate( private val rootFlow: RootFlow<*, *>, - @LayoutRes private val defaultFlowLayout: Int?, + @LayoutRes private val defaultControllerContainer: Int?, private val trimCacheThreshold: Int, private val savedStateEnabled: Boolean = true, private val fullScreenEnabled: Boolean = true, ) : LifecycleObserver, HooksProvider { - private lateinit var controllerManager: RootControllerManager + private var rootControllerManager: RootControllerManager? = null + private var kompotHost: Fragment? = null - private var kompotHost: SavedStateRegistryOwner? = null private val hooks = mutableMapOf, ControllerHook>() fun onViewCreated(fragment: Fragment) { - val rootManagerCreated = ::controllerManager.isInitialized - if (!rootManagerCreated) { + val rootInitialised = rootControllerManager != null + if (!rootInitialised) { + val rootFlowWasCreated = rootFlow.created + if (rootFlowWasCreated) { + (rootFlow.view.parent as? ViewGroup)?.removeView(rootFlow.view) + } + setUpWindow(fragment.requireActivity()) - createKompotRoot( + kompotHost = fragment + rootControllerManager = createKompotRoot( container = fragment.view as ViewGroup, activityLauncher = ActivityFromFragmentLauncher(fragment), permissionsRequester = FragmentPermissionsRequester(fragment), savedStateOwner = fragment, ) + + fragment + .savedStateRegistry + .takeIf { savedStateEnabled } + ?.registerSavedStateProvider(KOMPOT_SAVED_STATE_KEY) { + Bundle().apply { + rootControllerManager?.saveState(this) + } + } + + fragment.lifecycle.addObserver(this) + + if (rootFlowWasCreated) { + throw KompotIllegalLifecycleException( + "Can't initialise Kompot with the pre-created flow: ${rootFlow.controllerName} is ${rootFlow.lifecycle.currentState}" + ) + } } else { //If root controller manager is already created, then onViewCreated //is invoked more than one time. That means that fragment's view was recreated //and we need to show the root flow in the new container - controllerManager.attachToHostContainer(container = fragment.view as ViewGroup) + requireRootControllerManager().apply { + detachFromHostContainer() + attachToHostContainer(container = fragment.view as ViewGroup) + } } fragment.viewLifecycleOwner.lifecycle.addObserver( object : LifecycleObserver { @OnLifecycleEvent(Lifecycle.Event.ON_DESTROY) fun onDestroy() { - controllerManager.detachFromHostContainer() + requireRootControllerManager().detachFromHostContainer() fragment.viewLifecycleOwner.lifecycle.removeObserver(this) } } @@ -89,15 +116,15 @@ internal class KompotDelegate( savedStateOwner: SavedStateRegistryOwner, activityLauncher: ActivityLauncher, permissionsRequester: PermissionsRequester, - ) { + ): RootControllerManager { val savedState = savedStateOwner .savedStateRegistry .takeIf { savedStateEnabled } ?.consumeRestoredStateForKey(KOMPOT_SAVED_STATE_KEY) - controllerManager = RootControllerManager( + return RootControllerManager( controllersCache = DefaultControllersCache(trimCacheThreshold), - defaultFlowLayout = defaultFlowLayout, + defaultControllerContainer = defaultControllerContainer, activityLauncher = activityLauncher, permissionsRequester = permissionsRequester, rootFlow = rootFlow, @@ -105,18 +132,6 @@ internal class KompotDelegate( ).apply { showRootFlow(savedState, container) } - - savedStateOwner - .savedStateRegistry - .takeIf { savedStateEnabled } - ?.registerSavedStateProvider(KOMPOT_SAVED_STATE_KEY) { - Bundle().apply { - controllerManager.saveState(this) - } - } - - savedStateOwner.lifecycle.addObserver(this) - kompotHost = savedStateOwner } fun registerHook(hook: ControllerHook, key: ControllerHook.Key<*>) { @@ -125,38 +140,38 @@ internal class KompotDelegate( @OnLifecycleEvent(Lifecycle.Event.ON_RESUME) fun onResume() { - controllerManager.onHostResumed() + requireRootControllerManager().onHostResumed() } @OnLifecycleEvent(Lifecycle.Event.ON_PAUSE) fun onPause() { - controllerManager.onHostPaused() + requireRootControllerManager().onHostPaused() } @OnLifecycleEvent(Lifecycle.Event.ON_START) fun onStart() { - controllerManager.onHostStarted() + requireRootControllerManager().onHostStarted() } @OnLifecycleEvent(Lifecycle.Event.ON_STOP) fun onStop() { - controllerManager.onHostStopped() + requireRootControllerManager().onHostStopped() } @OnLifecycleEvent(Lifecycle.Event.ON_DESTROY) fun onDestroy() { - controllerManager.onDestroy() + requireRootControllerManager().onDestroy() kompotHost?.lifecycle?.removeObserver(this) kompotHost?.savedStateRegistry?.unregisterSavedStateProvider(KOMPOT_SAVED_STATE_KEY) kompotHost = null } fun onBackPressed() { - controllerManager.handleBack() + requireRootControllerManager().handleBack() } fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - controllerManager.onActivityResult(requestCode, resultCode, data) + requireRootControllerManager().onActivityResult(requestCode, resultCode, data) } fun onRequestPermissionsResult( @@ -165,10 +180,12 @@ internal class KompotDelegate( grantResults: IntArray ) { if (permissions.isNotEmpty()) { - controllerManager.onRequestPermissionsResult(requestCode, permissions, grantResults) + requireRootControllerManager().onRequestPermissionsResult(requestCode, permissions, grantResults) } } + private fun requireRootControllerManager() = checkNotNull(rootControllerManager) + @Suppress("UNCHECKED_CAST") override fun getHook(key: ControllerHook.Key): T? = hooks[key] as? T } diff --git a/kompot/src/main/kotlin/com/revolut/kompot/entry_point/fragment/KompotConfig.kt b/kompot/src/main/kotlin/com/revolut/kompot/entry_point/fragment/KompotConfig.kt index 6888c7d..2296f00 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/entry_point/fragment/KompotConfig.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/entry_point/fragment/KompotConfig.kt @@ -23,7 +23,7 @@ import com.revolut.kompot.navigable.root.RootFlow data class KompotConfig( val rootFlow: RootFlow<*, *>, - @LayoutRes val defaultFlowLayout: Int? = R.layout.base_flow_container, + @LayoutRes val defaultControllerContainer: Int? = R.layout.base_flow_container, val trimCacheThreshold: Int = DEFAULT_TRIM_CACHE_THRESHOLD, val savedStateEnabled: Boolean = true, val fullScreenEnabled: Boolean = true, @@ -36,7 +36,7 @@ data class KompotConfig( internal fun KompotConfig.createDelegate() = KompotDelegate( rootFlow = rootFlow, - defaultFlowLayout = defaultFlowLayout, + defaultControllerContainer = defaultControllerContainer, trimCacheThreshold = trimCacheThreshold, savedStateEnabled = savedStateEnabled, fullScreenEnabled = fullScreenEnabled, diff --git a/kompot/src/main/kotlin/com/revolut/kompot/entry_point/fragment/KompotFragment.kt b/kompot/src/main/kotlin/com/revolut/kompot/entry_point/fragment/KompotFragment.kt index fa46c10..7d0c5d9 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/entry_point/fragment/KompotFragment.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/entry_point/fragment/KompotFragment.kt @@ -30,6 +30,14 @@ import com.revolut.kompot.navigable.hooks.ControllerHook abstract class KompotFragment : Fragment() { + private val onBackPressedCallback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + isEnabled = false + kompotDelegate.onBackPressed() + isEnabled = true + } + } + abstract fun config(): KompotConfig internal val kompotDelegate: KompotDelegate by lazy(LazyThreadSafetyMode.NONE) { @@ -48,7 +56,13 @@ abstract class KompotFragment : Fragment() { super.onViewCreated(view, savedInstanceState) kompotDelegate.onViewCreated(this) - requireActivity().onBackPressedDispatcher.addCallback(this.viewLifecycleOwner, getBackPressInterceptor()) + requireActivity().onBackPressedDispatcher.addCallback(onBackPressedCallback) + } + + @CallSuper + override fun onDestroyView() { + onBackPressedCallback.remove() + super.onDestroyView() } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { @@ -61,17 +75,6 @@ abstract class KompotFragment : Fragment() { kompotDelegate.onRequestPermissionsResult(requestCode, permissions, grantResults) } - private fun getBackPressInterceptor(): OnBackPressedCallback { - val enabled = true - return object : OnBackPressedCallback(enabled) { - override fun handleOnBackPressed() { - isEnabled = false - kompotDelegate.onBackPressed() - isEnabled = true - } - } - } - } fun KompotFragment.registerHook(hook: ControllerHook, key: ControllerHook.Key<*>) { diff --git a/kompot/src/main/kotlin/com/revolut/kompot/holder/ControllerTransaction.kt b/kompot/src/main/kotlin/com/revolut/kompot/holder/ControllerTransaction.kt index 4f96d5a..12b0edd 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/holder/ControllerTransaction.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/holder/ControllerTransaction.kt @@ -21,14 +21,19 @@ import com.revolut.kompot.navigable.Controller import com.revolut.kompot.navigable.ControllerManager import com.revolut.kompot.navigable.TransitionAnimation import com.revolut.kompot.navigable.transition.TransitionListener +import com.revolut.kompot.view.ControllerContainer internal class ControllerTransaction( val from: Controller?, val to: Controller?, val controllerManager: ControllerManager, - val backward: Boolean + val backward: Boolean, + val indefinite: Boolean, ) : TransitionListener { + private val controllerContainer + get() = controllerManager.controllerViewHolder.container as? ControllerContainer + fun startWith(animation: TransitionAnimation) { to?.onTransitionRunUp(true) from?.onTransitionRunUp(false) @@ -44,34 +49,57 @@ internal class ControllerTransaction( override fun onTransitionCreated() { from?.view?.visibility = View.VISIBLE to?.view?.visibility = View.VISIBLE - - from?.onDetach() - val managerAttached = controllerManager.attached - val destinationControllerAttached = to?.attached == true - if (managerAttached && !destinationControllerAttached) { - to?.onAttach() + if (controllerManager.attached) { + updateControllersVisibilityLifecycle( + controllerToDetach = from.takeIf { !indefinite }, + controllerToAttach = to, + ) } + } - if (from != null) { - controllerManager.onDetachController?.invoke(from, controllerManager) + private fun updateControllersVisibilityLifecycle( + controllerToAttach: Controller?, + controllerToDetach: Controller? + ) { + if (controllerToAttach == null && controllerToDetach == null) return + require(controllerToAttach != controllerToDetach) + + val shouldDetachController = controllerToDetach?.attached == true + val shouldAttachController = controllerToAttach?.attached == false + + if (shouldDetachController) { + controllerToDetach?.onDetach() + } + if (shouldAttachController) { + controllerToAttach?.onAttach() } - if (to != null && managerAttached && !destinationControllerAttached) { - controllerManager.onAttachController?.invoke(to, controllerManager) + if (shouldDetachController && controllerToDetach != null) { + controllerManager.onDetachController?.invoke(controllerToDetach, controllerManager) + } + if (shouldAttachController && controllerToAttach != null) { + controllerManager.onAttachController?.invoke(controllerToAttach, controllerManager) } } override fun onTransitionStart() { + controllerContainer?.onControllersTransitionStart(indefinite) from?.onTransitionStart(false) to?.onTransitionStart(true) } override fun onTransitionEnd() { + controllerContainer?.onControllersTransitionEnd(indefinite) from?.onTransitionEnd(false) to?.onTransitionEnd(true) } override fun onTransitionFinished() { + updateControllersVisibilityLifecycle( + controllerToDetach = from.takeIf { indefinite }, + controllerToAttach = null, + ) + if (from != null) { controllerManager.controllerViewHolder.remove(from.view) if (backward || !controllerManager.controllersCache.isControllerCached(from.key)) { @@ -80,28 +108,53 @@ internal class ControllerTransaction( } } + override fun onTransitionCanceled() { + controllerContainer?.onControllersTransitionCanceled(indefinite) + to?.onTransitionCanceled() + from?.onTransitionCanceled() + + //revert critical state: update controllers lifecycle and detach views if needed + updateControllersVisibilityLifecycle( + controllerToDetach = to, + controllerToAttach = from, + ) + if (to != null) { + controllerManager.controllerViewHolder.remove(to.view) + if (!backward) { + to.onDestroy() + } + } + controllerManager.onTransitionCanceled( + from = from, + backward = backward, + ) + } + companion object { fun replaceTransaction( from: Controller?, to: Controller, controllerManager: ControllerManager, - backward: Boolean + backward: Boolean, + indefinite: Boolean, ) = ControllerTransaction( from = from, to = to, controllerManager = controllerManager, - backward = backward + backward = backward, + indefinite = indefinite, ) fun popTransaction( from: Controller, - controllerManager: ControllerManager + controllerManager: ControllerManager, ) = ControllerTransaction( from = from, to = null, controllerManager = controllerManager, backward = true, + indefinite = false, ) fun removeTransaction( @@ -112,8 +165,8 @@ internal class ControllerTransaction( to = null, controllerManager = controllerManager, backward = false, + indefinite = false, ) - } } \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/holder/ControllerViewHolder.kt b/kompot/src/main/kotlin/com/revolut/kompot/holder/ControllerViewHolder.kt index 4c3c7f1..4ac33f5 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/holder/ControllerViewHolder.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/holder/ControllerViewHolder.kt @@ -26,6 +26,7 @@ internal interface ControllerViewHolder { val container: ViewGroup fun add(view: View) + fun addToBottom(view: View) /** * Runs transition form the views specified in params diff --git a/kompot/src/main/kotlin/com/revolut/kompot/holder/DefaultControllerViewHolder.kt b/kompot/src/main/kotlin/com/revolut/kompot/holder/DefaultControllerViewHolder.kt index 2c665ab..e0966ac 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/holder/DefaultControllerViewHolder.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/holder/DefaultControllerViewHolder.kt @@ -42,9 +42,22 @@ internal class DefaultControllerViewHolder( activeTransition?.endImmediately() container.removeView(view) container.addView( - view, - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT + /* view */ view, + /* width */ ViewGroup.LayoutParams.MATCH_PARENT, + /* height */ ViewGroup.LayoutParams.MATCH_PARENT + ) + } + + override fun addToBottom(view: View) { + activeTransition?.endImmediately() + container.removeView(view) + container.addView( + /* view */ view, + /* index */ 0, + /* params */ ViewGroup.LayoutParams( + /* width */ ViewGroup.LayoutParams.MATCH_PARENT, + /* height */ ViewGroup.LayoutParams.MATCH_PARENT + ) ) } diff --git a/kompot/src/main/kotlin/com/revolut/kompot/holder/ModalControllerViewHolder.kt b/kompot/src/main/kotlin/com/revolut/kompot/holder/ModalControllerViewHolder.kt index 7509cff..8bda38d 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/holder/ModalControllerViewHolder.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/holder/ModalControllerViewHolder.kt @@ -26,6 +26,7 @@ import com.revolut.kompot.navigable.transition.TransitionFactory import com.revolut.kompot.navigable.transition.TransitionListener import com.revolut.kompot.view.ControllerContainer import com.revolut.kompot.view.RootFrameLayout +import timber.log.Timber internal class ModalControllerViewHolder( override val container: ViewGroup, @@ -61,6 +62,14 @@ internal class ModalControllerViewHolder( ) } + override fun addToBottom(view: View) { + Timber.w( + "addToBottom has no effect on the modal controller view holder because " + + "it's always expected to have one view only" + ) + add(view) + } + override fun makeTransition( from: View?, to: View?, diff --git a/kompot/src/main/kotlin/com/revolut/kompot/holder/RootControllerViewHolder.kt b/kompot/src/main/kotlin/com/revolut/kompot/holder/RootControllerViewHolder.kt index 640e585..b65f261 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/holder/RootControllerViewHolder.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/holder/RootControllerViewHolder.kt @@ -32,7 +32,7 @@ internal class RootControllerViewHolder : ControllerViewHolder { private var _container: ViewGroup? = null set(value) { - controllerViewHolder = value?.let { DefaultControllerViewHolder(it)} + controllerViewHolder = value?.let { DefaultControllerViewHolder(it) } field = value } @@ -51,6 +51,10 @@ internal class RootControllerViewHolder : ControllerViewHolder { controllerViewHolder?.add(view) } + override fun addToBottom(view: View) { + controllerViewHolder?.addToBottom(view) + } + override fun makeTransition( from: View?, to: View?, diff --git a/kompot/src/main/kotlin/com/revolut/kompot/lifecycle/ControllerLifecycleCallbacks.kt b/kompot/src/main/kotlin/com/revolut/kompot/lifecycle/ControllerLifecycleCallbacks.kt new file mode 100644 index 0000000..6b58338 --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/lifecycle/ControllerLifecycleCallbacks.kt @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.lifecycle + +import com.revolut.kompot.navigable.Controller + +interface ControllerLifecycleCallbacks { + + fun onControllerCreated(controller: Controller) = Unit + fun onControllerAttached(controller: Controller) = Unit + fun onControllerDetached(controller: Controller) = Unit + fun onControllerDestroyed(controller: Controller) = Unit + + fun onTransitionStart(controller: Controller, enter: Boolean) = Unit + fun onTransitionEnd(controller: Controller, enter: Boolean) = Unit +} \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/Controller.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/Controller.kt index 1003ce4..f294fdb 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/navigable/Controller.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/Controller.kt @@ -29,31 +29,44 @@ import androidx.annotation.StyleRes import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry -import com.revolut.kompot.BuildConfig +import com.revolut.kompot.KompotPlugin import com.revolut.kompot.common.ActivityFromControllerLauncher import com.revolut.kompot.common.ActivityLauncher import com.revolut.kompot.common.PermissionsFromControllerRequester import com.revolut.kompot.common.PermissionsRequester import com.revolut.kompot.di.flow.ParentFlow +import com.revolut.kompot.navigable.binder.CompositeBinding import com.revolut.kompot.navigable.cache.ControllerCacheStrategy import com.revolut.kompot.navigable.cache.ControllersCache import com.revolut.kompot.navigable.flow.BaseFlow +import com.revolut.kompot.navigable.hooks.ControllerViewContextHook import com.revolut.kompot.navigable.hooks.HooksProvider +import com.revolut.kompot.navigable.root.RootFlow import com.revolut.kompot.navigable.transition.TransitionCallbacks +import com.revolut.kompot.navigable.utils.ControllerEnvironment +import com.revolut.kompot.navigable.utils.collectTillDetachView import com.revolut.kompot.utils.ControllerScope import com.revolut.kompot.view.ControllerContainer import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.cancelChildren import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onCompletion -import kotlinx.coroutines.flow.onEach import timber.log.Timber @Suppress("SyntheticAccessor") -abstract class Controller : TransitionCallbacks, LifecycleOwner, ActivityLauncher, PermissionsRequester { +abstract class Controller : + TransitionCallbacks, + LifecycleOwner, + ActivityLauncher, + PermissionsRequester, + LayoutOwner { + + @StyleRes + open val themeId: Int? = null + open val fitStatusBar: Boolean? = null + open val fitNavigationBar: Boolean? = null + open var keyInitialization: () -> ControllerKey = { ControllerKey.random() } + val activity: Activity by lazy(LazyThreadSafetyMode.NONE) { var context: Context = view.context while (context is ContextWrapper) { @@ -65,20 +78,20 @@ abstract class Controller : TransitionCallbacks, LifecycleOwner, ActivityLaunche (context as? Activity) ?: throw IllegalStateException("Activity is not present") } - - internal val key: ControllerKey by lazy(LazyThreadSafetyMode.NONE) { + val key: ControllerKey by lazy(LazyThreadSafetyMode.NONE) { keyInitialization() } - open var keyInitialization: () -> ControllerKey = { ControllerKey.random() } + val environment: ControllerEnvironment by lazy { + ControllerEnvironment(this) + } + val resources: Resources get() = activity.resources internal val createdScope: CoroutineScope = ControllerScope() internal val attachedScope: CoroutineScope = ControllerScope() - - val resources: Resources - get() = activity.resources + internal val tillDestroyBinding = CompositeBinding() internal lateinit var parentControllerManager: ControllerManager - protected val controllersCache: ControllersCache + internal val controllersCache: ControllersCache get() = parentControllerManager.controllersCache internal val hooksProvider: HooksProvider? get() = parentControllerManager.hooksProvider @@ -90,12 +103,6 @@ abstract class Controller : TransitionCallbacks, LifecycleOwner, ActivityLaunche PermissionsFromControllerRequester(this) } - @StyleRes - protected open val themeId: Int? = null - protected abstract val layoutId: Int - open val fitStatusBar: Boolean? = null - open val fitNavigationBar: Boolean? = null - protected val parentFlow: ParentFlow get() = parentController as ParentFlow @@ -129,6 +136,7 @@ abstract class Controller : TransitionCallbacks, LifecycleOwner, ActivityLaunche private val onCreateCallbacks = mutableListOf<() -> Unit>() private val onDestroyCallbacks = mutableListOf<() -> Unit>() private val onAttachCallbacks = mutableListOf<() -> Unit>() + private val onExitTransitionEndCallbacks = mutableListOf<() -> Unit>() private val lifecycleRegistry by lazy(LazyThreadSafetyMode.NONE) { LifecycleRegistry.createUnsafe(this) } @@ -145,13 +153,26 @@ abstract class Controller : TransitionCallbacks, LifecycleOwner, ActivityLaunche open var cacheStrategy: ControllerCacheStrategy = ControllerCacheStrategy.Auto - open val controllerDelegates: Set = emptySet() + open val controllerExtensions: Set = emptySet() - abstract fun createView(inflater: LayoutInflater): View + open fun createView(inflater: LayoutInflater): View = + inflater.inflate(layoutId, null, false).also { + this.view = it + } - internal fun patchLayoutInflaterWithTheme(inflater: LayoutInflater) = themeId?.let { themeResId -> - LayoutInflater.from(ContextThemeWrapper(inflater.context, themeResId)) - } ?: inflater + internal fun getViewInflater(baseInflater: LayoutInflater): LayoutInflater { + val themeId = themeId + val controllerViewCtxHook = hooksProvider?.getHook(ControllerViewContextHook) + if (controllerViewCtxHook == null && themeId == null) return baseInflater + var inflaterContext = baseInflater.context + if (controllerViewCtxHook != null) { + inflaterContext = controllerViewCtxHook.hook(environment, inflaterContext) + } + if (themeId != null) { + inflaterContext = ContextThemeWrapper(inflaterContext, themeId) + } + return LayoutInflater.from(inflaterContext) + } internal fun getOrCreateView(inflater: LayoutInflater): View { return if (created) { @@ -176,7 +197,16 @@ abstract class Controller : TransitionCallbacks, LifecycleOwner, ActivityLaunche internal fun bind( controllerManager: ControllerManager, - parentController: Controller? + parentController: Controller?, + enterTransition: TransitionAnimation, + ) { + bind(controllerManager, parentController) + this.environment.enterTransition = enterTransition + } + + internal fun bind( + controllerManager: ControllerManager, + parentController: Controller?, ) { parentControllerManager = controllerManager this.parentController = parentController @@ -184,6 +214,7 @@ abstract class Controller : TransitionCallbacks, LifecycleOwner, ActivityLaunche open fun onCreate() { _created = true + controllerExtensions.forEach { extension -> extension.init(attachedScope) } onCreateCallbacks.forEach { func -> func() } onCreateCallbacks.clear() @@ -192,10 +223,12 @@ abstract class Controller : TransitionCallbacks, LifecycleOwner, ActivityLaunche lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_START) _started = true - controllerDelegates.forEach(ControllerExtension::onCreate) + controllerExtensions.forEach { extension -> extension.onParentLifecycleEvent(Lifecycle.Event.ON_CREATE) } + KompotPlugin.controllerLifecycleCallbacks.forEach { callback -> callback.onControllerCreated(this) } } open fun onDestroy() { + if (destroyed) return _destroyed = true onDestroyCallbacks.forEach { func -> func() } onDestroyCallbacks.clear() @@ -207,7 +240,8 @@ abstract class Controller : TransitionCallbacks, LifecycleOwner, ActivityLaunche } lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) - controllerDelegates.forEach(ControllerExtension::onDestroy) + controllerExtensions.forEach { extension -> extension.onParentLifecycleEvent(Lifecycle.Event.ON_DESTROY) } + KompotPlugin.controllerLifecycleCallbacks.forEach { callback -> callback.onControllerDestroyed(this) } } open fun onAttach() { @@ -217,8 +251,9 @@ abstract class Controller : TransitionCallbacks, LifecycleOwner, ActivityLaunche onAttachCallbacks.forEach { func -> func() } onAttachCallbacks.clear() lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_RESUME) + KompotPlugin.controllerShownSharedFlow.tryEmit(this) - controllerDelegates.forEach(ControllerExtension::onAttach) + controllerExtensions.forEach { extension -> extension.onParentLifecycleEvent(Lifecycle.Event.ON_RESUME) } } open fun onDetach() { @@ -227,7 +262,8 @@ abstract class Controller : TransitionCallbacks, LifecycleOwner, ActivityLaunche _detached = true lifecycleRegistry.handleLifecycleEvent(Lifecycle.Event.ON_PAUSE) - controllerDelegates.forEach(ControllerExtension::onDetach) + controllerExtensions.forEach { extension -> extension.onParentLifecycleEvent(Lifecycle.Event.ON_PAUSE) } + KompotPlugin.controllerLifecycleCallbacks.forEach { callback -> callback.onControllerDetached(this) } } internal fun finish() { @@ -254,15 +290,15 @@ abstract class Controller : TransitionCallbacks, LifecycleOwner, ActivityLaunche open fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { doOnAttach { - controllerDelegates.forEach { delegate -> - delegate.onActivityResult(requestCode, resultCode, data) + controllerExtensions.forEach { extension -> + extension.onActivityResult(requestCode, resultCode, data) } } } open fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { - controllerDelegates.forEach { delegate -> - delegate.onRequestPermissionsResult(requestCode, permissions, grantResults) + controllerExtensions.forEach { extension -> + extension.onRequestPermissionsResult(requestCode, permissions, grantResults) } } @@ -270,14 +306,28 @@ abstract class Controller : TransitionCallbacks, LifecycleOwner, ActivityLaunche override fun onTransitionStart(enter: Boolean) { _activeTransition = if (enter) ActiveTransition.ENTER else ActiveTransition.EXIT + KompotPlugin.controllerLifecycleCallbacks.forEach { callback -> + callback.onTransitionStart(this, enter) + } } override fun onTransitionEnd(enter: Boolean) { _activeTransition = ActiveTransition.NONE + KompotPlugin.controllerLifecycleCallbacks.forEach { callback -> + callback.onTransitionEnd(this, enter) + } + if (!enter) { + onExitTransitionEndCallbacks.forEach { func -> func() } + onExitTransitionEndCallbacks.clear() + } + } + + override fun onTransitionCanceled() { + _activeTransition = ActiveTransition.NONE } open fun handleBack(): Boolean { - if (controllerDelegates.any { delegate -> delegate.handleBack() }) { + if (controllerExtensions.any { extension -> extension.handleBack() }) { return true } if (!backEnabled) { @@ -287,15 +337,15 @@ abstract class Controller : TransitionCallbacks, LifecycleOwner, ActivityLaunche } internal open fun onParentManagerCleared() { - controllerDelegates.any { delegate -> delegate.handleBack() } + controllerExtensions.any { extension -> extension.handleBack() } } - fun getTopFlow(): BaseFlow<*, *, *> { + fun getTopFlow(): RootFlow<*, *> { var topController = (this as? BaseFlow<*, *, *>) ?: parentController while (topController?.parentController != null) { topController = topController.parentController } - return topController as BaseFlow<*, *, *> + return topController as RootFlow<*, *> } override fun startActivity(intent: Intent) = @@ -311,35 +361,14 @@ abstract class Controller : TransitionCallbacks, LifecycleOwner, ActivityLaunche onError: suspend (Throwable) -> Unit = { Timber.e(it) }, onSuccessCompletion: suspend () -> Unit = {}, onEach: suspend (T) -> Unit = {} - ): Job { - if (BuildConfig.DEBUG) { - when { - detached -> error("collectTillDetachView is called after onDetach [${this@Controller}]") - !attached -> error("collectTillDetachView is called before onAttach [${this@Controller}]") - } - } - return launchInScope( - scope = attachedScope, - onError = onError, - onSuccessCompletion = onSuccessCompletion, - onEach = onEach - ) - } - - private fun Flow.launchInScope( - scope: CoroutineScope, - onError: suspend (Throwable) -> Unit = { Timber.e(it) }, - onSuccessCompletion: suspend () -> Unit = {}, - onEach: suspend (T) -> Unit = {} - ): Job = - onEach(onEach) - .onCompletion { cause -> - if (cause == null) onSuccessCompletion() - } - .catch { cause -> - onError(cause) - } - .launchIn(scope) + ): Job = collectTillDetachView( + attached = attached, + detached = detached, + attachedScope = attachedScope, + onError = onError, + onSuccessCompletion = onSuccessCompletion, + onEach = onEach + ) fun doOnCreate(onCreate: () -> Unit) { if (created) { @@ -357,6 +386,10 @@ abstract class Controller : TransitionCallbacks, LifecycleOwner, ActivityLaunche } } + fun doOnNextExitTransition(onNextExit: () -> Unit) { + onExitTransitionEndCallbacks.add(onNextExit) + } + fun doOnDestroy(onDestroy: () -> Unit) { if (destroyed) { onDestroy() diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/ControllerExtension.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/ControllerExtension.kt index 395af3e..7733aff 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/navigable/ControllerExtension.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/ControllerExtension.kt @@ -17,19 +17,74 @@ package com.revolut.kompot.navigable import android.content.Intent +import androidx.annotation.VisibleForTesting +import androidx.lifecycle.Lifecycle +import com.revolut.kompot.navigable.utils.collectTillDetachView +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import timber.log.Timber -interface ControllerExtension { - fun onCreate() = Unit +abstract class ControllerExtension { - fun onDestroy() = Unit + private lateinit var attachedScope: CoroutineScope + private var attached = false + private var detached = false - fun onAttach() = Unit + @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) + fun init(attachedScope: CoroutineScope) { + this.attachedScope = attachedScope + } - fun onDetach() = Unit + @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) + fun onParentLifecycleEvent(event: Lifecycle.Event) { + when (event) { + Lifecycle.Event.ON_CREATE, + Lifecycle.Event.ON_START -> { + onCreate() + } + Lifecycle.Event.ON_RESUME -> { + attached = true + detached = false + onAttach() + } + Lifecycle.Event.ON_PAUSE -> { + attached = false + detached = true + onDetach() + } + Lifecycle.Event.ON_STOP, + Lifecycle.Event.ON_DESTROY -> { + onDestroy() + } + Lifecycle.Event.ON_ANY -> Unit + } + } - fun handleBack(): Boolean = false + open fun onCreate() = Unit - fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) = Unit + open fun onDestroy() = Unit - fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) = Unit + open fun onAttach() = Unit + + open fun onDetach() = Unit + + open fun handleBack(): Boolean = false + + open fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) = Unit + + open fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) = Unit + + protected fun Flow.collectTillDetachView( + onError: suspend (Throwable) -> Unit = { Timber.e(it) }, + onSuccessCompletion: suspend () -> Unit = {}, + onEach: suspend (T) -> Unit = {} + ): Job = collectTillDetachView( + attached = attached, + detached = detached, + attachedScope = attachedScope, + onError = onError, + onSuccessCompletion = onSuccessCompletion, + onEach = onEach + ) } \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/ControllerManager.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/ControllerManager.kt index cae8a9f..207003e 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/navigable/ControllerManager.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/ControllerManager.kt @@ -21,7 +21,7 @@ import android.content.ContextWrapper import android.content.Intent import android.view.LayoutInflater import androidx.annotation.LayoutRes -import com.revolut.kompot.KompotPlugin +import androidx.core.view.contains import com.revolut.kompot.holder.ControllerTransaction import com.revolut.kompot.holder.ControllerViewHolder import com.revolut.kompot.navigable.cache.ControllersCache @@ -29,11 +29,12 @@ import com.revolut.kompot.navigable.hooks.HooksProvider internal open class ControllerManager( val modal: Boolean, - @LayoutRes internal val defaultFlowLayout: Int?, + @LayoutRes internal val defaultControllerContainer: Int?, internal val controllersCache: ControllersCache, internal val controllerViewHolder: ControllerViewHolder, internal val onAttachController: ChildControllerListener? = null, internal val onDetachController: ChildControllerListener? = null, + private val onTransitionCanceled: CanceledTransitionListener? = null ) { init { @@ -43,7 +44,7 @@ internal open class ControllerManager( from = activeController, controllerManager = this ) - popTransaction.startWith(TransitionAnimation.MODAL_SLIDE) + popTransaction.startWith(ModalTransitionAnimation.ModalPopup()) _activeController = null } } @@ -77,16 +78,29 @@ internal open class ControllerManager( controller: Controller, animation: TransitionAnimation, backward: Boolean, - parentController: Controller? + parentController: Controller?, ) { val oldController = _activeController _activeController = controller - controller.bind(this, parentController) + if (!backward) { + controller.bind(this, parentController, enterTransition = animation) + } else { + controller.bind(this, parentController) + } val context = controllerViewHolder.container.context val controllerView = controller.getOrCreateView(LayoutInflater.from(context)) - controllerViewHolder.add(controllerView) + if (controllerViewHolder.container.contains(controllerView).not() && controllerView.parent != null){ + val cache = controllersCache.getCacheLogWithKeys() + // Throwing exception is okay since the app is going to crash anyway. + throw IllegalStateException("Can’t show controller because it’s already attached to another flow. ${controller.fullControllerName} key: ${controller.key.value} \n $cache") + } + if (backward) { + controllerViewHolder.addToBottom(controllerView) + } else { + controllerViewHolder.add(controllerView) + } if (!controller.created) { controller.onCreate() } @@ -95,10 +109,14 @@ internal open class ControllerManager( from = oldController.takeIf { oldController != _activeController }, to = controller, controllerManager = this, - backward = backward + backward = backward, + indefinite = animation.indefinite, ).startWith(animation) + } - KompotPlugin.controllerShownSharedFlow.tryEmit(controller) + internal fun onTransitionCanceled(from: Controller?, backward: Boolean) { + _activeController = from + onTransitionCanceled?.invoke(backward) } fun removeActiveController() { @@ -130,30 +148,22 @@ internal open class ControllerManager( onDetach() } - fun onAttach(): Boolean { + fun onAttach() { if (_activeController != null) { _attached = true if (_activeController?.attached == false) { _activeController?.onAttach() - - return true } } - - return false } - fun onDetach(): Boolean { + fun onDetach() { if (_activeController != null) { _attached = false if (_activeController?.attached == true) { _activeController?.onDetach() - - return true } } - - return false } fun handleBack(): Boolean { @@ -168,7 +178,7 @@ internal open class ControllerManager( controllerManager = this ) if (attached) { - popTransaction.startWith(TransitionAnimation.MODAL_SLIDE) + popTransaction.startWith(ModalTransitionAnimation.ModalPopup()) } else { popTransaction.startWith(TransitionAnimation.NONE) _attached = true @@ -189,7 +199,7 @@ internal open class ControllerManager( ControllerTransaction.popTransaction( from = requireNotNull(_activeController), controllerManager = this - ).startWith(TransitionAnimation.MODAL_SLIDE) + ).startWith(ModalTransitionAnimation.ModalPopup()) resetActiveController() } } @@ -206,4 +216,5 @@ internal open class ControllerManager( } -internal typealias ChildControllerListener = (Controller, ControllerManager) -> Unit \ No newline at end of file +internal typealias ChildControllerListener = (Controller, ControllerManager) -> Unit +internal typealias CanceledTransitionListener = (backward: Boolean) -> Unit \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/ControllerModel.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/ControllerModel.kt index afed20f..8d23197 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/navigable/ControllerModel.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/ControllerModel.kt @@ -14,11 +14,14 @@ * limitations under the License. */ +@file:OptIn(ExperimentalKompotApi::class) + package com.revolut.kompot.navigable import android.os.SystemClock import androidx.annotation.CallSuper -import com.revolut.kompot.BuildConfig +import com.revolut.kompot.ExperimentalKompotApi +import com.revolut.kompot.common.ControllerDescriptor import com.revolut.kompot.common.ErrorEvent import com.revolut.kompot.common.ErrorEventResult import com.revolut.kompot.common.Event @@ -29,36 +32,41 @@ import com.revolut.kompot.common.InternalDestination import com.revolut.kompot.common.LifecycleEvent import com.revolut.kompot.common.ModalDestination import com.revolut.kompot.common.NavigationDestination -import com.revolut.kompot.common.handleNavigationEvent +import com.revolut.kompot.common.NavigationRequest import com.revolut.kompot.dialog.DialogDisplayer import com.revolut.kompot.dialog.DialogModel import com.revolut.kompot.dialog.DialogModelResult import com.revolut.kompot.navigable.cache.ControllersCache import com.revolut.kompot.navigable.flow.Flow +import com.revolut.kompot.navigable.flow.scroller.ScrollerFlow import com.revolut.kompot.navigable.screen.Screen -import com.revolut.kompot.navigable.utils.single_task.IllegalConcurrentAccessException +import com.revolut.kompot.navigable.utils.collectTillFinish +import com.revolut.kompot.navigable.utils.collectTillHide +import com.revolut.kompot.navigable.utils.getController +import com.revolut.kompot.navigable.utils.hideAllDialogs +import com.revolut.kompot.navigable.utils.hideDialog +import com.revolut.kompot.navigable.utils.navigate +import com.revolut.kompot.navigable.utils.setBlockingLoadingVisibility +import com.revolut.kompot.navigable.utils.showDialog +import com.revolut.kompot.navigable.utils.showModal +import com.revolut.kompot.navigable.utils.singleTask import com.revolut.kompot.navigable.utils.single_task.SingleTasksRegistry +import com.revolut.kompot.navigable.utils.tillFinish +import com.revolut.kompot.navigable.utils.tillHide +import com.revolut.kompot.navigable.utils.withLoading +import com.revolut.kompot.navigable.vc.ViewController import com.revolut.kompot.utils.ContextId import com.revolut.kompot.utils.ContextId.CreatedScopeContextId import com.revolut.kompot.utils.ContextId.ShownScopeContextId import com.revolut.kompot.utils.ControllerScope import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.cancelChildren -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.flatMapConcat -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onCompletion -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch import kotlinx.coroutines.plus -import kotlinx.coroutines.withContext -import timber.log.Timber +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine abstract class ControllerModel { @@ -90,13 +98,16 @@ abstract class ControllerModel { protected val lastLifecycleEvent: LifecycleEvent? get() = _lastLifecycleEvent + private lateinit var extensions: Set + private val singleTasksRegistry = SingleTasksRegistry() open fun injectDependencies( dialogDisplayer: DialogDisplayer, eventsDispatcher: EventsDispatcher, controllersCache: ControllersCache, - mainDispatcher: CoroutineDispatcher = Dispatchers.Unconfined + mainDispatcher: CoroutineDispatcher = Dispatchers.Unconfined, + controllerModelExtensions: Set = emptySet(), ) { _dialogDisplayer = dialogDisplayer _eventsDispatcher = eventsDispatcher @@ -105,14 +116,12 @@ abstract class ControllerModel { _shownScope += mainDispatcher _createdScope += mainDispatcher + + extensions = controllerModelExtensions.onEach { extension -> extension.init(this) } } open fun setBlockingLoadingVisibility(visible: Boolean, immediate: Boolean = false) { - if (visible) { - dialogDisplayer.showLoadingDialog(if (immediate) 0 else 1000) - } else { - dialogDisplayer.hideLoadingDialog() - } + setBlockingLoadingVisibility(dialogDisplayer, visible, immediate) } @CallSuper @@ -122,21 +131,25 @@ abstract class ControllerModel { LifecycleEvent.SHOWN -> { onShown(if (hideTime > 0L) (SystemClock.elapsedRealtime() - hideTime) else 0L) } + LifecycleEvent.HIDDEN -> { hideTime = SystemClock.elapsedRealtime() shownScope.coroutineContext.cancelChildren() onHiddenCleanUp() onHidden() } + LifecycleEvent.CREATED -> { onCreated() } + LifecycleEvent.FINISHED -> { createdScope.coroutineContext.cancelChildren() onFinishedCleanUp() onFinished() } } + extensions.forEach { it.onParentLifecycleEvent(event) } } open fun onHiddenCleanUp() = Unit @@ -155,130 +168,60 @@ abstract class ControllerModel { handleError: suspend (Throwable) -> Boolean = { false }, onSuccessCompletion: suspend () -> Unit = {}, onEach: suspend (T) -> Unit = {} - ): Job { - assertWrongLaunchTillHide( - methodName = "collectTillHide", - createdScopeAlternative = "collectTillFinish" - ) - return launchInScope(shownScope, handleError, onSuccessCompletion, onEach) - } + ): Job = collectTillHide( + eventsDispatcher = eventsDispatcher, + lastLifecycleEvent = lastLifecycleEvent, + shownScope = shownScope, + handleError = handleError, + onSuccessCompletion = onSuccessCompletion, + onEach = onEach, + ) protected fun kotlinx.coroutines.flow.Flow.collectTillFinish( handleError: suspend (Throwable) -> Boolean = { false }, onSuccessCompletion: suspend () -> Unit = {}, onEach: suspend (T) -> Unit = {} - ): Job { - assertWrongLaunchTillFinish( - methodName = "collectTillFinish", - shownScopeAlternative = "collectTillHide" - ) - return launchInScope(createdScope, handleError, onSuccessCompletion, onEach) - } - - private fun kotlinx.coroutines.flow.Flow.launchInScope( - scope: CoroutineScope, - handleError: suspend (Throwable) -> Boolean = { false }, - onSuccessCompletion: suspend () -> Unit = {}, - onEach: suspend (T) -> Unit = {} - ): Job = - onEach(onEach) - .onCompletion { cause -> - if (cause == null) onSuccessCompletion() - } - .catch { cause -> - if (!handleError(cause)) { - sendErrorEvent(cause) - } - } - .launchIn(scope) + ): Job = collectTillFinish( + eventsDispatcher = eventsDispatcher, + lastLifecycleEvent = lastLifecycleEvent, + createdScope = createdScope, + handleError = handleError, + onSuccessCompletion = onSuccessCompletion, + onEach = onEach, + ) protected fun tillFinish( handleError: (Throwable) -> Boolean = { false }, block: suspend CoroutineScope.() -> T - ): Job { - assertWrongLaunchTillFinish( - methodName = "tillFinish", - shownScopeAlternative = "tillHide" - ) - - val exceptionHandler = CoroutineExceptionHandler { _, exception -> - if (!handleError(exception)) { - sendErrorEvent(exception) - } - } - - return createdScope.launch(exceptionHandler) { - block() - } - } + ): Job = tillFinish( + eventsDispatcher = eventsDispatcher, + createdScope = createdScope, + lastLifecycleEvent = lastLifecycleEvent, + handleError = handleError, + block = block, + ) protected fun tillHide( handleError: (Throwable) -> Boolean = { false }, block: suspend CoroutineScope.() -> T - ): Job { - assertWrongLaunchTillHide( - methodName = "tillHide", - createdScopeAlternative = "tillFinish" - ) - - val exceptionHandler = CoroutineExceptionHandler { _, exception -> - if (!handleError(exception)) { - sendErrorEvent(exception) - } - } - - return shownScope.launch(exceptionHandler) { - block() - } - } - - protected suspend fun withLoading(block: suspend () -> T): T = withContext(mainDispatcher) { - try { - setBlockingLoadingVisibility(true) - block() - } finally { - setBlockingLoadingVisibility(false) - } - } + ): Job = tillHide( + eventsDispatcher = eventsDispatcher, + shownScope = shownScope, + lastLifecycleEvent = lastLifecycleEvent, + handleError = handleError, + block = block, + ) + + suspend fun withLoading(block: suspend () -> T): T = withLoading( + dialogDisplayer = dialogDisplayer, + mainDispatcher = mainDispatcher, + block = block, + ) @Suppress("FunctionName") private fun ModelScope(contextId: ContextId): CoroutineScope = ControllerScope() + mainDispatcher + contextId - private fun sendErrorEvent(throwable: Throwable) { - eventsDispatcher.handleEvent(ErrorEvent(throwable)) - } - - private fun assertWrongLaunchTillFinish( - methodName: String, - shownScopeAlternative: String - ) { - if (BuildConfig.DEBUG) { - when (lastLifecycleEvent) { - LifecycleEvent.CREATED -> Unit - LifecycleEvent.SHOWN -> Timber.e("$methodName is called after onShown, consider to use $shownScopeAlternative [$this]") - LifecycleEvent.HIDDEN -> Timber.e("$methodName is called after onHidden $this]") - LifecycleEvent.FINISHED -> error("$methodName is called after onFinished [$this]") - null -> Timber.e("$methodName is called before onCreate [$this]") - } - } - } - - private fun assertWrongLaunchTillHide( - methodName: String, - createdScopeAlternative: String - ) { - if (BuildConfig.DEBUG) { - when (lastLifecycleEvent) { - LifecycleEvent.CREATED -> Timber.e("$methodName is called before onShown, consider to use $createdScopeAlternative [$this]") - LifecycleEvent.SHOWN -> Unit - LifecycleEvent.HIDDEN -> error("$methodName is called after onHidden [$this]") - LifecycleEvent.FINISHED -> error("$methodName is called after onFinished [$this]") - null -> Timber.e("$methodName is called before onCreate [$this]") - } - } - } - open fun handleErrorEvent(throwable: Throwable): Boolean = false open fun tryHandleEvent(event: Event): EventResult? { @@ -289,68 +232,65 @@ abstract class ControllerModel { return null } - fun navigate(internalDestination: InternalDestination<*>) = internalDestination.navigate() + fun navigate(internalDestination: InternalDestination<*>) = navigate(eventsDispatcher, internalDestination) + + fun NavigationDestination.navigate() = navigate(eventsDispatcher) - fun NavigationDestination.navigate() = eventsDispatcher.handleNavigationEvent(this) + fun ControllerDescriptor.getController() = getController(eventsDispatcher) + suspend fun NavigationRequest.navigate() = navigate(eventsDispatcher) + + @Deprecated("This call doesn't support saved state. Use ModalCoordinator or FlowCoordinator to dispatch modals", ReplaceWith("")) fun Screen.showModal( - style: ModalDestination.Style = ModalDestination.Style.FULLSCREEN, + style: ModalDestination.Style = ModalDestination.Style.FULLSCREEN_FADE, onResult: ((T) -> Unit)? = null - ) = - eventsDispatcher.handleNavigationEvent( - ModalDestination.ExplicitScreen( - screen = this, - onResult = onResult, - style = style - ) - ) + ) = showModal(eventsDispatcher, style, onResult) + @Deprecated("This call doesn't support saved state. Use ModalCoordinator or FlowCoordinator to dispatch modals", ReplaceWith("")) fun Flow.showModal( - style: ModalDestination.Style = ModalDestination.Style.FULLSCREEN, + style: ModalDestination.Style = ModalDestination.Style.FULLSCREEN_FADE, onResult: ((T) -> Unit)? = null - ) = - eventsDispatcher.handleNavigationEvent( - ModalDestination.ExplicitFlow( - flow = this, - onResult = onResult, - style = style - ) - ) + ) = showModal(eventsDispatcher, style, onResult) + + @Deprecated("This call doesn't support saved state. Use ModalCoordinator or FlowCoordinator to dispatch modals", ReplaceWith("")) + suspend fun Flow.showModalSuspend( + style: ModalDestination.Style = ModalDestination.Style.FULLSCREEN_FADE, + ): T = suspendCoroutine { emitter -> + showModal(eventsDispatcher, style, emitter::resume) + } + + @Deprecated("This call doesn't support saved state. Use ModalCoordinator or FlowCoordinator to dispatch modals", ReplaceWith("")) + @OptIn(ExperimentalKompotApi::class) + fun ScrollerFlow.showModal( + style: ModalDestination.Style = ModalDestination.Style.FULLSCREEN_FADE, + onResult: ((T) -> Unit)? = null + ) = showModal(eventsDispatcher, style, onResult) + + @Deprecated("This call doesn't support saved state. Use ModalCoordinator or FlowCoordinator to dispatch modals", ReplaceWith("")) + fun ViewController.showModal( + style: ModalDestination.Style = ModalDestination.Style.FULLSCREEN_FADE, + onResult: ((T) -> Unit)? = null + ) = showModal(eventsDispatcher, style, onResult) + + @Deprecated("This call doesn't support saved state. Use ModalCoordinator or FlowCoordinator to dispatch modals", ReplaceWith("")) + suspend fun Screen.showModalSuspend( + style: ModalDestination.Style = ModalDestination.Style.FULLSCREEN_FADE, + ): T = suspendCoroutine { emitter -> + showModal(eventsDispatcher, style, emitter::resume) + } fun showDialog(dialogModel: DialogModel): kotlinx.coroutines.flow.Flow = - dialogDisplayer.showDialog(dialogModel) + showDialog(dialogDisplayer, dialogModel) + + fun hideDialog(dialogModel: DialogModel<*>) = + hideDialog(dialogDisplayer, dialogModel) - fun hideAllDialogs() = dialogDisplayer.hideAllDialogs() + fun hideAllDialogs() = hideAllDialogs(dialogDisplayer) - @OptIn(FlowPreview::class) protected fun kotlinx.coroutines.flow.Flow.singleTask(taskId: String): kotlinx.coroutines.flow.Flow = - flow { - if (singleTasksRegistry.acquire(taskId)) { - emit(Unit) - } else { - throw IllegalConcurrentAccessException() - } - }.flatMapConcat { - this - }.onCompletion { cause -> - if (cause !is IllegalConcurrentAccessException) { - singleTasksRegistry.release(taskId) - } - }.catch { e -> - if (e !is IllegalConcurrentAccessException) { - throw e - } - } + singleTask(singleTasksRegistry, taskId) protected suspend fun singleTask(taskId: String, action: suspend () -> T): T? { - if (!singleTasksRegistry.acquire(taskId)) { - return null - } - - return try { - action.invoke() - } finally { - singleTasksRegistry.release(taskId) - } + return singleTask(singleTasksRegistry, taskId, action) } } \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/ControllerModelExtension.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/ControllerModelExtension.kt new file mode 100644 index 0000000..d0a9db8 --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/ControllerModelExtension.kt @@ -0,0 +1,205 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable + +import androidx.annotation.VisibleForTesting +import com.revolut.kompot.ExperimentalKompotApi +import com.revolut.kompot.common.ControllerDescriptor +import com.revolut.kompot.common.EventsDispatcher +import com.revolut.kompot.common.IOData +import com.revolut.kompot.common.InternalDestination +import com.revolut.kompot.common.LifecycleEvent +import com.revolut.kompot.common.ModalDestination +import com.revolut.kompot.common.NavigationDestination +import com.revolut.kompot.common.NavigationRequest +import com.revolut.kompot.dialog.DialogDisplayer +import com.revolut.kompot.dialog.DialogModel +import com.revolut.kompot.dialog.DialogModelResult +import com.revolut.kompot.navigable.flow.Flow +import com.revolut.kompot.navigable.flow.scroller.ScrollerFlow +import com.revolut.kompot.navigable.screen.Screen +import com.revolut.kompot.navigable.utils.collectTillFinish +import com.revolut.kompot.navigable.utils.collectTillHide +import com.revolut.kompot.navigable.utils.getController +import com.revolut.kompot.navigable.utils.hideAllDialogs +import com.revolut.kompot.navigable.utils.navigate +import com.revolut.kompot.navigable.utils.setBlockingLoadingVisibility +import com.revolut.kompot.navigable.utils.showDialog +import com.revolut.kompot.navigable.utils.showModal +import com.revolut.kompot.navigable.utils.singleTask +import com.revolut.kompot.navigable.utils.single_task.SingleTasksRegistry +import com.revolut.kompot.navigable.utils.tillFinish +import com.revolut.kompot.navigable.utils.tillHide +import com.revolut.kompot.navigable.utils.withLoading +import com.revolut.kompot.navigable.vc.ViewController +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job + +abstract class ControllerModelExtension { + + private lateinit var parent: ControllerModel + private val parentMainDispatcher: CoroutineDispatcher + get() = parent.mainDispatcher + private val parentCreatedScope: CoroutineScope + get() = parent.createdScope + private val parentShownScope: CoroutineScope + get() = parent.shownScope + private var parentLastLifecycleEvent: LifecycleEvent? = null + + val parentDialogDisplayer: DialogDisplayer + get() = parent.dialogDisplayer + + val parentEventsDispatcher: EventsDispatcher + get() = parent.eventsDispatcher + + private val singleTasksRegistry = SingleTasksRegistry() + + @VisibleForTesting + fun init(parent: ControllerModel) { + this.parent = parent + } + + internal fun onParentLifecycleEvent(event: LifecycleEvent) { + parentLastLifecycleEvent = event + when (event) { + LifecycleEvent.CREATED -> { + onCreated() + } + + LifecycleEvent.SHOWN -> { + onShown() + } + + LifecycleEvent.HIDDEN -> { + onHidden() + } + + LifecycleEvent.FINISHED -> { + onFinished() + } + } + } + + open fun onCreated() = Unit + + open fun onShown() = Unit + + open fun onHidden() = Unit + + open fun onFinished() = Unit + + protected fun setBlockingLoadingVisibility(visible: Boolean, immediate: Boolean = false) { + setBlockingLoadingVisibility(parentDialogDisplayer, visible, immediate) + } + + protected suspend fun withLoading(block: suspend () -> T): T = withLoading( + dialogDisplayer = parentDialogDisplayer, + mainDispatcher = parentMainDispatcher, + block = block, + ) + + protected fun tillHide( + handleError: (Throwable) -> Boolean = { false }, + block: suspend CoroutineScope.() -> T + ): Job = tillHide( + eventsDispatcher = parentEventsDispatcher, + shownScope = parentShownScope, + lastLifecycleEvent = parentLastLifecycleEvent, + handleError = handleError, + block = block, + ) + + protected fun tillFinish( + handleError: (Throwable) -> Boolean = { false }, + block: suspend CoroutineScope.() -> T + ): Job = tillFinish( + eventsDispatcher = parentEventsDispatcher, + createdScope = parentCreatedScope, + lastLifecycleEvent = parentLastLifecycleEvent, + handleError = handleError, + block = block, + ) + + protected fun kotlinx.coroutines.flow.Flow.collectTillHide( + handleError: suspend (Throwable) -> Boolean = { false }, + onSuccessCompletion: suspend () -> Unit = {}, + onEach: suspend (T) -> Unit = {} + ): Job = collectTillHide( + eventsDispatcher = parentEventsDispatcher, + lastLifecycleEvent = parentLastLifecycleEvent, + shownScope = parentShownScope, + handleError = handleError, + onSuccessCompletion = onSuccessCompletion, + onEach = onEach, + ) + + protected fun kotlinx.coroutines.flow.Flow.collectTillFinish( + handleError: suspend (Throwable) -> Boolean = { false }, + onSuccessCompletion: suspend () -> Unit = {}, + onEach: suspend (T) -> Unit = {} + ): Job = collectTillFinish( + eventsDispatcher = parentEventsDispatcher, + lastLifecycleEvent = parentLastLifecycleEvent, + createdScope = parentCreatedScope, + handleError = handleError, + onSuccessCompletion = onSuccessCompletion, + onEach = onEach, + ) + + protected fun navigate(internalDestination: InternalDestination<*>) = navigate(parentEventsDispatcher, internalDestination) + + protected fun NavigationDestination.navigate() = navigate(parentEventsDispatcher) + + fun ControllerDescriptor.getController() = getController(parentEventsDispatcher) + + suspend fun NavigationRequest.navigate() = navigate(parentEventsDispatcher) + + protected fun Screen.showModal( + style: ModalDestination.Style = ModalDestination.Style.FULLSCREEN_FADE, + onResult: ((T) -> Unit)? = null + ) = showModal(parentEventsDispatcher, style, onResult) + + protected fun Flow.showModal( + style: ModalDestination.Style = ModalDestination.Style.FULLSCREEN_FADE, + onResult: ((T) -> Unit)? = null + ) = showModal(parentEventsDispatcher, style, onResult) + + @OptIn(ExperimentalKompotApi::class) + protected fun ScrollerFlow.showModal( + style: ModalDestination.Style = ModalDestination.Style.FULLSCREEN_FADE, + onResult: ((T) -> Unit)? = null + ) = showModal(parentEventsDispatcher, style, onResult) + + protected fun ViewController.showModal( + style: ModalDestination.Style = ModalDestination.Style.FULLSCREEN_FADE, + onResult: ((T) -> Unit)? = null + ) = showModal(parentEventsDispatcher, style, onResult) + + protected fun showDialog(dialogModel: DialogModel): kotlinx.coroutines.flow.Flow = + showDialog(parentDialogDisplayer, dialogModel) + + protected fun hideAllDialogs() = hideAllDialogs(parentDialogDisplayer) + + protected fun kotlinx.coroutines.flow.Flow.singleTask(taskId: String): kotlinx.coroutines.flow.Flow = + singleTask(singleTasksRegistry, taskId) + + protected suspend fun singleTask(taskId: String, action: suspend () -> T): T? { + return singleTask(singleTasksRegistry, taskId, action) + } + +} \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/LayoutOwner.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/LayoutOwner.kt new file mode 100644 index 0000000..91f17f5 --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/LayoutOwner.kt @@ -0,0 +1,21 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable + +interface LayoutOwner { + val layoutId: Int +} \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/RootControllerManager.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/RootControllerManager.kt index bce726e..8285928 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/navigable/RootControllerManager.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/RootControllerManager.kt @@ -23,7 +23,6 @@ import com.revolut.kompot.common.ActivityLauncher import com.revolut.kompot.common.PermissionsRequester import com.revolut.kompot.holder.RootControllerViewHolder import com.revolut.kompot.navigable.cache.ControllersCache -import com.revolut.kompot.navigable.flow.RestorationPolicy import com.revolut.kompot.navigable.hooks.HooksProvider import com.revolut.kompot.navigable.root.RootFlow @@ -31,16 +30,17 @@ internal class RootControllerManager( private val rootFlow: RootFlow<*, *>, private val activityLauncher: ActivityLauncher, private val permissionsRequester: PermissionsRequester, - @LayoutRes defaultFlowLayout: Int?, + @LayoutRes defaultControllerContainer: Int?, controllersCache: ControllersCache, hooksProvider: HooksProvider, ) : ControllerManager( modal = false, - defaultFlowLayout = defaultFlowLayout, + defaultControllerContainer = defaultControllerContainer, controllersCache = controllersCache, controllerViewHolder = RootControllerViewHolder(), onAttachController = null, onDetachController = null, + onTransitionCanceled = null, ), ActivityLauncher by activityLauncher, PermissionsRequester by permissionsRequester { private val rootControllerViewHolder @@ -55,7 +55,7 @@ internal class RootControllerManager( savedState?.let { bundle -> rootFlow.doOnCreate { - rootFlow.restoreState(RestorationPolicy.FromBundle(bundle)) + rootFlow.restoreState(bundle) } } showImmediately(rootFlow) @@ -76,8 +76,8 @@ internal class RootControllerManager( } override fun onDestroy() { - controllersCache.clearCache() super.onDestroy() + controllersCache.clearCache() } } \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/SavedStateOwner.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/SavedStateOwner.kt new file mode 100644 index 0000000..5a58198 --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/SavedStateOwner.kt @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable + +import android.os.Bundle + +interface SavedStateOwner { + fun saveState(outState: Bundle) + fun restoreState(state: Bundle) +} \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/TransitionAnimation.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/TransitionAnimation.kt index 3f1cda1..e1f117f 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/navigable/TransitionAnimation.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/TransitionAnimation.kt @@ -16,12 +16,68 @@ package com.revolut.kompot.navigable -enum class TransitionAnimation { - NONE, - SLIDE_RIGHT_TO_LEFT, - SLIDE_LEFT_TO_RIGHT, - FADE, - MODAL_FADE, - MODAL_SLIDE, - BOTTOM_DIALOG_SLIDE, -} \ No newline at end of file +import android.os.Parcelable +import com.revolut.kompot.ExperimentalBottomDialogStyle +import com.revolut.kompot.common.ModalDestination +import kotlinx.parcelize.Parcelize + +sealed interface TransitionAnimation : Parcelable { + + val indefinite: Boolean get() = false + + @Parcelize + object NONE : TransitionAnimation + + @Parcelize + object SLIDE_RIGHT_TO_LEFT : TransitionAnimation + + @Parcelize + object SLIDE_LEFT_TO_RIGHT : TransitionAnimation + + @Parcelize + object FADE : TransitionAnimation + + interface Custom : TransitionAnimation { + val providerId: Int + } +} + +internal sealed interface InternalTransitionAnimation : TransitionAnimation + +internal sealed interface ModalTransitionAnimation : InternalTransitionAnimation { + + val style: ModalDestination.Style + + @Parcelize + data class ModalFullscreenFade( + val showImmediately: Boolean = false, + override val style: ModalDestination.Style + ) : ModalTransitionAnimation + + @Parcelize + data class ModalFullscreenSlideFromBottom(val showImmediately: Boolean = false) : ModalTransitionAnimation { + override val style: ModalDestination.Style get() = ModalDestination.Style.FULLSCREEN_SLIDE_FROM_BOTTOM + } + + @Parcelize + data class ModalPopup(val showImmediately: Boolean = false) : ModalTransitionAnimation { + override val style: ModalDestination.Style get() = ModalDestination.Style.POPUP + } + + @Parcelize + @OptIn(ExperimentalBottomDialogStyle::class) + data class BottomDialog(val showImmediately: Boolean = false) : ModalTransitionAnimation { + override val style: ModalDestination.Style get() = ModalDestination.Style.BOTTOM_DIALOG + } +} + +internal fun ModalDestination.Style.toModalTransitionAnimation(showImmediately: Boolean) = + when (this) { + ModalDestination.Style.POPUP -> ModalTransitionAnimation.ModalPopup(showImmediately) + ModalDestination.Style.FULLSCREEN_FADE -> ModalTransitionAnimation.ModalFullscreenFade(showImmediately, this) + ModalDestination.Style.FULLSCREEN_SLIDE_FROM_BOTTOM -> ModalTransitionAnimation.ModalFullscreenSlideFromBottom(showImmediately) + ModalDestination.Style.FULLSCREEN_IMMEDIATE -> ModalTransitionAnimation.ModalFullscreenFade(showImmediately = true, this) + ModalDestination.Style.BOTTOM_DIALOG -> ModalTransitionAnimation.BottomDialog(showImmediately) + } + +internal fun TransitionAnimation.extractModalStyle(): ModalDestination.Style? = (this as? ModalTransitionAnimation)?.style \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/binder/ModelBinder.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/binder/ModelBinder.kt index b620235..8bbf482 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/navigable/binder/ModelBinder.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/binder/ModelBinder.kt @@ -18,12 +18,12 @@ package com.revolut.kompot.navigable.binder import androidx.annotation.VisibleForTesting -interface ModelBinder: ModelObserver { +interface ModelBinder : ModelObserver { fun bind(observer: ModelObserver): Binding fun unbind(observer: ModelObserver) } -internal class DefaultModelBinder : ModelBinder { +internal open class DefaultModelBinder : ModelBinder { @VisibleForTesting internal val observers = mutableListOf>() @@ -45,6 +45,21 @@ internal class DefaultModelBinder : ModelBinder { } +internal class StatefulModelBinder : DefaultModelBinder() { + + private var latestValue: T? = null + + override fun bind(observer: ModelObserver): Binding { + latestValue?.let { value -> observer.notify(value) } + return super.bind(observer) + } + + override fun notify(value: T) { + latestValue = value + super.notify(value) + } +} + @Suppress("FunctionName") internal fun ModelBinder(): ModelBinder = DefaultModelBinder() diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/cache/ControllersCache.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/cache/ControllersCache.kt index f3f6487..471d9c0 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/navigable/cache/ControllersCache.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/cache/ControllersCache.kt @@ -31,4 +31,6 @@ interface ControllersCache { fun isControllerCached(controllerKey: ControllerKey): Boolean fun clearCache() + + fun getCacheLogWithKeys() : String } \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/cache/DefaultControllersCache.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/cache/DefaultControllersCache.kt index 4f13334..fa1f0aa 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/navigable/cache/DefaultControllersCache.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/cache/DefaultControllersCache.kt @@ -165,6 +165,7 @@ class DefaultControllersCache( dependsMap.clear() dependantsGlobalSet.clear() weakFlowControllersMap.clear() + printCacheSize() } private fun printCacheSize() { @@ -209,4 +210,45 @@ class DefaultControllersCache( } } } + + override fun getCacheLogWithKeys(): String { + + return buildString { + + val strongControllersCount = strongControllersMap.size + val weakScreensCount = weakScreenControllersMap.size + val weakFlowsCount = weakFlowControllersMap.size + val totalSize = strongControllersCount + weakScreensCount + weakFlowsCount + + appendLine("--------- Cache statistics ---------") + appendLine("Size $totalSize (strong: $strongControllersCount; weak: $weakCacheSize)") + if (strongControllersMap.values.isNotEmpty()) { + appendLine("Strong storing: ") + strongControllersMap.entries.reversed().forEach { (key, controller) -> + appendLine(" ${controller.controllerName} (${controller.fullControllerName}) key: ${key.value}") + } + } + + if (weakScreenControllersMap.values.isNotEmpty()) { + appendLine("Weak storing: ") + weakScreenControllersMap.entries.reversed().forEach { (key, controller) -> + appendLine(" ${controller.controllerName} (${controller.fullControllerName}) key: ${key.value}") + } + } + + if (weakFlowControllersMap.values.isNotEmpty()) { + appendLine("Flow Weak storing: ") + weakFlowControllersMap.entries.reversed().forEach { (key, controller) -> + appendLine(" ${controller.controllerName} (${controller.fullControllerName}) key: ${key.value})") + } + } + + dependsMap.keys.forEach { key -> + appendLine("$key dependants:") + dependsMap[key]?.forEach { dependant -> + appendLine(" $dependant") + } + } + } + } } \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/extension/BaseStatefulControllerModelExtension.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/extension/BaseStatefulControllerModelExtension.kt new file mode 100644 index 0000000..a446174 --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/extension/BaseStatefulControllerModelExtension.kt @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.extension + +import com.revolut.kompot.navigable.ControllerModelExtension +import com.revolut.kompot.utils.MutableBehaviourFlow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged + +abstract class BaseStatefulControllerModelExtension : ControllerModelExtension(), StatefulControllerModelExtension { + + protected abstract val initialState: STATE + + private val stateFlow by lazy(LazyThreadSafetyMode.NONE) { + MutableBehaviourFlow().apply { tryEmit(initialState) } + } + protected val state: STATE + get() = checkNotNull(stateFlow.replayCache.firstOrNull()) { "initialState must be initialised to use coroutines API" } + + final override fun stateStream(): Flow = stateFlow.distinctUntilChanged() + + protected fun updateState(func: STATE.() -> STATE) { + stateFlow.tryEmit(state.func()) + } + +} \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/extension/StatefulControllerModelExtension.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/extension/StatefulControllerModelExtension.kt new file mode 100644 index 0000000..27a1035 --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/extension/StatefulControllerModelExtension.kt @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.extension + +import kotlinx.coroutines.flow.Flow + +interface StatefulControllerModelExtension { + + fun stateStream(): Flow + +} \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/BaseFlow.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/BaseFlow.kt index 50364b5..92a479d 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/BaseFlow.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/BaseFlow.kt @@ -26,43 +26,49 @@ import androidx.annotation.CallSuper import androidx.annotation.IdRes import androidx.annotation.VisibleForTesting import com.revolut.kompot.BuildConfig +import com.revolut.kompot.KompotPlugin import com.revolut.kompot.R import com.revolut.kompot.common.Event import com.revolut.kompot.common.EventResult import com.revolut.kompot.common.EventsDispatcher import com.revolut.kompot.common.IOData import com.revolut.kompot.common.service.ScreenAddedEvent +import com.revolut.kompot.di.flow.BaseFlowComponent import com.revolut.kompot.holder.ControllerViewHolder import com.revolut.kompot.holder.DefaultControllerViewHolder import com.revolut.kompot.holder.ModalControllerViewHolder import com.revolut.kompot.navigable.Controller import com.revolut.kompot.navigable.ControllerManager import com.revolut.kompot.navigable.ControllerModel +import com.revolut.kompot.navigable.SavedStateOwner import com.revolut.kompot.navigable.TransitionAnimation -import com.revolut.kompot.navigable.binder.CompositeBinding import com.revolut.kompot.navigable.findRootFlow +import com.revolut.kompot.navigable.hooks.LifecycleViewTagHook import com.revolut.kompot.navigable.root.NavActionsScheduler import com.revolut.kompot.navigable.screen.BaseScreen +import com.revolut.kompot.navigable.transition.BackwardTransitionOwner import com.revolut.kompot.navigable.transition.ModalAnimatable import com.revolut.kompot.navigable.utils.Preconditions import com.revolut.kompot.utils.logSize import com.revolut.kompot.view.ControllerContainer +import com.revolut.kompot.view.ControllerContainer.Companion.MAIN_CONTAINER_ID +import com.revolut.kompot.view.ControllerContainer.Companion.MODAL_CONTAINER_ID import kotlinx.coroutines.Dispatchers +import timber.log.Timber @Suppress("SyntheticAccessor") abstract class BaseFlow( val inputData: INPUT_DATA -) : Controller(), Flow, EventsDispatcher { +) : Controller(), Flow, EventsDispatcher, SavedStateOwner { private val lifecycleDelegate by lazy { FlowLifecycleDelegate( controller = this, controllerModel = flowModel as ControllerModel, - childControllerManagers = { childControllerManagers.values.toList() }, + childControllerManagers = { childControllerManagers.all }, onActivityResultInternal = ::onActivityResultInternal ) } - private val serviceEventHandler by lazy { FlowServiceEventHandler( controller = this, @@ -79,39 +85,40 @@ abstract class BaseFlow Unit = { } override val layoutId: Int - get() = parentControllerManager.defaultFlowLayout ?: R.layout.base_flow_container + get() = parentControllerManager.defaultControllerContainer ?: R.layout.base_flow_container @VisibleForTesting - internal lateinit var childManagerContainerView: ViewGroup - private val mainControllerManager: ControllerManager by lazy { - getChildControllerManager(childManagerContainerView) - } - private val childControllerManagers = LinkedHashMap() - - internal lateinit var currentController: Controller + internal lateinit var mainControllerContainer: ControllerContainer + private val childControllerManagers = ControllerManagersHolder() protected abstract val flowModel: FlowModel + override val hasBackStack: Boolean get() = flowModel.hasBackStack + + abstract override val component: BaseFlowComponent - override val controllerDelegates by lazy { + override val controllerExtensions by lazy { component.getControllerExtensions() } - private val tillDestroyBinding = CompositeBinding() @IdRes protected open val containerId: Int = R.id.container override fun createView(inflater: LayoutInflater): View { - val name = this::class.java.simpleName - val view = patchLayoutInflaterWithTheme(inflater).inflate(layoutId, null, false) as? ControllerContainer - ?: throw IllegalStateException("$name: root ViewGroup should be ControllerContainer") + val inflatedLayout = getViewInflater(inflater).inflate(layoutId, null, false) + require(inflatedLayout is ControllerContainer) { "$controllerName: root ViewGroup should be ControllerContainer" } + inflatedLayout.applyEdgeToEdgeConfig() + inflatedLayout.tag = this.controllerName + hooksProvider?.getHook(LifecycleViewTagHook.Key)?.tagId?.let { lifecycleTag -> + inflatedLayout.setTag(lifecycleTag, lifecycle) + } - view.applyEdgeToEdgeConfig() - this.view = view as View - childManagerContainerView = (view as View).findViewById(containerId) - ?: throw IllegalStateException("$name: container for child manager should be presented") + val mainContainerView = inflatedLayout.findViewById(containerId) + requireNotNull(mainContainerView) { "$controllerName: container for child manager should be presented" } + require(mainContainerView is ControllerContainer) { "$controllerName: container for child manager should be ControllerContainer" } + mainContainerView.containerId = MAIN_CONTAINER_ID + mainControllerContainer = mainContainerView - view.tag = controllerName - return view + return inflatedLayout.also { this.view = it } } open fun getModalAnimatable(): ((context: Context) -> ModalAnimatable)? { @@ -126,29 +133,47 @@ abstract class BaseFlow + moveModalToLifecycleForeground(controller, controllerManager) + } + + @CallSuper + internal open fun onChildControllerDetached(controller: Controller, controllerManager: ControllerManager) { + removeModalFromLifecycleForeground(controller, controllerManager) + } + + /** + * Mark every controller under a modal as detached + */ + private fun moveModalToLifecycleForeground(controller: Controller, controllerManager: ControllerManager) { + if (controllerManager.modal && (controllerManager.activeController == null || controllerManager.activeController == controller)) { + childControllerManagers.all.forEach { childControllerManager -> if (childControllerManager != controllerManager && childControllerManager.activeController != null) { childControllerManager.onDetach() } @@ -156,15 +181,19 @@ abstract class BaseFlow + /** + * Bring back attached state for the controllers under a modal + */ + private fun removeModalFromLifecycleForeground(controller: Controller, controllerManager: ControllerManager) { + if (controllerManager.modal && (controllerManager.activeController == null || controllerManager.activeController == controller)) { + childControllerManagers.all.asReversed().forEach { childControllerManager -> if (childControllerManager != controllerManager && childControllerManager.activeController != null) { if (controllerManager.attached) { childControllerManager.onAttach() } - return //we need only one active controller manager after a current + if (childControllerManager.modal) { + return //we need only one active modal controller manager after a current + } } } } @@ -176,19 +205,26 @@ abstract class BaseFlow manager.onDestroy() } + childControllerManagers.all.forEach { manager -> manager.onDestroy() } super.onDestroy() tillDestroyBinding.clear() navActionsScheduler.cancel(key.value) @@ -200,7 +236,7 @@ abstract class BaseFlow) { controller.doOnCreate { @@ -208,30 +244,47 @@ abstract class BaseFlow) { - handleEvent(ScreenAddedEvent(currentController, this, animation != TransitionAnimation.NONE)) + if (controller is BaseScreen<*, *, *>) { + handleEvent(ScreenAddedEvent(controller, this, animation != TransitionAnimation.NONE)) } updateUi() } - private fun pushController( - controller: Controller = flowModel.getController(), - animation: TransitionAnimation = TransitionAnimation.NONE, - backward: Boolean = false, - restore: Boolean = false - ) = scheduleNavAction { - pushControllerNow(controller, animation, backward, restore) + private fun pushController(command: PushControllerCommand) { + fun push() { + pushControllerNow( + controller = command.controller, + animation = command.animation, + backward = command.backward, + restore = command.fromSavedState, + ) + } + if (command.executeImmediately) { + push() + } else { + scheduleNavAction { push() } + } } override fun onAttach() { super.onAttach() updateUi() lifecycleDelegate.onAttach() - childControllerManagers.values.reversed().any { it.onAttach() } + + for (manager in childControllerManagers.all.asReversed()) { + if (manager.activeController != null) { + manager.onAttach() + if (manager.modal) break + } + } + KompotPlugin.controllerLifecycleCallbacks.forEach { callback -> callback.onControllerAttached(this) } } override fun onDetach() { @@ -249,6 +302,11 @@ abstract class BaseFlow) { + val currentController = requireCurrentController() if (command.addCurrentStepToBackStack) { updateChildFlowState() - } else if (currentController is BaseScreen<*, *, *>) { - flowModel.updateCurrentScreenState(Bundle()) } flowModel.setNextState(command.step, command.animation, command.addCurrentStepToBackStack, (currentController as? BaseFlow<*, *, *>)?.flowModel) - pushController(animation = command.animation, backward = false) - } - private fun handleBackStack(): Boolean { - if (flowModel.hasBackStack) { - val backwardAnimation = flowModel.animation - flowModel.restorePreviousState() - - val targetController = flowModel.getController() - pushController( - controller = targetController, - animation = backwardAnimation, - backward = true, - restore = !targetController.created + pushController( + command = PushControllerCommand( + controller = flowModel.getController(), + animation = command.animation, + backward = false, + fromSavedState = false, + executeImmediately = command.executeImmediately, ) - return true - } - return false + ) } + private fun handleBackStack(immediate: Boolean = false) = flowModel.handleBackStack(immediate) + private fun updateChildFlowState() { - currentController.let { controller -> - if (controller is BaseScreen<*, *, *>) { - flowModel.updateCurrentScreenState(controller.saveState()) + when (val currentController = requireCurrentController()) { + is BaseFlow<*, *, *> -> { + currentController.updateChildFlowState() + flowModel.updateChildFlowState(currentController.flowModel) } - controller as? BaseFlow<*, *, *> - }?.also { childFlow -> - childFlow.updateChildFlowState() - flowModel.updateChildFlowState(childFlow.flowModel) + is SavedStateOwner -> { + val bundle = Bundle() + currentController.saveState(outState = bundle) + flowModel.updateCurrentScreenState(bundle) + } } } - internal fun saveState(outState: Bundle) { + final override fun saveState(outState: Bundle) { updateChildFlowState() flowModel.saveState(outState) if (BuildConfig.DEBUG) { @@ -382,32 +436,62 @@ abstract class BaseFlow) { when (command) { is Next -> { if (!navActionsScheduler.ensureAvailability(command)) return Preconditions.requireMainThread("BaseFlowModel.next()") next(command) + logCommand(command) } + is Back -> { if (!navActionsScheduler.ensureAvailability(command)) return Preconditions.requireMainThread("BaseFlowModel.back()") back() } + is Quit -> { if (!navActionsScheduler.ensureAvailability(command)) return Preconditions.requireMainThread("BaseFlowModel.quit()") quitFlow(navActionsScheduler) } + is PostFlowResult -> { Preconditions.requireMainThread("BaseFlowModel.postFlowResult()") onFlowResult(command.data) } + is StartPostponedStateRestore -> { if (!navActionsScheduler.ensureAvailability(command)) return Preconditions.requireMainThread("BaseFlowModel.startPostponedSavedStateRestore()") - pushController(restore = true) + pushController( + command = PushControllerCommand.immediate( + controller = flowModel.getController(), + fromSavedState = true + ) + ) } + + is PushControllerCommand -> { + Preconditions.requireMainThread("Push controller") + pushController(command) + } + } + } + + private fun requireCurrentController(): Controller { + val controllerManager = childControllerManagers.get(mainControllerContainer.containerId) + return checkNotNull(controllerManager?.activeController) + } + + private fun logCommand(command: FlowNavigationCommand) { + if (BuildConfig.DEBUG) { + Timber.d("Kompot NAVIGATION TO $command") } } @@ -422,7 +506,7 @@ abstract class BaseFlow): Boolean { +internal fun NavActionsScheduler.ensureAvailability(newCommand: Any): Boolean { if (hasPendingActions()) { if (BuildConfig.DEBUG) { error(IllegalStateException("Can't start $newCommand. Kompot can only handle one command at a time.")) @@ -437,6 +521,10 @@ internal fun Controller.quitFlow(navActionsScheduler: NavActionsScheduler) { if (parentControllerManager.modal) { navActionsScheduler.schedule(key.value) { parentControllerManager.clear() } } else { - parentController?.handleQuit() + //If there is no parentController, we'll let receiver controller to handle quit itself + //because it must be a root flow + (parentController ?: this).handleQuit() } -} \ No newline at end of file +} + +internal val ControllerManager.containerId get() = (controllerViewHolder.container as ControllerContainer).containerId \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/BaseFlowModel.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/BaseFlowModel.kt index ddcf560..000d53e 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/BaseFlowModel.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/BaseFlowModel.kt @@ -26,19 +26,18 @@ import com.revolut.kompot.common.NavigationDestination import com.revolut.kompot.common.NavigationEvent import com.revolut.kompot.common.NavigationEventHandledResult import com.revolut.kompot.navigable.Controller -import com.revolut.kompot.navigable.ControllerKey import com.revolut.kompot.navigable.ControllerModel import com.revolut.kompot.navigable.TransitionAnimation -import com.revolut.kompot.navigable.cache.ControllerCacheStrategy -import com.revolut.kompot.navigable.screen.BaseScreen import com.revolut.kompot.navigable.binder.ModelBinder -import java.util.* -import kotlin.collections.ArrayList +import com.revolut.kompot.navigable.binder.StatefulModelBinder +import com.revolut.kompot.navigable.screen.BaseScreen +import com.revolut.kompot.navigable.vc.ViewController +import com.revolut.kompot.utils.PostponedRestorationTriggeredEvent abstract class BaseFlowModel : ControllerModel(), FlowModel { internal lateinit var stateWrapper: FlowStateWrapper - private val _backStack = LinkedList>() + private val _backStack = mutableListOf>() internal val backStack: List> get() = _backStack @@ -46,14 +45,11 @@ abstract class BaseFlowModel RestorationState.POSTPONED + restorationPolicy != null -> RestorationState.REQUIRED + else -> null + } private var restorationPolicy: RestorationPolicy? = null + @Deprecated("Replaced by synchronous API. See BaseFlowModel#next") private var onNextStateUpdater: (STATE) -> STATE? = { null } + private var latestStateBackup: Backup? = null protected abstract val initialStep: STEP protected abstract val initialState: STATE protected open val initialBackStack: List> = emptyList() - private val navigationCommandsBinder = ModelBinder>() + private val navigationCommandsBinder = StatefulModelBinder>() final override fun onLifecycleEvent(event: LifecycleEvent) { if (event == LifecycleEvent.CREATED) { - setInitialFlowModelState(restorationPolicy) + performCreate() } super.onLifecycleEvent(event) } + private fun performCreate() { + setInitialFlowModelState(restorationPolicy) + navigationCommandsBinder.notify( + PushControllerCommand( + controller = getController(), + fromSavedState = restorationState == RestorationState.REQUIRED, + animation = TransitionAnimation.NONE, + backward = false, + executeImmediately = true, + ) + ) + } + private fun setInitialFlowModelState(restorationPolicy: RestorationPolicy?) { if (restorationPolicy?.postponed == true) { setInitialState() @@ -102,7 +117,7 @@ abstract class BaseFlowModel STATE? = { null }) { + fun next( + step: STEP, + addCurrentStepToBackStack: Boolean, + animation: TransitionAnimation? = null, + executeImmediately: Boolean = false, + stateUpdater: (STATE) -> STATE? = { null }, + ) { onNextStateUpdater = stateUpdater - next(step, addCurrentStepToBackStack, animation) + next(step, addCurrentStepToBackStack, animation, executeImmediately = executeImmediately) } - fun next(step: STEP, addCurrentStepToBackStack: Boolean, animation: TransitionAnimation? = null) { - navigationCommandsBinder.notify(Next(step, addCurrentStepToBackStack, animation ?: TransitionAnimation.SLIDE_RIGHT_TO_LEFT)) + /** + * Start transition to the specified step + * + * @param executeImmediately start transition immediately without scheduling to the main thread. + * Can produce side effects in the controller that is about to appear. + * Problematic use case: You trigger command from the coroutine dispatched with Dispatchers.Unconfined or Dispatcher.Main.immediate. + * If the new controller starts coroutines in the onCreated/onShown, their execution will be delayed because + * unconfined dispatchers use event loops to prevent stack overflow in the nested invocations. + */ + fun next( + step: STEP, + addCurrentStepToBackStack: Boolean, + animation: TransitionAnimation? = null, + executeImmediately: Boolean = false, + ) { + navigationCommandsBinder.notify( + Next( + step = step, + addCurrentStepToBackStack = addCurrentStepToBackStack, + animation = animation ?: TransitionAnimation.SLIDE_RIGHT_TO_LEFT, + executeImmediately = executeImmediately, + ) + ) } fun back() { @@ -173,45 +217,29 @@ abstract class BaseFlowModel) { controller.doOnCreate { stateWrapper.currentScreenState?.run(controller::restoreState) } } + if (cachedController == null && controller is ViewController<*>) { + controller.doOnCreate { stateWrapper.currentScreenState?.run(controller::restoreState) } + } stateWrapper = stateWrapper.copy(currentControllerKey = controller.key) return controller } - protected fun dependentController( - flowKey: ControllerKey, - step: FlowStep, - controllerProvider: () -> Controller - ): Controller { - val controllerKey = ControllerKey(step.javaClass.canonicalName ?: step.javaClass.simpleName) - val cachedController = controllersCache.getController(controllerKey) - return cachedController ?: controllerProvider().apply { - cacheStrategy = ControllerCacheStrategy.DependentOn(flowKey) - keyInitialization = { controllerKey } - } - } - - protected fun dependentController( - flowKey: String, - step: FlowStep, - controllerProvider: () -> Controller - ): Controller = dependentController(ControllerKey(flowKey), step, controllerProvider) - private fun prepareBackStack(initialBackStack: List>) { _backStack.clear() - initialBackStack.forEach { (step, animation) -> + initialBackStack.forEach { (step, transition) -> _backStack.add( FlowStateWrapper( state = initialState, step = step, - animation = animation + animation = transition ) ) } } - private fun prepareInitialAnimation() = + private fun prepareInitialTransition(): TransitionAnimation = if (initialBackStack.isEmpty()) { TransitionAnimation.NONE } else { @@ -238,7 +266,7 @@ abstract class BaseFlowModel -> stepRestorationCriteria.stepClass == it.step.javaClass } }?.let { - while (hasBackStack && _backStack.last.step != it.step) { + while (hasBackStack && _backStack.last().step != it.step) { removePreviousStateUnsafe() } if (stepRestorationCriteria.removeCurrent) { @@ -249,17 +277,40 @@ abstract class BaseFlowModel?) { + final override fun setNextState( + step: STEP, + animation: TransitionAnimation, + addCurrentStepToBackStack: Boolean, + childFlowModel: FlowModel<*, *>? + ) { if (!addCurrentStepToBackStack) { invalidateCache(stateWrapper, destroy = false) } else { @@ -284,6 +335,20 @@ abstract class BaseFlowModel + stateWrapper = backup.state + _backStack.add(backup.backstackTopEntry) + } + } else { + if (hasBackStack) { + stateWrapper = _backStack.removeLast() + } + } + } + private fun invalidateCache(state: FlowStateWrapper<*, *>, destroy: Boolean) { if (state.currentControllerKey != null) { controllersCache.removeController(state.currentControllerKey, destroy) @@ -295,7 +360,9 @@ abstract class BaseFlowModel?) = - (childFlowModel as? BaseFlowModel<*, *, *>)?.let { + (childFlowModel as? BaseFlowModel<*, *, *>)?.takeIf { + it.restorationState != RestorationState.POSTPONED + }?.let { ChildFlowState(it.stateWrapper.copy(), ArrayList(it.backStack)) } @@ -304,6 +371,7 @@ abstract class BaseFlowModel Boolean, removeCurrent: Boolean) : StepRestorationCriteria(removeCurrent) } + + private inner class Backup( + val state: FlowStateWrapper, + val backstackTopEntry: FlowStateWrapper, + ) } \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/ControllerManagersHolder.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/ControllerManagersHolder.kt new file mode 100644 index 0000000..b53ff40 --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/ControllerManagersHolder.kt @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.flow + +import com.revolut.kompot.navigable.ControllerManager + +internal class ControllerManagersHolder : ControllerManagersProvider { + + private var modalsStartInd = 0 + private val controllerManagers = mutableListOf() + + override val all: List get() = controllerManagers.map { it.controllerManager } + val allNonModal: List + get() = controllerManagers.mapNotNull { + it.controllerManager.takeIf { manager -> !manager.modal } + } + + fun add(controllerManager: ControllerManager, id: String) { + val modal = controllerManager.modal + if (modal) { + controllerManagers.add(ControllerManagerWithId(id, controllerManager)) + } else { + val index = modalsStartInd++ + controllerManagers.add(index, ControllerManagerWithId(id, controllerManager)) + } + } + + inline fun getOrAdd(id: String, defaultValue: () -> ControllerManager): ControllerManager { + get(id)?.let { return it } + return defaultValue().also { controllerManager -> + add(controllerManager, id) + } + } + + fun get(id: String): ControllerManager? = controllerManagers.find { it.id == id }?.controllerManager + + private class ControllerManagerWithId( + val id: String, + val controllerManager: ControllerManager, + ) +} + +internal interface ControllerManagersProvider { + val all: List +} \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/EmptyFlowState.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/EmptyFlowState.kt new file mode 100644 index 0000000..3c09dfe --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/EmptyFlowState.kt @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.flow + +import kotlinx.parcelize.Parcelize + +@Parcelize +object EmptyFlowState : FlowState \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/FlowExtensionsInjector.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/FlowExtensionsInjector.kt new file mode 100644 index 0000000..7576a6d --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/FlowExtensionsInjector.kt @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.flow + +import com.revolut.kompot.di.scope.FlowQualifier +import com.revolut.kompot.navigable.ControllerExtension + +interface FlowExtensionsInjector { + + @FlowQualifier + fun getControllerExtensions(): Set +} \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/FlowLifecycleDelagate.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/FlowLifecycleDelagate.kt index 4dce07e..77e1011 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/FlowLifecycleDelagate.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/FlowLifecycleDelagate.kt @@ -43,7 +43,7 @@ internal class FlowLifecycleDelegate( fun onDetach() { controllerModel.onLifecycleEvent(LifecycleEvent.HIDDEN) - childControllerManagers().reversed().forEach { manager -> manager.onDetach() } + childControllerManagers().asReversed().forEach { manager -> manager.onDetach() } } fun onTransitionStart(enter: Boolean) { @@ -58,6 +58,12 @@ internal class FlowLifecycleDelegate( } } + fun onTransitionCanceled() { + childControllerManagers().forEach { manager -> + manager.activeController?.onTransitionCanceled() + } + } + fun onHostPaused() { childControllerManagers().forEach { manager -> manager.onHostPaused() } } diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/FlowModel.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/FlowModel.kt index 25f5836..dbdee8b 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/FlowModel.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/FlowModel.kt @@ -30,15 +30,13 @@ interface FlowModel { val hasChildFlow: Boolean - val animation: TransitionAnimation - - val restorationNeeded: Boolean - fun navigationBinder(): ModelBinder> fun getController(): Controller - fun restorePreviousState() + fun handleBackStack(immediate: Boolean) : Boolean + + fun onTransitionCanceled(backward: Boolean) fun setNextState( step: STEP, @@ -56,5 +54,4 @@ interface FlowModel { fun restoreState(restorationPolicy: RestorationPolicy) fun handleNavigationDestination(navigationDestination: NavigationDestination): Boolean - } \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/FlowModelExtensionsInjector.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/FlowModelExtensionsInjector.kt new file mode 100644 index 0000000..4b12ace --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/FlowModelExtensionsInjector.kt @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.flow + +import com.revolut.kompot.di.scope.FlowQualifier +import com.revolut.kompot.navigable.ControllerModelExtension + +interface FlowModelExtensionsInjector { + + @FlowQualifier + fun getControllerModelExtensions(): Set + +} \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/FlowNavigationCommand.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/FlowNavigationCommand.kt index 2fb4fd5..d14fff9 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/FlowNavigationCommand.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/FlowNavigationCommand.kt @@ -17,12 +17,17 @@ package com.revolut.kompot.navigable.flow import com.revolut.kompot.common.IOData +import com.revolut.kompot.navigable.Controller import com.revolut.kompot.navigable.TransitionAnimation sealed class FlowNavigationCommand -data class Next(val step: STEP, val addCurrentStepToBackStack: Boolean, val animation: TransitionAnimation) : - FlowNavigationCommand() +data class Next( + val step: STEP, + val addCurrentStepToBackStack: Boolean, + val animation: TransitionAnimation, + val executeImmediately: Boolean = false, +) : FlowNavigationCommand() class Back : FlowNavigationCommand() @@ -30,4 +35,27 @@ class Quit : FlowNavigationCommand(val data: OUTPUT) : FlowNavigationCommand() -class StartPostponedStateRestore: FlowNavigationCommand() \ No newline at end of file +class StartPostponedStateRestore : FlowNavigationCommand() + +internal data class PushControllerCommand( + val controller: Controller, + val fromSavedState: Boolean, + val animation: TransitionAnimation, + val backward: Boolean, + val executeImmediately: Boolean, +) : FlowNavigationCommand() { + + companion object { + + fun immediate( + controller: Controller, + fromSavedState: Boolean, + ) = PushControllerCommand( + controller = controller, + fromSavedState = fromSavedState, + animation = TransitionAnimation.NONE, + backward = false, + executeImmediately = true, + ) + } +} \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/FlowServiceEventHandler.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/FlowServiceEventHandler.kt index 7bbd78b..b35bd06 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/FlowServiceEventHandler.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/FlowServiceEventHandler.kt @@ -31,7 +31,7 @@ import com.revolut.kompot.common.service.ServiceEvent import com.revolut.kompot.navigable.Controller import com.revolut.kompot.navigable.ControllerManager import com.revolut.kompot.navigable.ControllerModel -import com.revolut.kompot.navigable.transition.AnimatorTransition +import com.revolut.kompot.navigable.transition.Transition.Companion.DURATION_DEFAULT internal class FlowServiceEventHandler( private val controller: Controller, @@ -92,7 +92,7 @@ internal class FlowServiceEventHandler( if (currentColor != color) { view.doOnPreDraw { ValueAnimator.ofObject(ArgbEvaluator(), currentColor, color).apply { - duration = AnimatorTransition.DURATION_DEFAULT + duration = DURATION_DEFAULT addUpdateListener { animator -> view.setBackgroundColor(animator.animatedValue as Int) } diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/FlowStateWrapper.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/FlowStateWrapper.kt index 6823ee2..2147993 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/FlowStateWrapper.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/FlowStateWrapper.kt @@ -30,7 +30,7 @@ internal data class FlowStateWrapper( val currentScreenState: Bundle? = null, val animation: TransitionAnimation = TransitionAnimation.NONE, val currentControllerKey: ControllerKey? = null -): Parcelable +) : Parcelable @Parcelize internal data class ChildFlowState( diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/FlowStep.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/FlowStep.kt index 9954202..d73ff17 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/FlowStep.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/FlowStep.kt @@ -17,5 +17,13 @@ package com.revolut.kompot.navigable.flow import android.os.Parcelable +import com.revolut.kompot.navigable.ControllerKey -interface FlowStep : Parcelable \ No newline at end of file +interface FlowStep : Parcelable + +interface ReusableFlowStep : FlowStep { + val key: String +} + +internal fun ReusableFlowStep.getControllerKey(flowKey: ControllerKey): ControllerKey = + ControllerKey("${flowKey}_$key") \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/RestorationPolicy.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/RestorationPolicy.kt index b6b4c4a..db534ef 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/RestorationPolicy.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/RestorationPolicy.kt @@ -24,4 +24,9 @@ sealed class RestorationPolicy { data class FromBundle(val bundle: Bundle, override val postponed: Boolean = false) : RestorationPolicy() data class FromParent(val parentFlowModel: FlowModel<*, *>, override val postponed: Boolean = false) : RestorationPolicy() +} + +enum class RestorationState { + REQUIRED, + POSTPONED, } \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/scroller/BaseScrollerFlow.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/scroller/BaseScrollerFlow.kt index a16cc07..93ec786 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/scroller/BaseScrollerFlow.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/scroller/BaseScrollerFlow.kt @@ -25,35 +25,37 @@ import androidx.recyclerview.widget.PagerSnapHelper import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.SnapHelper import com.revolut.kompot.ExperimentalKompotApi +import com.revolut.kompot.KompotPlugin import com.revolut.kompot.R import com.revolut.kompot.common.Event import com.revolut.kompot.common.EventResult import com.revolut.kompot.common.EventsDispatcher import com.revolut.kompot.common.IOData +import com.revolut.kompot.di.flow.scroller.BaseScrollerFlowComponent import com.revolut.kompot.navigable.Controller import com.revolut.kompot.navigable.ControllerModel -import com.revolut.kompot.navigable.binder.CompositeBinding import com.revolut.kompot.navigable.findRootFlow import com.revolut.kompot.navigable.flow.Back import com.revolut.kompot.navigable.flow.BaseFlow import com.revolut.kompot.navigable.flow.FlowLifecycleDelegate import com.revolut.kompot.navigable.flow.FlowNavigationCommand import com.revolut.kompot.navigable.flow.FlowServiceEventHandler -import com.revolut.kompot.navigable.flow.FlowStep import com.revolut.kompot.navigable.flow.PostFlowResult import com.revolut.kompot.navigable.flow.Quit import com.revolut.kompot.navigable.flow.ensureAvailability import com.revolut.kompot.navigable.flow.quitFlow import com.revolut.kompot.navigable.flow.scroller.steps.StepsChangeCommand +import com.revolut.kompot.navigable.hooks.LifecycleViewTagHook import com.revolut.kompot.navigable.root.NavActionsScheduler import com.revolut.kompot.navigable.utils.Preconditions +import com.revolut.kompot.navigable.vc.scroller.ScrollMode import com.revolut.kompot.view.ControllerContainer import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @ExperimentalKompotApi -abstract class BaseScrollerFlow( +abstract class BaseScrollerFlow( val inputData: INPUT_DATA ) : Controller(), ScrollerFlow, EventsDispatcher { @@ -89,7 +91,7 @@ abstract class BaseScrollerFlow + override val hasBackStack: Boolean = false - override val controllerDelegates by lazy { + abstract override val component: BaseScrollerFlowComponent + + override val controllerExtensions by lazy { component.getControllerExtensions() } - private val tillDestroyBinding = CompositeBinding() - @IdRes protected open val recyclerViewId: Int = R.id.recyclerView private lateinit var recyclerView: RecyclerView override fun createView(inflater: LayoutInflater): View { - val controllerContainer = patchLayoutInflaterWithTheme(inflater).inflate(layoutId, null, false) as? ControllerContainer + val controllerContainer = getViewInflater(inflater).inflate(layoutId, null, false) as? ControllerContainer ?: throw IllegalStateException("Root ViewGroup should be ControllerContainer") controllerContainer.applyEdgeToEdgeConfig() view = controllerContainer as View view.tag = controllerName + hooksProvider?.getHook(LifecycleViewTagHook.Key)?.tagId?.let { lifecycleTag -> + view.setTag(lifecycleTag, lifecycle) + } view.findViewById(recyclerViewId).apply { recyclerView = this layoutManager = this@BaseScrollerFlow.layoutManager @@ -154,9 +160,9 @@ abstract class BaseScrollerFlow) { - val step = stepsChangeCommand.selected ?: return + val stepId = stepsChangeCommand.selectedStepId ?: return - val position = controllersAdapter.currentList.indexOf(step) + val position = controllersAdapter.currentList.indexOfFirst { it.id == stepId } if (position in 0..layoutManager.itemCount) { if (stepsChangeCommand.smoothScroll) { //We need to queue the smooth scrolls until everything is laid out @@ -180,7 +186,8 @@ abstract class BaseScrollerFlow) { if (controllersAdapter.currentList != stepsChangeCommand.steps) { + controllersAdapter.updateCache(controllersAdapter.currentList, stepsChangeCommand.steps) controllersAdapter.submitList(stepsChangeCommand.steps) { scrollToSelectedStep(stepsChangeCommand) } @@ -205,6 +213,7 @@ abstract class BaseScrollerFlow manager.onDestroy() } super.onDestroy() tillDestroyBinding.clear() @@ -217,6 +226,7 @@ abstract class BaseScrollerFlow manager.onAttach() } + KompotPlugin.controllerLifecycleCallbacks.forEach { callback -> callback.onControllerAttached(this) } } override fun onDetach() { @@ -234,6 +244,11 @@ abstract class BaseScrollerFlow { if (!navActionsScheduler.ensureAvailability(command)) return Preconditions.requireMainThread("BaseScrollerFlow.quit()") quitFlow(navActionsScheduler) } + is PostFlowResult -> { Preconditions.requireMainThread("BaseScrollerFlow.postFlowResult()") onFlowResult(command.data) } + else -> throw IllegalStateException("$command is not supported by the ScrollerFlow") } } - - enum class ScrollMode { - HORIZONTAL, VERTICAL, PAGER - } } \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/scroller/BaseScrollerFlowModel.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/scroller/BaseScrollerFlowModel.kt index 320e656..79bbb5c 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/scroller/BaseScrollerFlowModel.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/scroller/BaseScrollerFlowModel.kt @@ -22,7 +22,6 @@ import com.revolut.kompot.navigable.ControllerModel import com.revolut.kompot.navigable.binder.ModelBinder import com.revolut.kompot.navigable.flow.Back import com.revolut.kompot.navigable.flow.FlowNavigationCommand -import com.revolut.kompot.navigable.flow.FlowStep import com.revolut.kompot.navigable.flow.PostFlowResult import com.revolut.kompot.navigable.flow.Quit import com.revolut.kompot.navigable.flow.scroller.steps.Steps @@ -32,7 +31,7 @@ import com.revolut.kompot.utils.MutableBehaviourFlow import kotlinx.coroutines.flow.Flow @ExperimentalKompotApi -abstract class BaseScrollerFlowModel : +abstract class BaseScrollerFlowModel : ControllerModel(), ScrollerFlowModel { protected abstract val initialSteps: Steps @@ -53,14 +52,14 @@ abstract class BaseScrollerFlowModel : final override fun stepsCommands(): Flow> = _stepsCommands protected fun updateSteps( - selected: STEP? = null, + selectedStepId: String? = null, steps: List = lastStepsCommand.steps, smoothScroll: Boolean = true, ) { _stepsCommands.tryEmit( StepsChangeCommand( steps = steps, - selected = selected, + selectedStepId = selectedStepId, smoothScroll = smoothScroll ) ) diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/scroller/FixedIdScrollerStep.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/scroller/FixedIdScrollerStep.kt new file mode 100644 index 0000000..6eb6dad --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/scroller/FixedIdScrollerStep.kt @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.flow.scroller + +abstract class FixedIdScrollerStep: ScrollerFlowStep { + + override val id: String = this.javaClass.name +} \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/scroller/ScrollerFlowControllersAdapter.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/scroller/ScrollerFlowControllersAdapter.kt index fb6c136..b977782 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/scroller/ScrollerFlowControllersAdapter.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/scroller/ScrollerFlowControllersAdapter.kt @@ -22,7 +22,6 @@ import androidx.annotation.LayoutRes import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView -import com.revolut.kompot.ExperimentalKompotApi import com.revolut.kompot.holder.DefaultControllerViewHolder import com.revolut.kompot.navigable.Controller import com.revolut.kompot.navigable.ControllerKey @@ -30,20 +29,23 @@ import com.revolut.kompot.navigable.ControllerManager import com.revolut.kompot.navigable.TransitionAnimation import com.revolut.kompot.navigable.cache.ControllerCacheStrategy import com.revolut.kompot.navigable.cache.ControllersCache -import com.revolut.kompot.navigable.flow.FlowStep -import java.util.* +import com.revolut.kompot.navigable.flow.ControllerManagersProvider +import com.revolut.kompot.navigable.vc.scroller.ScrollerItem +import java.util.LinkedList -@ExperimentalKompotApi -internal class ScrollerFlowControllersAdapter( +internal class ScrollerFlowControllersAdapter( @LayoutRes private val layoutContainerId: Int, private val parentController: Controller, private val controllersCache: ControllersCache, - private val flowModel: ScrollerFlowModel -) : ListAdapter(ItemCallBack()) { + private val controllersFactory: (T) -> Controller, +) : ListAdapter(ItemCallBack()), ControllerManagersProvider { - private val controllerKeys = HashMap() + private val controllerKeys = HashMap() val childControllerManagers = LinkedList() + override val all: List + get() = childControllerManagers + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ControllerViewHolder { val container = LayoutInflater.from(parent.context).inflate(layoutContainerId, parent, false) return ControllerViewHolder( @@ -62,7 +64,7 @@ internal class ScrollerFlowControllersAdapter( controller = controller, animation = TransitionAnimation.NONE, backward = false, - parentController = parentController + parentController = parentController, ) } } @@ -79,6 +81,16 @@ internal class ScrollerFlowControllersAdapter( removeController(holder) } + fun updateCache(oldList: List, newList: List) { + val itemsToRemove = oldList - newList.toSet() + itemsToRemove.map(::removeControllerFromCacheByStep) + } + + private fun removeControllerFromCacheByStep(item: T) { + controllerKeys[item]?.let { controllerKey -> controllersCache.removeController(controllerKey, true) } + controllerKeys.remove(item) + } + private fun removeController(holder: ControllerViewHolder) = holder.controllerManager.apply { detach() @@ -90,14 +102,14 @@ internal class ScrollerFlowControllersAdapter( onDetach() } - private fun getController(step: STEP): Controller = controllerKeys[step] + private fun getController(item: T): Controller = controllerKeys[item] ?.let { controllersCache.getController(it) } - ?: createControllerInternal(step) + ?: createControllerInternal(item) - private fun createControllerInternal(step: STEP) = - flowModel.getController(step).also { - it.cacheStrategy = ControllerCacheStrategy.DependentOn(parentController.key) - controllerKeys[step] = it.key + private fun createControllerInternal(item: T) = + controllersFactory.invoke(item).also { + it.cacheStrategy = ControllerCacheStrategy.Prioritized + controllerKeys[item] = it.key } internal class ControllerViewHolder( @@ -108,12 +120,18 @@ internal class ScrollerFlowControllersAdapter( controllerViewHolder = DefaultControllerViewHolder(itemView), modal = false, controllersCache = controllersCache, - defaultFlowLayout = null + defaultControllerContainer = null, + onTransitionCanceled = null, ) } - internal class ItemCallBack : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: STEP, newItem: STEP): Boolean = oldItem.javaClass.name == newItem.javaClass.name - override fun areContentsTheSame(oldItem: STEP, newItem: STEP): Boolean = oldItem.javaClass.name == newItem.javaClass.name + internal class ItemCallBack : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: T, newItem: T): Boolean { + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: T, newItem: T): Boolean { + return oldItem.equals(newItem) + } } } \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/scroller/ScrollerFlowModel.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/scroller/ScrollerFlowModel.kt index 37757c0..9c6403d 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/scroller/ScrollerFlowModel.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/scroller/ScrollerFlowModel.kt @@ -21,13 +21,12 @@ import com.revolut.kompot.common.IOData import com.revolut.kompot.navigable.Controller import com.revolut.kompot.navigable.binder.ModelBinder import com.revolut.kompot.navigable.flow.FlowNavigationCommand -import com.revolut.kompot.navigable.flow.FlowStep import com.revolut.kompot.navigable.flow.scroller.steps.StepsChangeCommand import kotlinx.coroutines.flow.Flow @ExperimentalKompotApi interface ScrollerFlowModel< - STEP : FlowStep, + STEP : ScrollerFlowStep, OUTPUT_DATA : IOData.Output > { diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/scroller/ScrollerFlowStep.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/scroller/ScrollerFlowStep.kt new file mode 100644 index 0000000..7469f70 --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/scroller/ScrollerFlowStep.kt @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.flow.scroller + +import com.revolut.kompot.navigable.flow.FlowStep +import com.revolut.kompot.navigable.vc.scroller.ScrollerItem + +/** + * stepId used to identify each step, so it must be unique. + * If your steps don’t have any input params, you can inherit from [FixedIdScrollerStep] which uses javaClass.name as id to avoid providing ids manually + */ +interface ScrollerFlowStep : FlowStep, ScrollerItem \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/scroller/steps/Steps.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/scroller/steps/Steps.kt index b698239..2acce4a 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/scroller/steps/Steps.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/scroller/steps/Steps.kt @@ -16,20 +16,20 @@ package com.revolut.kompot.navigable.flow.scroller.steps -import com.revolut.kompot.navigable.flow.FlowStep +import com.revolut.kompot.navigable.flow.scroller.ScrollerFlowStep -data class Steps( +data class Steps( val steps: List, - val selected: S = requireNotNull(steps.firstOrNull()) { "Non empty list should be provided for steps" } + val selectedStepId: String = requireNotNull(steps.firstOrNull()?.id) { "Non empty list should be provided for steps" } ) { companion object { - operator fun invoke(vararg steps: S): Steps = Steps(steps = steps.toList()) + operator fun invoke(vararg steps: S): Steps = Steps(steps = steps.toList()) } } -data class StepsChangeCommand( +data class StepsChangeCommand( val steps: List, - val selected: S?, + val selectedStepId: String?, val smoothScroll: Boolean, ) \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/scroller/steps/StepsMapper.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/scroller/steps/StepsMapper.kt index f3414dd..7806b1e 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/scroller/steps/StepsMapper.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/flow/scroller/steps/StepsMapper.kt @@ -16,7 +16,7 @@ package com.revolut.kompot.navigable.flow.scroller.steps -import com.revolut.kompot.navigable.flow.FlowStep +import com.revolut.kompot.navigable.flow.scroller.ScrollerFlowStep -internal fun Steps.toChangeCommand(smoothScroll: Boolean) = - StepsChangeCommand(steps = steps, selected = selected, smoothScroll = smoothScroll) \ No newline at end of file +internal fun Steps.toChangeCommand(smoothScroll: Boolean) = + StepsChangeCommand(steps = steps, selectedStepId = selectedStepId, smoothScroll = smoothScroll) \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/hooks/ControllerViewContextHook.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/hooks/ControllerViewContextHook.kt new file mode 100644 index 0000000..ca9b8e7 --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/hooks/ControllerViewContextHook.kt @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.hooks + +import android.content.Context +import com.revolut.kompot.navigable.utils.ControllerEnvironment + +class ControllerViewContextHook(val hook: (ControllerEnvironment, Context) -> Context) : ControllerHook { + companion object Key : ControllerHook.Key +} \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/hooks/LifecycleViewTagHook.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/hooks/LifecycleViewTagHook.kt new file mode 100644 index 0000000..506c113 --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/hooks/LifecycleViewTagHook.kt @@ -0,0 +1,21 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.hooks + +class LifecycleViewTagHook(val tagId: Int) : ControllerHook { + companion object Key : ControllerHook.Key +} \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/hooks/PersistentModelStateStorageHook.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/hooks/PersistentModelStateStorageHook.kt new file mode 100644 index 0000000..a9d48fe --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/hooks/PersistentModelStateStorageHook.kt @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.hooks + +import com.revolut.kompot.navigable.vc.ui.PersistentModelStateStorage + +class PersistentModelStateStorageHook(val storage: PersistentModelStateStorage) : ControllerHook { + companion object Key : ControllerHook.Key +} \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/root/BaseRootFlowModel.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/root/BaseRootFlowModel.kt index 27f193a..0b91eb5 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/navigable/root/BaseRootFlowModel.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/root/BaseRootFlowModel.kt @@ -16,6 +16,7 @@ package com.revolut.kompot.navigable.root +import androidx.annotation.VisibleForTesting import com.revolut.kompot.common.Event import com.revolut.kompot.common.EventResult import com.revolut.kompot.common.ExternalDestination @@ -23,21 +24,35 @@ import com.revolut.kompot.common.IOData import com.revolut.kompot.common.ModalDestination import com.revolut.kompot.common.NavigationEvent import com.revolut.kompot.common.NavigationEventHandledResult +import com.revolut.kompot.utils.PostponedRestorationTriggeredEvent +import com.revolut.kompot.utils.EventHandledResult import com.revolut.kompot.navigable.Controller import com.revolut.kompot.navigable.flow.BaseFlowModel import com.revolut.kompot.navigable.flow.FlowState import com.revolut.kompot.navigable.flow.FlowStep +import com.revolut.kompot.navigable.vc.modal.ModalRestorationRequest abstract class BaseRootFlowModel : BaseFlowModel() { open val onExternalActivityOpened: () -> Unit = {} internal lateinit var rootNavigator: RootNavigator + private val modalRestorationRequests = mutableListOf() + private var controllersFirstLayoutConsumed = false + override fun onCreated() { super.onCreated() rootNavigator.addOpenExternalForResultListener(onExternalActivityOpened) } + //Callback introduced to manage modals saved state because it's error prone to trigger modals creation while flows are attaching. + //Doing so results in lifecycle events conflict (Modal tries to cover a flow while its onAttach method is still in process) + //It is safe to call modals restoration after initial controllers hierarchy is laid out + internal fun onControllersFirstLayout() { + restoreModals() + controllersFirstLayoutConsumed = true + } + override fun onFinished() { super.onFinished() @@ -50,24 +65,66 @@ abstract class BaseRootFlowModel : BaseFlowM //This way the logic in successors in handleNavigationDestination will be handled first //that keeps the behaviour consistent with handleNavigationDestination logic. val superResult = super.tryHandleEvent(event) - if(superResult != null) return superResult + if (superResult != null) return superResult if (event is NavigationEvent) { when (event.destination) { - is ModalDestination -> rootNavigator.openModal(event.destination, event.controller) + is ModalDestination -> rootNavigator.openModal(event.destination, event.controller, showImmediately = false) is ExternalDestination -> handleExternalDestination(event.destination, event.controller) else -> return super.tryHandleEvent(event) } return NavigationEventHandledResult } + if (event is ModalRestorationRequest) { + modalRestorationRequests.add(event) + return EventHandledResult + } + if (event is PostponedRestorationTriggeredEvent) { + if (controllersFirstLayoutConsumed) { + restoreModals() + } + return EventHandledResult + } return null } + @VisibleForTesting + fun setupRootNavigator(rootFlow: RootFlow<*, *>) { + rootNavigator = RootNavigator(rootFlow) + } + private fun handleExternalDestination(destination: ExternalDestination, controller: Controller?) { when (destination) { is ExternalDestination.Browser -> rootNavigator.openWebPage(destination.url) else -> rootNavigator.openExternal(destination, controller) } } + + private fun restoreModals() { + /** + * Each restored modal can be a host for another modal. In this case, the restored modal + * will trigger child modal restoration request upon creation. Therefore we poll restoration + * requests in a loop until all of them are processed + */ + while (modalRestorationRequests.isNotEmpty()) { + //Making a sorted copy of requests list to avoid concurrent modifications. + //Sorting required to restore modals in the correct order + modalRestorationRequests.sortedBy { it.index }.forEach { request -> + restoreModal(request) + modalRestorationRequests.remove(request) + } + } + } + + private fun restoreModal(restorationRequest: ModalRestorationRequest) { + rootNavigator.openModal( + ModalDestination.CallbackController( + controller = restorationRequest.modalController, + style = restorationRequest.style, + ), + callerController = restorationRequest.controller, + showImmediately = true, + ) + } } \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/root/RootFlow.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/root/RootFlow.kt index fd27385..efd7588 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/navigable/root/RootFlow.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/root/RootFlow.kt @@ -17,8 +17,8 @@ package com.revolut.kompot.navigable.root import android.view.View +import androidx.core.view.doOnLayout import androidx.core.view.isVisible -import com.revolut.kompot.ExperimentalBottomDialogStyle import com.revolut.kompot.common.IOData import com.revolut.kompot.common.ModalDestination import com.revolut.kompot.dialog.DefaultLoadingDialogDisplayer @@ -29,10 +29,11 @@ import com.revolut.kompot.navigable.TransitionAnimation import com.revolut.kompot.navigable.cache.ControllerCacheStrategy import com.revolut.kompot.navigable.flow.BaseFlow import com.revolut.kompot.navigable.flow.FlowStep +import com.revolut.kompot.navigable.toModalTransitionAnimation +import com.revolut.kompot.view.ControllerContainer import com.revolut.kompot.view.ControllerContainerFrameLayout -abstract class RootFlow(inputData: INPUT_DATA) : - BaseFlow(inputData) { +abstract class RootFlow(inputData: INPUT_DATA) : BaseFlow(inputData) { open val rootDialogDisplayer by lazy(LazyThreadSafetyMode.NONE) { DialogDisplayer( @@ -55,6 +56,10 @@ abstract class RootFlow(inputData: I rootDialogDisplayer.onCreate() (flowModel as BaseRootFlowModel<*, *>).rootNavigator = RootNavigator(this) + + view.doOnLayout { + (flowModel as BaseRootFlowModel<*, *>).onControllersFirstLayout() + } } override fun onDestroyFlowView() { @@ -76,43 +81,29 @@ abstract class RootFlow(inputData: I rootDialogDisplayer.onDetach() } - @OptIn(ExperimentalBottomDialogStyle::class) - internal fun open( - controller: Controller, - style: ModalDestination.Style, - parentController: Controller? - ) { - containerForModalNavigation.isVisible = true - getFirstAvailableModalManager().show( - controller = controller, - animation = when (style) { - ModalDestination.Style.POPUP -> { - if (getModalAnimatable() != null) - TransitionAnimation.MODAL_SLIDE - else - TransitionAnimation.FADE - } - ModalDestination.Style.FULLSCREEN -> - if (getModalAnimatable() != null) - TransitionAnimation.MODAL_FADE - else - TransitionAnimation.FADE - - ModalDestination.Style.BOTTOM_DIALOG -> - if (getModalAnimatable() != null) - TransitionAnimation.BOTTOM_DIALOG_SLIDE - else - TransitionAnimation.FADE - }, - backward = false, - parentController = parentController ?: this - ) + internal fun open(controller: Controller, style: ModalDestination.Style, parentController: Controller?, showImmediately: Boolean) { + val openModalAction = { + containerForModalNavigation.containerId = ControllerContainer.MODAL_CONTAINER_ID + containerForModalNavigation.isVisible = true + getFirstAvailableModalManager().show( + controller = controller, + animation = if (getModalAnimatable() != null) { + style.toModalTransitionAnimation(showImmediately) + } else { + TransitionAnimation.FADE + }, + backward = false, + parentController = parentController ?: this, + ) + } + if (showImmediately || style == ModalDestination.Style.FULLSCREEN_IMMEDIATE) { + openModalAction() + } else { + navActionsScheduler.schedule(key.value, openModalAction) + } } - override fun onChildControllerAttached( - controller: Controller, - controllerManager: ControllerManager - ) { + override fun onChildControllerAttached(controller: Controller, controllerManager: ControllerManager) { super.onChildControllerAttached(controller, controllerManager) if (controllerManager.modal) { modalManagersCount++ @@ -123,10 +114,7 @@ abstract class RootFlow(inputData: I } } - override fun onChildControllerDetached( - controller: Controller, - controllerManager: ControllerManager - ) { + override fun onChildControllerDetached(controller: Controller, controllerManager: ControllerManager) { super.onChildControllerDetached(controller, controllerManager) if (controllerManager.modal) { modalManagersCount-- @@ -137,13 +125,13 @@ abstract class RootFlow(inputData: I } private fun getFirstAvailableModalManager(): ControllerManager = - getChildControllerManager(containerForModalNavigation, "modal_N${modalManagersCount}") + getOrCreateChildControllerManager(containerForModalNavigation, "modalControllerManager_N${modalManagersCount}") override fun updateUi(step: STEP) = Unit override fun handleQuit() { if (!flowModel.hasBackStack) { - activity.finish() + handleQuitOnEmptyBackStack() } else { super.handleQuit() } @@ -156,6 +144,10 @@ abstract class RootFlow(inputData: I return BackHandleResult.INTERCEPTED } + protected open fun handleQuitOnEmptyBackStack() { + activity.finish() + } + protected open fun handleBackOnEmptyBackStack() { activity.onBackPressed() } diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/root/RootNavigator.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/root/RootNavigator.kt index 6bf044b..f28c89b 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/navigable/root/RootNavigator.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/root/RootNavigator.kt @@ -16,26 +16,48 @@ package com.revolut.kompot.navigable.root +import com.revolut.kompot.ExperimentalKompotApi import com.revolut.kompot.common.ExternalDestination import com.revolut.kompot.common.IOData import com.revolut.kompot.common.ModalDestination import com.revolut.kompot.common.toIntent import com.revolut.kompot.navigable.Controller import com.revolut.kompot.navigable.flow.BaseFlow +import com.revolut.kompot.navigable.flow.Flow +import com.revolut.kompot.navigable.flow.scroller.ScrollerFlow +import com.revolut.kompot.navigable.screen.Screen +import com.revolut.kompot.navigable.vc.ViewController +import com.revolut.kompot.navigable.vc.flow.FlowViewController import com.revolut.kompot.view.ControllerContainer internal class RootNavigator(private val rootFlow: RootFlow<*, *>) { private val addOpenExternalForResultListeners = mutableListOf<() -> Unit>() - fun openModal(destination: ModalDestination, callerController: Controller) { - if (destination is ModalDestination.ExplicitScreen<*>) { - openModalScreen(destination, callerController) - } else if (destination is ModalDestination.ExplicitFlow<*>) { - openModalFlow(destination, callerController) + fun openModal(destination: ModalDestination, callerController: Controller, showImmediately: Boolean) { + when (destination) { + is ModalDestination.ExplicitScreen<*> -> { + openModalScreen(destination, callerController, showImmediately) + } + + is ModalDestination.ExplicitFlow<*> -> { + openModalFlow(destination, callerController, showImmediately) + } + + is ModalDestination.ExplicitScrollerFlow<*> -> { + openModalScrollerFlow(destination, callerController, showImmediately) + } + + is ModalDestination.CallbackController -> { + openModalCallbackController(destination, callerController, showImmediately) + } } } - private fun openModalScreen(destination: ModalDestination.ExplicitScreen, callerController: Controller) { + private fun openModalScreen( + destination: ModalDestination.ExplicitScreen, + callerController: Controller, + showImmediately: Boolean + ) { rootFlow.open( controller = destination.screen.apply { (this as Controller).run { @@ -49,11 +71,40 @@ internal class RootNavigator(private val rootFlow: RootFlow<*, *>) { } } as Controller, style = destination.style, - parentController = callerController.getFlow() + parentController = callerController.getFlow(), + showImmediately = showImmediately, + ) + } + + private fun openModalFlow( + destination: ModalDestination.ExplicitFlow, + callerController: Controller, + showImmediately: Boolean + ) { + rootFlow.open( + controller = destination.flow.apply { + (this as Controller).run { + doOnCreate { + (view as ControllerContainer).fitStatusBar = true + } + } + onFlowResult = { result -> + (this as Controller).parentControllerManager.clear() + destination.onResult?.invoke(result) + } + } as Controller, + style = destination.style, + parentController = callerController.getFlow(), + showImmediately = showImmediately, ) } - private fun openModalFlow(destination: ModalDestination.ExplicitFlow, callerController: Controller) { + @OptIn(ExperimentalKompotApi::class) + private fun openModalScrollerFlow( + destination: ModalDestination.ExplicitScrollerFlow, + callerController: Controller, + showImmediately: Boolean + ) { rootFlow.open( controller = destination.flow.apply { (this as Controller).run { @@ -67,11 +118,67 @@ internal class RootNavigator(private val rootFlow: RootFlow<*, *>) { } } as Controller, style = destination.style, - parentController = callerController.getFlow() + parentController = callerController.getFlow(), + showImmediately = showImmediately, ) } - private fun Controller.getFlow(): Controller? = if (this is BaseFlow<*, *, *>) { + @OptIn(ExperimentalKompotApi::class) + private fun openModalCallbackController( + destination: ModalDestination.CallbackController, + callerController: Controller, + showImmediately: Boolean + ) { + rootFlow.open( + controller = destination.controller.apply { + when (this) { + is ViewController<*> -> applyViewControllerModalResultHandling() + is Screen<*> -> applyScreenModalResultHandling() + is Flow<*> -> applyFlowModalResultHandling() + is ScrollerFlow<*> -> applyScrollerModalResultHandling() + else -> error("Unsupported modal controller type: ${destination.controller::class.java}}") + } + }, + style = destination.style, + parentController = callerController.getFlow(), + showImmediately = showImmediately, + ) + } + + private fun Screen.applyScreenModalResultHandling() { + val originalResultHandler = onScreenResult + onScreenResult = { result -> + (this as Controller).parentControllerManager.clear() + originalResultHandler.invoke(result) + } + } + + private fun Flow.applyFlowModalResultHandling() { + val originalResultHandler = onFlowResult + onFlowResult = { result -> + (this as Controller).parentControllerManager.clear() + originalResultHandler.invoke(result) + } + } + + private fun ViewController.applyViewControllerModalResultHandling() { + val originalResultHandler = onResult + onResult = { result -> + (this as Controller).parentControllerManager.clear() + originalResultHandler.invoke(result) + } + } + + @OptIn(ExperimentalKompotApi::class) + private fun ScrollerFlow.applyScrollerModalResultHandling() { + val originalResultHandler = onFlowResult + onFlowResult = { result -> + (this as Controller).parentControllerManager.clear() + originalResultHandler.invoke(result) + } + } + + private fun Controller.getFlow(): Controller? = if (this is BaseFlow<*, *, *> || this is FlowViewController) { this } else { parentController diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/screen/BaseRecyclerViewScreen.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/screen/BaseRecyclerViewScreen.kt index ca731a9..2c07ba0 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/navigable/screen/BaseRecyclerViewScreen.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/screen/BaseRecyclerViewScreen.kt @@ -21,6 +21,9 @@ import android.os.Parcelable import android.view.View import androidx.annotation.CallSuper import androidx.annotation.IdRes +import androidx.annotation.RestrictTo +import androidx.annotation.RestrictTo.Scope.TESTS +import androidx.annotation.VisibleForTesting import androidx.recyclerview.widget.DefaultItemAnimator import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager @@ -30,8 +33,9 @@ import com.revolut.decorations.overlay.DelegatesOverlayItemDecoration import com.revolut.kompot.R import com.revolut.kompot.common.IOData import com.revolut.kompot.navigable.hooks.BaseRecyclerViewScreenHook +import com.revolut.recyclerkit.delegates.DelegatesManager +import com.revolut.recyclerkit.delegates.DiffAdapter import com.revolut.recyclerkit.delegates.RecyclerViewDelegate -import com.revolut.rxdiffadapter.RxDiffAdapter abstract class BaseRecyclerViewScreen< UI_STATE : ScreenStates.UIList, @@ -42,6 +46,9 @@ abstract class BaseRecyclerViewScreen< override val layoutId: Int = R.layout.screen_recycler_view protected abstract val delegates: List> + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + @get:RestrictTo(TESTS) + val delegatesForTesting: List> get() = delegates protected lateinit var recyclerView: RecyclerView @IdRes protected open val recyclerViewId: Int = R.id.recyclerView @@ -50,12 +57,11 @@ abstract class BaseRecyclerViewScreen< protected open val saveRecyclerViewState = true private var recyclerViewState: Parcelable? = null - protected open val listAdapter: RxDiffAdapter by lazy(LazyThreadSafetyMode.NONE) { - RxDiffAdapter( - delegates = emptyList(), + protected open val listAdapter: DiffAdapter by lazy(LazyThreadSafetyMode.NONE) { + DiffAdapter( + delegatesManager = DelegatesManager(emptyList()), async = false, - autoScrollToTop = autoScrollToTop, - detectMoves = true + autoScrollToTop = autoScrollToTop ) } diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/screen/BaseScreen.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/screen/BaseScreen.kt index a0c3231..059a4e8 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/navigable/screen/BaseScreen.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/screen/BaseScreen.kt @@ -16,26 +16,28 @@ package com.revolut.kompot.navigable.screen -import android.app.Service import android.content.Intent import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.WindowManager -import android.view.inputmethod.InputMethodManager import androidx.core.view.postDelayed +import com.revolut.kompot.KompotPlugin import com.revolut.kompot.common.Event import com.revolut.kompot.common.EventResult import com.revolut.kompot.common.EventsDispatcher import com.revolut.kompot.common.IOData import com.revolut.kompot.common.LifecycleEvent -import com.revolut.kompot.di.flow.ParentFlowComponent +import com.revolut.kompot.di.flow.ControllerComponent import com.revolut.kompot.di.screen.BaseScreenComponent import com.revolut.kompot.navigable.Controller import com.revolut.kompot.navigable.ControllerModel -import com.revolut.kompot.navigable.binder.CompositeBinding +import com.revolut.kompot.navigable.SavedStateOwner import com.revolut.kompot.navigable.findRootFlow +import com.revolut.kompot.navigable.hooks.LifecycleViewTagHook import com.revolut.kompot.navigable.utils.Preconditions +import com.revolut.kompot.navigable.utils.hideKeyboard +import com.revolut.kompot.navigable.utils.showKeyboard import com.revolut.kompot.utils.DEFAULT_EXTRA_BUFFER_CAPACITY import com.revolut.kompot.utils.debounceButEmitFirst import com.revolut.kompot.utils.withPrevious @@ -59,7 +61,7 @@ abstract class BaseScreen< UI_STATE : ScreenStates.UI, INPUT_DATA : IOData.Input, OUTPUT_DATA : IOData.Output - >(val inputData: INPUT_DATA) : Controller(), Screen, EventsDispatcher { + >(val inputData: INPUT_DATA) : Controller(), Screen, EventsDispatcher, SavedStateOwner { final override var onScreenResult: (data: OUTPUT_DATA) -> Unit = { } @@ -70,23 +72,21 @@ abstract class BaseScreen< ) private val transitionEndFlow = MutableSharedFlow(extraBufferCapacity = DEFAULT_EXTRA_BUFFER_CAPACITY) - private val tillDestroyBinding = CompositeBinding() - abstract val screenComponent: BaseScreenComponent - protected val flowComponent: ParentFlowComponent + protected val flowComponent: ControllerComponent get() = parentFlow.component protected abstract val screenModel: ScreenModel open val needKeyboard: Boolean = false - override val controllerDelegates by lazy { + override val controllerExtensions by lazy { screenComponent.getControllerExtensions() } override fun createView(inflater: LayoutInflater): View { - val view = patchLayoutInflaterWithTheme(inflater).inflate(layoutId, null, false) as? ControllerContainer + val view = getViewInflater(inflater).inflate(layoutId, null, false) as? ControllerContainer ?: throw IllegalStateException("Root ViewGroup should be ControllerContainer") view.applyEdgeToEdgeConfig() @@ -94,6 +94,9 @@ abstract class BaseScreen< view.tag = controllerName + hooksProvider?.getHook(LifecycleViewTagHook.Key)?.tagId?.let { lifecycleTag -> + view.setTag(lifecycleTag, lifecycle) + } return view } @@ -107,7 +110,8 @@ abstract class BaseScreen< dialogDisplayer = findRootFlow().rootDialogDisplayer, eventsDispatcher = this@BaseScreen, controllersCache = controllersCache, - mainDispatcher = Dispatchers.Main.immediate + mainDispatcher = Dispatchers.Main.immediate, + controllerModelExtensions = screenComponent.getControllerModelExtensions(), ) startCollectingUIState() @@ -164,6 +168,7 @@ abstract class BaseScreen< activity.currentFocus?.clearFocus() view.showKeyboard(400) } + KompotPlugin.controllerLifecycleCallbacks.forEach { callback -> callback.onControllerAttached(this) } } @OptIn(ExperimentalCoroutinesApi::class) @@ -252,9 +257,13 @@ abstract class BaseScreen< onDetach() } - fun saveState() = screenModel.saveState() + override fun saveState(outState: Bundle) { + screenModel.saveState(outState) + } - fun restoreState(state: Bundle) = screenModel.restoreState(state) + override fun restoreState(state: Bundle) { + screenModel.restoreState(state) + } protected open fun onScreenViewAttached(view: View) = Unit @@ -265,16 +274,4 @@ abstract class BaseScreen< protected open fun onScreenViewDestroyed() = Unit protected abstract fun bindScreen(uiState: UI_STATE, payload: ScreenStates.UIPayload?) -} - -private fun View.showKeyboard(delayMs: Long = 0) { - postDelayed({ - val inputMethodManager = context.getSystemService(Service.INPUT_METHOD_SERVICE) as InputMethodManager - inputMethodManager.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT) - }, delayMs) -} - -private fun View.hideKeyboard() { - val inputMethodManager = context.getSystemService(Service.INPUT_METHOD_SERVICE) as InputMethodManager - inputMethodManager.hideSoftInputFromWindow(windowToken, 0) } \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/screen/BaseScreenModel.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/screen/BaseScreenModel.kt index bb2db01..dfb972e 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/navigable/screen/BaseScreenModel.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/screen/BaseScreenModel.kt @@ -22,6 +22,7 @@ import androidx.annotation.VisibleForTesting import com.revolut.kompot.common.IOData import com.revolut.kompot.common.LifecycleEvent import com.revolut.kompot.navigable.ControllerModel +import com.revolut.kompot.navigable.SavedStateOwner import com.revolut.kompot.navigable.screen.state.SaveStateDelegate import com.revolut.kompot.utils.MutableBehaviourFlow import com.revolut.kompot.navigable.binder.ModelBinder @@ -36,7 +37,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.withIndex abstract class BaseScreenModel(private val stateMapper: StateMapper) : ControllerModel(), - ScreenModel { + ScreenModel, SavedStateOwner { protected abstract val initialState: STATE @@ -92,13 +93,12 @@ abstract class BaseScreenModel - putParcelable(DOMAIN_STATE_KEY, retainedState) + outState.putParcelable(DOMAIN_STATE_KEY, retainedState) } } - @Suppress("UNCHECKED_CAST") override fun restoreState(state: Bundle) { state.classLoader = javaClass.classLoader state.getParcelable(DOMAIN_STATE_KEY)?.let { retainedState -> diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/screen/ScreenModel.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/screen/ScreenModel.kt index 5848c09..49c3473 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/navigable/screen/ScreenModel.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/screen/ScreenModel.kt @@ -16,20 +16,16 @@ package com.revolut.kompot.navigable.screen -import android.os.Bundle import com.revolut.kompot.common.IOData +import com.revolut.kompot.navigable.SavedStateOwner import com.revolut.kompot.navigable.binder.ModelBinder import kotlinx.coroutines.flow.Flow -interface ScreenModel { +interface ScreenModel : SavedStateOwner { fun uiStateStream(): Flow fun resultsBinder(): ModelBinder fun backPressBinder(): ModelBinder - - fun saveState(): Bundle - - fun restoreState(state: Bundle) } \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/screen/ScreenModelExtensionsInjector.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/screen/ScreenModelExtensionsInjector.kt new file mode 100644 index 0000000..74ba384 --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/screen/ScreenModelExtensionsInjector.kt @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.screen + +import com.revolut.kompot.navigable.ControllerModelExtension + +interface ScreenModelExtensionsInjector { + + fun getControllerModelExtensions(): Set + +} \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/transition/AnimatorTransition.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/transition/AnimatorTransition.kt index 2b7ed05..f00a023 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/navigable/transition/AnimatorTransition.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/transition/AnimatorTransition.kt @@ -21,6 +21,7 @@ import android.animation.AnimatorListenerAdapter import android.view.View import android.view.animation.AccelerateDecelerateInterpolator import android.view.animation.Interpolator +import com.revolut.kompot.navigable.transition.Transition.Companion.DURATION_DEFAULT internal abstract class AnimatorTransition( val duration: Long = DURATION_DEFAULT, @@ -66,7 +67,7 @@ internal abstract class AnimatorTransition( animator.duration = duration animator.interpolator = interpolator animator.addListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator?) { + override fun onAnimationEnd(animation: Animator) { activeAnimator = null (to ?: from)!!.post { finishActiveTransition() } } @@ -86,9 +87,4 @@ internal abstract class AnimatorTransition( activeTransitionListener?.onTransitionFinished() activeTransitionListener = null } - - companion object { - const val DURATION_DEFAULT = 300L - } - } \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/transition/BackwardTransitionOwner.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/transition/BackwardTransitionOwner.kt new file mode 100644 index 0000000..680d7da --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/transition/BackwardTransitionOwner.kt @@ -0,0 +1,21 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.transition + +interface BackwardTransitionOwner { + fun doOnBackwardEvent(block: () -> Unit) +} \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/transition/CustomTransition.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/transition/CustomTransition.kt new file mode 100644 index 0000000..fed608a --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/transition/CustomTransition.kt @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.transition + +import android.view.View +import com.revolut.kompot.navigable.TransitionAnimation + +internal class CustomTransition( + private val animation: TransitionAnimation.Custom +) : Transition { + + private var transitionOwner: CustomTransitionOwner? = null + + override fun start( + from: View?, + to: View?, + backward: Boolean, + transitionListener: TransitionListener + ) { + transitionOwner = (to ?: from)?.let(::getTransitionOwner) + transitionOwner?.setTransitionListener(transitionListener) + transitionOwner?.startTransition(backward, animation) + } + + override fun endImmediately() { + transitionOwner?.setTransitionListener(null) + transitionOwner = null + } + + private fun getTransitionOwner(view: View): CustomTransitionOwner { + view.rootView.findViewById(animation.providerId)?.let { provider -> + return provider as? CustomTransitionOwner + ?: error( + "CustomTransitionOwner with id ${animation.providerId} must implement CustomTransitionOwner interface" + ) + } ?: error( + "CustomTransitionOwner with id ${animation.providerId} not found in view hierarchy" + ) + } +} + +interface CustomTransitionOwner { + fun startTransition(backward: Boolean, animation: TransitionAnimation.Custom) + fun setTransitionListener(transitionListener: TransitionListener?) +} \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/transition/ModalAnimatable.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/transition/ModalAnimatable.kt index 850a4e3..fb2dffb 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/navigable/transition/ModalAnimatable.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/transition/ModalAnimatable.kt @@ -26,6 +26,8 @@ interface ModalAnimatable { fun show(onTransitionEnd: () -> Unit) + fun showImmediately() + fun hide(onTransitionEnd: () -> Unit) fun addContent(view: View) @@ -35,6 +37,9 @@ interface ModalAnimatable { fun setOnDismissListener(onDismiss: (() -> Unit)?) enum class Style { - FADE, SLIDE, BOTTOM_DIALOG_SHEET, + FULLSCREEN_FADE, + FULLSCREEN_SLIDE_FROM_BOTTOM, + POPUP, + BOTTOM_DIALOG_SHEET } } \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/transition/ModalShiftTransition.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/transition/ModalShiftTransition.kt index 7e1de84..31aeb7a 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/navigable/transition/ModalShiftTransition.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/transition/ModalShiftTransition.kt @@ -22,64 +22,93 @@ import android.animation.ObjectAnimator import android.view.View import android.view.animation.AccelerateDecelerateInterpolator import android.view.animation.Interpolator +import com.revolut.kompot.navigable.transition.Transition.Companion.DURATION_DEFAULT internal class ModalShiftTransition( - private val style: ModalAnimatable.Style + private val style: ModalAnimatable.Style, + private val showImmediately: Boolean = false, ) : Transition { private val interpolator: Interpolator = AccelerateDecelerateInterpolator() - private fun getAnimatable(view: View?): ModalAnimatable? { - var currentView = view - while (currentView != null) { - val animatable = currentView.tag as? ModalAnimatable - if (animatable != null) return animatable - currentView = currentView.parent as? View? + override fun start(from: View?, to: View?, backward: Boolean, transitionListener: TransitionListener) { + if (!backward) { + if (showImmediately) { + startImmediateForwardTransition(to, transitionListener) + } else { + startForwardTransition(to, transitionListener) + } + } else { + val animatable = getAnimatable(from) + if (animatable != null) { + startBackwardTransition(animatable, transitionListener) + } else { + startFallbackBackwardTransition(from, to, transitionListener) + } } - return null } - override fun start(from: View?, to: View?, backward: Boolean, transitionListener: TransitionListener) { + private fun startForwardTransition(to: View?, transitionListener: TransitionListener) { + val animatable = requireAnimatable(to) transitionListener.onTransitionCreated() transitionListener.onTransitionStart() - if (!backward) { - getAnimatable(to)?.let { animatable -> - animatable.style = style - animatable.show { + animatable.style = style + animatable.show { + transitionListener.onTransitionEnd() + transitionListener.onTransitionFinished() + } + } + + + private fun startImmediateForwardTransition(to: View?, transitionListener: TransitionListener) { + val animatable = requireAnimatable(to) + transitionListener.onTransitionCreated() + transitionListener.onTransitionStart() + animatable.style = style + animatable.showImmediately() + transitionListener.onTransitionEnd() + transitionListener.onTransitionFinished() + } + + private fun startBackwardTransition(animatable: ModalAnimatable, transitionListener: TransitionListener) { + transitionListener.onTransitionCreated() + transitionListener.onTransitionStart() + animatable.hide { + transitionListener.onTransitionEnd() + transitionListener.onTransitionFinished() + } + } + + private fun startFallbackBackwardTransition(from: View?, to: View?, transitionListener: TransitionListener) { + //For the cases when animatable class is not used we still should animate back from any view safely + transitionListener.onTransitionCreated() + transitionListener.onTransitionStart() + val animator = ObjectAnimator.ofFloat(from, View.ALPHA, 0f) + animator.interpolator = interpolator + animator.duration = DURATION_DEFAULT + animator.addListener(object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + (to ?: from)!!.post { transitionListener.onTransitionEnd() transitionListener.onTransitionFinished() } } - } else { - val modalAnimatable = getAnimatable(from) - if (modalAnimatable != null) { - modalAnimatable.let { animatable -> - animatable.hide { - transitionListener.onTransitionEnd() - transitionListener.onTransitionFinished() - } - } - } else { - //For the cases when animatable class is not used we still should animate back from any view safely - transitionListener.onTransitionCreated() - transitionListener.onTransitionStart() - val animator = ObjectAnimator.ofFloat(from, View.ALPHA, 0f) - animator.interpolator = interpolator - animator.duration = AnimatorTransition.DURATION_DEFAULT - animator.addListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator?) { - (to ?: from)!!.post { - transitionListener.onTransitionEnd() - transitionListener.onTransitionFinished() - } - } - }) - animator.start() - } + }) + animator.start() + } + private fun getAnimatable(view: View?): ModalAnimatable? { + var currentView = view + while (currentView != null) { + val animatable = currentView.tag as? ModalAnimatable + if (animatable != null) return animatable + currentView = currentView.parent as? View? } + return null } + private fun requireAnimatable(view: View?): ModalAnimatable = checkNotNull(getAnimatable(view)) + override fun endImmediately() { //Do nothing } diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/transition/Transition.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/transition/Transition.kt index 5ca6188..faae0e8 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/navigable/transition/Transition.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/transition/Transition.kt @@ -18,7 +18,7 @@ package com.revolut.kompot.navigable.transition import android.view.View -internal interface Transition { +interface Transition { fun start( from: View?, @@ -28,4 +28,8 @@ internal interface Transition { ) fun endImmediately() + + companion object { + const val DURATION_DEFAULT = 300L + } } \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/transition/TransitionCallbacks.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/transition/TransitionCallbacks.kt index dba4e78..65b8878 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/navigable/transition/TransitionCallbacks.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/transition/TransitionCallbacks.kt @@ -18,8 +18,7 @@ package com.revolut.kompot.navigable.transition interface TransitionCallbacks { fun onTransitionRunUp(enter: Boolean) - fun onTransitionStart(enter: Boolean) - fun onTransitionEnd(enter: Boolean) + fun onTransitionCanceled() } \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/transition/TransitionFactory.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/transition/TransitionFactory.kt index c12e48a..5489fe6 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/navigable/transition/TransitionFactory.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/transition/TransitionFactory.kt @@ -16,18 +16,21 @@ package com.revolut.kompot.navigable.transition +import com.revolut.kompot.navigable.ModalTransitionAnimation import com.revolut.kompot.navigable.TransitionAnimation +import com.revolut.kompot.navigable.transition.Transition.Companion.DURATION_DEFAULT internal class TransitionFactory { fun createTransition(animation: TransitionAnimation): Transition = when (animation) { TransitionAnimation.NONE -> ImmediateTransition() - TransitionAnimation.SLIDE_RIGHT_TO_LEFT -> SlideTransition(AnimatorTransition.DURATION_DEFAULT, SlideTransition.Direction.RIGHT_TO_LEFT) - TransitionAnimation.SLIDE_LEFT_TO_RIGHT -> SlideTransition(AnimatorTransition.DURATION_DEFAULT, SlideTransition.Direction.LEFT_TO_RIGHT) - TransitionAnimation.FADE -> FadeTransition(AnimatorTransition.DURATION_DEFAULT) - TransitionAnimation.MODAL_SLIDE -> ModalShiftTransition(ModalAnimatable.Style.SLIDE) - TransitionAnimation.MODAL_FADE -> ModalShiftTransition(ModalAnimatable.Style.FADE) - TransitionAnimation.BOTTOM_DIALOG_SLIDE -> ModalShiftTransition(ModalAnimatable.Style.BOTTOM_DIALOG_SHEET) + TransitionAnimation.SLIDE_RIGHT_TO_LEFT -> SlideTransition(DURATION_DEFAULT, SlideTransition.Direction.RIGHT_TO_LEFT) + TransitionAnimation.SLIDE_LEFT_TO_RIGHT -> SlideTransition(DURATION_DEFAULT, SlideTransition.Direction.LEFT_TO_RIGHT) + TransitionAnimation.FADE -> FadeTransition(DURATION_DEFAULT) + is TransitionAnimation.Custom -> CustomTransition(animation) + is ModalTransitionAnimation.ModalPopup -> ModalShiftTransition(ModalAnimatable.Style.POPUP, animation.showImmediately) + is ModalTransitionAnimation.ModalFullscreenFade -> ModalShiftTransition(ModalAnimatable.Style.FULLSCREEN_FADE, animation.showImmediately) + is ModalTransitionAnimation.ModalFullscreenSlideFromBottom -> ModalShiftTransition(ModalAnimatable.Style.FULLSCREEN_SLIDE_FROM_BOTTOM, animation.showImmediately) + is ModalTransitionAnimation.BottomDialog -> ModalShiftTransition(ModalAnimatable.Style.BOTTOM_DIALOG_SHEET, animation.showImmediately) } - } \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/transition/TransitionListener.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/transition/TransitionListener.kt index 2d794b7..07e609e 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/navigable/transition/TransitionListener.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/transition/TransitionListener.kt @@ -16,7 +16,7 @@ package com.revolut.kompot.navigable.transition -internal interface TransitionListener { +interface TransitionListener { fun onTransitionCreated() @@ -26,4 +26,5 @@ internal interface TransitionListener { fun onTransitionFinished() + fun onTransitionCanceled() } \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/utils/ControllerEnvironment.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/utils/ControllerEnvironment.kt new file mode 100644 index 0000000..507476c --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/utils/ControllerEnvironment.kt @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.utils + +import com.revolut.kompot.R +import com.revolut.kompot.common.ModalDestination +import com.revolut.kompot.di.flow.ParentFlow +import com.revolut.kompot.navigable.Controller +import com.revolut.kompot.navigable.TransitionAnimation +import com.revolut.kompot.navigable.extractModalStyle + +class ControllerEnvironment(private val controller: Controller) { + + internal var enterTransition: TransitionAnimation? = null + /** + * @return modal style if controller was started as modal. Null otherwise + */ + val modalStyle: ModalDestination.Style? get() = enterTransition?.extractModalStyle() + + val defaultControllerContainer: Int + get() = + controller.parentControllerManager.defaultControllerContainer ?: R.layout.base_flow_container + + fun isModalRoot(): Boolean { + var result = false + + var currentController = controller + var parent = currentController.parentController + + while (parent != null) { + if (currentController.parentControllerManager.modal) { + result = true + break + } + val parentFlow = parent as? ParentFlow + if (parentFlow == null || parentFlow.hasBackStack) { + break + } + currentController = parent + parent = parent.parentController + } + + return result + } +} \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/utils/DialogsExt.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/utils/DialogsExt.kt new file mode 100644 index 0000000..2d09814 --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/utils/DialogsExt.kt @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.utils + +import com.revolut.kompot.dialog.DialogDisplayer +import com.revolut.kompot.dialog.DialogModel +import com.revolut.kompot.dialog.DialogModelResult +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext + +fun setBlockingLoadingVisibility(dialogDisplayer: DialogDisplayer, visible: Boolean, immediate: Boolean = false) { + if (visible) { + dialogDisplayer.showLoadingDialog(if (immediate) 0 else 1000) + } else { + dialogDisplayer.hideLoadingDialog() + } +} + +internal suspend fun withLoading( + dialogDisplayer: DialogDisplayer, + mainDispatcher: CoroutineDispatcher, + block: suspend () -> T, +): T = withContext(mainDispatcher) { + try { + setBlockingLoadingVisibility(dialogDisplayer, true) + block() + } finally { + setBlockingLoadingVisibility(dialogDisplayer, false) + } +} + +internal fun showDialog(dialogDisplayer: DialogDisplayer, dialogModel: DialogModel): kotlinx.coroutines.flow.Flow = + dialogDisplayer.showDialog(dialogModel) + +internal fun hideDialog(dialogDisplayer: DialogDisplayer, dialogModel: DialogModel<*>) { + dialogDisplayer.hideDialog(dialogModel) +} + +internal fun hideAllDialogs(dialogDisplayer: DialogDisplayer) = dialogDisplayer.hideAllDialogs() \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/utils/LaunchInScopeExt.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/utils/LaunchInScopeExt.kt new file mode 100644 index 0000000..753a7f4 --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/utils/LaunchInScopeExt.kt @@ -0,0 +1,189 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.utils + +import com.revolut.kompot.BuildConfig +import com.revolut.kompot.common.ErrorEvent +import com.revolut.kompot.common.ErrorInterceptedEventResult +import com.revolut.kompot.common.ErrorInterceptionEvent +import com.revolut.kompot.common.EventsDispatcher +import com.revolut.kompot.common.LifecycleEvent +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import timber.log.Timber + +internal fun Flow.collectTillDetachView( + attached: Boolean, + detached: Boolean, + attachedScope: CoroutineScope, + onError: suspend (Throwable) -> Unit = { Timber.e(it) }, + onSuccessCompletion: suspend () -> Unit = {}, + onEach: suspend (T) -> Unit = {} +): Job { + if (BuildConfig.DEBUG) { + when { + detached -> error("collectTillDetachView is called after onDetach [$this]") + !attached -> error("collectTillDetachView is called before onAttach [$this]") + } + } + return launchInScope( + scope = attachedScope, + onError = onError, + onSuccessCompletion = onSuccessCompletion, + onEach = onEach + ) +} + +internal fun Flow.collectTillHide( + eventsDispatcher: EventsDispatcher, + lastLifecycleEvent: LifecycleEvent?, + shownScope: CoroutineScope, + handleError: suspend (Throwable) -> Boolean, + onSuccessCompletion: suspend () -> Unit, + onEach: suspend (T) -> Unit, +): Job { + Preconditions.assertWrongLaunchTillHide( + lastLifecycleEvent = lastLifecycleEvent, + methodName = "collectTillHide", + createdScopeAlternative = "collectTillFinish" + ) + return launchInScope(eventsDispatcher, shownScope, handleError, onSuccessCompletion, onEach) +} + +internal fun Flow.collectTillFinish( + eventsDispatcher: EventsDispatcher, + lastLifecycleEvent: LifecycleEvent?, + createdScope: CoroutineScope, + handleError: suspend (Throwable) -> Boolean, + onSuccessCompletion: suspend () -> Unit, + onEach: suspend (T) -> Unit, +): Job { + Preconditions.assertWrongLaunchTillFinish( + lastLifecycleEvent = lastLifecycleEvent, + methodName = "collectTillFinish", + shownScopeAlternative = "collectTillHide" + ) + return launchInScope(eventsDispatcher, createdScope, handleError, onSuccessCompletion, onEach) +} + +internal fun tillFinish( + eventsDispatcher: EventsDispatcher, + createdScope: CoroutineScope, + lastLifecycleEvent: LifecycleEvent?, + handleError: (Throwable) -> Boolean = { false }, + block: suspend CoroutineScope.() -> T +): Job { + Preconditions.assertWrongLaunchTillFinish( + lastLifecycleEvent = lastLifecycleEvent, + methodName = "tillFinish", + shownScopeAlternative = "tillHide" + ) + + val exceptionHandler = CoroutineExceptionHandler { _, exception -> + if (dispatchErrorInterception(eventsDispatcher, exception)){ + return@CoroutineExceptionHandler + } + + if (!handleError(exception)) { + sendErrorEvent(eventsDispatcher, exception) + } + } + + return createdScope.launch(exceptionHandler) { + block() + } +} + +internal fun tillHide( + eventsDispatcher: EventsDispatcher, + shownScope: CoroutineScope, + lastLifecycleEvent: LifecycleEvent?, + handleError: (Throwable) -> Boolean = { false }, + block: suspend CoroutineScope.() -> T +): Job { + Preconditions.assertWrongLaunchTillHide( + lastLifecycleEvent = lastLifecycleEvent, + methodName = "tillHide", + createdScopeAlternative = "tillFinish" + ) + + val exceptionHandler = CoroutineExceptionHandler { _, exception -> + if (dispatchErrorInterception(eventsDispatcher, exception)){ + return@CoroutineExceptionHandler + } + + if (!handleError(exception)) { + sendErrorEvent(eventsDispatcher, exception) + } + } + + return shownScope.launch(exceptionHandler) { + block() + } +} + +private fun Flow.launchInScope( + scope: CoroutineScope, + onError: suspend (Throwable) -> Unit = { Timber.e(it) }, + onSuccessCompletion: suspend () -> Unit = {}, + onEach: suspend (T) -> Unit = {} +): Job = + onEach(onEach) + .onCompletion { cause -> + if (cause == null) onSuccessCompletion() + } + .catch { cause -> + onError(cause) + } + .launchIn(scope) + +private fun Flow.launchInScope( + eventsDispatcher: EventsDispatcher, + scope: CoroutineScope, + handleError: suspend (Throwable) -> Boolean = { false }, + onSuccessCompletion: suspend () -> Unit = {}, + onEach: suspend (T) -> Unit = {} +): Job = + onEach(onEach) + .onCompletion { cause -> + if (cause == null) onSuccessCompletion() + } + .catch { cause -> + if (dispatchErrorInterception(eventsDispatcher, cause)){ + return@catch + } + if (!handleError(cause)) { + sendErrorEvent(eventsDispatcher, cause) + } + } + .launchIn(scope) + +private fun sendErrorEvent(eventsDispatcher: EventsDispatcher, throwable: Throwable) { + eventsDispatcher.handleEvent(ErrorEvent(throwable)) +} + +private fun dispatchErrorInterception(eventsDispatcher: EventsDispatcher, throwable: Throwable): Boolean { + val result = eventsDispatcher.handleEvent(ErrorInterceptionEvent(throwable)) + return result == ErrorInterceptedEventResult +} \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/utils/NavigationExt.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/utils/NavigationExt.kt new file mode 100644 index 0000000..bfce404 --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/utils/NavigationExt.kt @@ -0,0 +1,110 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalKompotApi::class) + +package com.revolut.kompot.navigable.utils + +import com.revolut.kompot.ExperimentalKompotApi +import com.revolut.kompot.common.ControllerDescriptor +import com.revolut.kompot.common.ControllerHolder +import com.revolut.kompot.common.ControllerRequest +import com.revolut.kompot.common.EventsDispatcher +import com.revolut.kompot.common.IOData +import com.revolut.kompot.common.InternalDestination +import com.revolut.kompot.common.ModalDestination +import com.revolut.kompot.common.NavigationDestination +import com.revolut.kompot.common.NavigationRequest +import com.revolut.kompot.common.NavigationRequestEvent +import com.revolut.kompot.common.NavigationRequestResult +import com.revolut.kompot.common.handleNavigationEvent +import com.revolut.kompot.navigable.flow.Flow +import com.revolut.kompot.navigable.flow.scroller.ScrollerFlow +import com.revolut.kompot.navigable.screen.Screen +import com.revolut.kompot.navigable.vc.ViewController + +internal fun navigate(eventsDispatcher: EventsDispatcher, internalDestination: InternalDestination<*>) = internalDestination.navigate(eventsDispatcher) + +internal fun NavigationDestination.navigate(eventsDispatcher: EventsDispatcher) = eventsDispatcher.handleNavigationEvent(this) + +internal fun Screen.showModal( + eventsDispatcher: EventsDispatcher, + style: ModalDestination.Style = ModalDestination.Style.FULLSCREEN_FADE, + onResult: ((T) -> Unit)? = null +) = + eventsDispatcher.handleNavigationEvent( + ModalDestination.ExplicitScreen( + screen = this, + onResult = onResult, + style = style + ) + ) + +internal fun Flow.showModal( + eventsDispatcher: EventsDispatcher, + style: ModalDestination.Style = ModalDestination.Style.FULLSCREEN_FADE, + onResult: ((T) -> Unit)? = null +) = + eventsDispatcher.handleNavigationEvent( + ModalDestination.ExplicitFlow( + flow = this, + onResult = onResult, + style = style + ) + ) + +@OptIn(ExperimentalKompotApi::class) +internal fun ScrollerFlow.showModal( + eventsDispatcher: EventsDispatcher, + style: ModalDestination.Style = ModalDestination.Style.FULLSCREEN_FADE, + onResult: ((T) -> Unit)? = null +) = + eventsDispatcher.handleNavigationEvent( + ModalDestination.ExplicitScrollerFlow( + flow = this, + onResult = onResult, + style = style + ) + ) + +internal fun ViewController.showModal( + eventsDispatcher: EventsDispatcher, + style: ModalDestination.Style = ModalDestination.Style.FULLSCREEN_FADE, + onResult: ((T) -> Unit)? = null +) { + onResult?.let(this::withResult) + eventsDispatcher.handleNavigationEvent( + ModalDestination.CallbackController( + controller = this, + style = style + ) + ) +} + +internal fun ControllerDescriptor.getController( + eventsDispatcher: EventsDispatcher +): ViewController { + val result = eventsDispatcher.handleEvent(ControllerRequest(this)) + check(result is ControllerHolder) { "Can't resolve controller for $this" } + @Suppress("UNCHECKED_CAST") //Type safety is controlled by FeatureGateway + return result.controller as ViewController +} + +internal suspend fun NavigationRequest.navigate(eventsDispatcher: EventsDispatcher) { + val navigationRequestResult = eventsDispatcher.handleEvent(NavigationRequestEvent(this)) + val navigationRequestResolver = (navigationRequestResult as? NavigationRequestResult)?.requestResolver ?: error("Couldn't resolve request $this") + navigationRequestResolver.invoke().navigate(eventsDispatcher = eventsDispatcher) +} \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/utils/Preconditions.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/utils/Preconditions.kt index 117bb05..46e9e5f 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/navigable/utils/Preconditions.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/utils/Preconditions.kt @@ -19,6 +19,8 @@ package com.revolut.kompot.navigable.utils import android.os.Looper import androidx.annotation.VisibleForTesting import com.revolut.kompot.BuildConfig +import com.revolut.kompot.common.LifecycleEvent +import timber.log.Timber internal object Preconditions { @@ -30,4 +32,36 @@ internal object Preconditions { throw IllegalStateException("$context is only allowed on the main thread!") } } + + fun assertWrongLaunchTillHide( + lastLifecycleEvent: LifecycleEvent?, + methodName: String, + createdScopeAlternative: String, + ) { + if (BuildConfig.DEBUG) { + when (lastLifecycleEvent) { + LifecycleEvent.CREATED -> Timber.e("$methodName is called before onShown, consider to use $createdScopeAlternative [$this]") + LifecycleEvent.SHOWN -> Unit + LifecycleEvent.HIDDEN -> error("$methodName is called after onHidden [$this]") + LifecycleEvent.FINISHED -> error("$methodName is called after onFinished [$this]") + null -> Timber.e("$methodName is called before onCreate [$this]") + } + } + } + + fun assertWrongLaunchTillFinish( + lastLifecycleEvent: LifecycleEvent?, + methodName: String, + shownScopeAlternative: String + ) { + if (BuildConfig.DEBUG) { + when (lastLifecycleEvent) { + LifecycleEvent.CREATED -> Unit + LifecycleEvent.SHOWN -> Timber.e("$methodName is called after onShown, consider to use $shownScopeAlternative [$this]") + LifecycleEvent.HIDDEN -> Timber.e("$methodName is called after onHidden $this]") + LifecycleEvent.FINISHED -> error("$methodName is called after onFinished [$this]") + null -> Timber.e("$methodName is called before onCreate [$this]") + } + } + } } \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/utils/SingleTasksExt.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/utils/SingleTasksExt.kt new file mode 100644 index 0000000..7839e58 --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/utils/SingleTasksExt.kt @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.utils + +import com.revolut.kompot.navigable.utils.single_task.IllegalConcurrentAccessException +import com.revolut.kompot.navigable.utils.single_task.SingleTasksRegistry +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flatMapConcat +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.onCompletion + +@OptIn(FlowPreview::class) +internal fun kotlinx.coroutines.flow.Flow.singleTask(singleTasksRegistry: SingleTasksRegistry, taskId: String): kotlinx.coroutines.flow.Flow = + flow { + if (singleTasksRegistry.acquire(taskId)) { + emit(Unit) + } else { + throw IllegalConcurrentAccessException() + } + }.flatMapConcat { + this + }.onCompletion { cause -> + if (cause !is IllegalConcurrentAccessException) { + singleTasksRegistry.release(taskId) + } + }.catch { e -> + if (e !is IllegalConcurrentAccessException) { + throw e + } + } + +internal suspend fun singleTask(singleTasksRegistry: SingleTasksRegistry, taskId: String, action: suspend () -> T): T? { + if (!singleTasksRegistry.acquire(taskId)) { + return null + } + + return try { + action.invoke() + } finally { + singleTasksRegistry.release(taskId) + } +} \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/utils/ControllerViewBindingDelegate.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/utils/ViewBindingDelegate.kt similarity index 100% rename from kompot/src/main/kotlin/com/revolut/kompot/navigable/utils/ControllerViewBindingDelegate.kt rename to kompot/src/main/kotlin/com/revolut/kompot/navigable/utils/ViewBindingDelegate.kt diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/utils/ViewExt.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/utils/ViewExt.kt new file mode 100644 index 0000000..4bb7144 --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/utils/ViewExt.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.utils + +import android.app.Service +import android.view.View +import android.view.inputmethod.InputMethodManager + +internal fun View.showKeyboard(delayMs: Long = 0) { + postDelayed({ + val inputMethodManager = context.getSystemService(Service.INPUT_METHOD_SERVICE) as InputMethodManager + inputMethodManager.showSoftInput(this, InputMethodManager.SHOW_IMPLICIT) + }, delayMs) +} + +internal fun View.hideKeyboard() { + val inputMethodManager = context.getSystemService(Service.INPUT_METHOD_SERVICE) as InputMethodManager + inputMethodManager.hideSoftInputFromWindow(windowToken, 0) +} \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/SimpleViewController.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/SimpleViewController.kt new file mode 100644 index 0000000..2b41cb0 --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/SimpleViewController.kt @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.vc + +import android.os.Bundle +import com.revolut.kompot.common.IOData +import com.revolut.kompot.navigable.vc.binding.ModelBinding +import com.revolut.kompot.navigable.vc.binding.ViewControllerModelApi +import com.revolut.kompot.navigable.vc.di.EmptyViewControllerComponent +import com.revolut.kompot.navigable.vc.di.ViewControllerComponent + +abstract class SimpleViewController : ViewController() { + override val controllerModel: ViewControllerModelApi = SimpleViewControllerModel() + override val modelBinding: ModelBinding = SimpleModelBinding() + override val component: ViewControllerComponent = EmptyViewControllerComponent +} + +internal class SimpleViewControllerModel : ViewControllerModel() + +internal class SimpleModelBinding : ModelBinding { + override fun saveState(outState: Bundle) = Unit + override fun restoreState(state: Bundle) = Unit +} \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/ViewController.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/ViewController.kt new file mode 100644 index 0000000..8d1c544 --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/ViewController.kt @@ -0,0 +1,287 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.vc + +import android.content.Intent +import android.os.Bundle +import android.os.Parcelable +import android.util.SparseArray +import android.view.LayoutInflater +import android.view.View +import androidx.core.view.postDelayed +import com.revolut.kompot.KompotPlugin +import com.revolut.kompot.common.Event +import com.revolut.kompot.common.EventResult +import com.revolut.kompot.common.EventsDispatcher +import com.revolut.kompot.common.IOData +import com.revolut.kompot.common.LifecycleEvent +import com.revolut.kompot.di.flow.ControllerComponent +import com.revolut.kompot.navigable.Controller +import com.revolut.kompot.navigable.ControllerModel +import com.revolut.kompot.navigable.findRootFlow +import com.revolut.kompot.navigable.hooks.LifecycleViewTagHook +import com.revolut.kompot.navigable.hooks.PersistentModelStateStorageHook +import com.revolut.kompot.navigable.utils.Preconditions +import com.revolut.kompot.navigable.utils.hideKeyboard +import com.revolut.kompot.navigable.utils.showKeyboard +import com.revolut.kompot.navigable.vc.binding.ViewControllerModelApi +import com.revolut.kompot.navigable.vc.di.ViewControllerComponent +import com.revolut.kompot.view.ControllerContainer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import timber.log.Timber + +abstract class ViewController : Controller(), ViewControllerApi { + + protected open val viewSavedStateEnabled = false + protected open val needKeyboard: Boolean = false + + protected abstract val controllerModel: ViewControllerModelApi + private val internalControllerModel get() = controllerModel as ControllerModel + + protected val parentComponent: ControllerComponent + get() = parentFlow.component + + internal var onResult: (data: OUTPUT) -> Unit = { } + + abstract override val component: ViewControllerComponent + override val controllerExtensions by lazy(LazyThreadSafetyMode.NONE) { + component.getControllerExtensions() + } + + override fun createView(inflater: LayoutInflater): View { + val inflatedLayout = getViewInflater(inflater).inflate(layoutId, null, false) + require(inflatedLayout is ControllerContainer) { "$controllerName: root ViewGroup should be ControllerContainer" } + inflatedLayout.applyEdgeToEdgeConfig() + inflatedLayout.tag = this.controllerName + hooksProvider?.getHook(LifecycleViewTagHook.Key)?.tagId?.let { lifecycleTag -> + inflatedLayout.setTag(lifecycleTag, lifecycle) + } + return inflatedLayout.also { this.view = it } + } + + final override fun onCreate() { + super.onCreate() + restorePersistentState() + internalControllerModel.injectDependencies( + dialogDisplayer = findRootFlow().rootDialogDisplayer, + eventsDispatcher = this, + controllersCache = controllersCache, + mainDispatcher = Dispatchers.Main.immediate, + controllerModelExtensions = component.getControllerModelExtensions(), + ) + + tillDestroyBinding += controllerModel.resultsBinder() + .bind(::onPostScreenResult) + tillDestroyBinding += controllerModel.backPressBinder() + .bind { onPostBack() } + modelBinding.onCreate() + + onCreated(view) + internalControllerModel.onLifecycleEvent(LifecycleEvent.CREATED) + } + + final override fun onDestroy() { + super.onDestroy() + onDestroyed() + tillDestroyBinding.clear() + internalControllerModel.onLifecycleEvent(LifecycleEvent.FINISHED) + modelBinding.onDestroy() + } + + final override fun onAttach() { + super.onAttach() + onShown(view) + internalControllerModel.onLifecycleEvent(LifecycleEvent.SHOWN) + modelBinding.onShow() + if (needKeyboard) { + view.postDelayed(400L) { + if (attached) { + activity.currentFocus?.showKeyboard() + } else { + activity.currentFocus?.clearFocus() + view.showKeyboard(400) + } + } + } else { + activity.currentFocus?.clearFocus() + view.showKeyboard(400) + } + KompotPlugin.controllerLifecycleCallbacks.forEach { callback -> callback.onControllerAttached(this) } + } + + final override fun onDetach() { + super.onDetach() + onHidden() + internalControllerModel.onLifecycleEvent(LifecycleEvent.HIDDEN) + modelBinding.onHide() + savePersistentState() + } + + override fun onTransitionStart(enter: Boolean) { + super.onTransitionStart(enter) + modelBinding.onTransitionStart(enter) + } + + override fun onTransitionEnd(enter: Boolean) { + super.onTransitionEnd(enter) + modelBinding.onTransitionEnd(enter) + } + + override fun onTransitionCanceled() { + super.onTransitionCanceled() + modelBinding.onTransitionCanceled() + } + + override fun onTransitionRunUp(enter: Boolean) { + super.onTransitionRunUp(enter) + + if (enter && !needKeyboard) { + activity.currentFocus?.hideKeyboard() + } + } + + override fun onHostPaused() { + super.onHostPaused() + modelBinding.onHostPaused() + } + + override fun onHostResumed() { + super.onHostResumed() + modelBinding.onHostResumed() + } + + override fun onHostStarted() { + super.onHostStarted() + onAttach() + modelBinding.onHostStarted() + } + + override fun onHostStopped() { + super.onHostStopped() + onDetach() + modelBinding.onHostStopped() + } + + override fun onParentManagerCleared() { + modelBinding.onParentManagerCleared() + super.onParentManagerCleared() + } + + final override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + handleActivityResult(requestCode, resultCode, data) + modelBinding.onActivityResult(requestCode, resultCode, data) + } + + final override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + handleRequestPermissionsResult(requestCode, permissions, grantResults) + modelBinding.onRequestPermissionsResult(requestCode, permissions, grantResults) + } + + open fun handleActivityResult(requestCode: Int, resultCode: Int, data: Intent?) = Unit + open fun handleRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) = Unit + + override fun handleEvent(event: Event): EventResult? { + if (event._controller == null) { + event._controller = this + } + return internalControllerModel.tryHandleEvent(event) ?: (parentController as? EventsDispatcher)?.handleEvent(event) + } + + private fun onPostScreenResult(result: OUTPUT) { + Preconditions.requireMainThread("ViewController#postScreenResult") + onResult.invoke(result) + } + + private fun onPostBack() { + Preconditions.requireMainThread("ViewController#postBack") + getTopFlow().handleBack() + } + + override fun handleBack(): Boolean = modelBinding.handleBack( + defaultHandler = { super.handleBack() } + ) + + override fun handleQuit() { + if (!modelBinding.handleQuit()) { + super.handleQuit() + } + } + + final override fun saveState(outState: Bundle) { + modelBinding.saveState(outState) + if (viewSavedStateEnabled) { + val containerState = SparseArray() + (view as ControllerContainer).saveState(containerState) + outState.putSparseParcelableArray(CONTAINER_VIEW_STATE_KEY, containerState) + } + } + + final override fun restoreState(state: Bundle) { + modelBinding.restoreState(state) + val containerState = state.getSparseParcelableArray(CONTAINER_VIEW_STATE_KEY) + if (containerState != null) { + (view as ControllerContainer).restoreState(containerState) + } + (internalControllerModel as? ViewControllerModel<*>)?._restored = true + } + + private fun restorePersistentState() { + hooksProvider + ?.getHook(PersistentModelStateStorageHook.Key) + ?.storage + ?.let(modelBinding::restoreStateFromStorage) + } + + private fun savePersistentState() { + hooksProvider + ?.getHook(PersistentModelStateStorageHook.Key) + ?.storage + ?.let(modelBinding::saveStateToStorage) + } + + fun withResult(block: (OUTPUT) -> Unit): ViewController { + this.onResult = block + return this + } + + fun postResult(result: OUTPUT) { + this.onResult.invoke(result) + } + + protected open fun onCreated(view: View) = Unit + protected open fun onShown(view: View) = Unit + protected open fun onHidden() = Unit + protected open fun onDestroyed() = Unit + + protected fun Flow.collectTillHide( + onError: suspend (Throwable) -> Unit = { Timber.e(it) }, + onSuccessCompletion: suspend () -> Unit = {}, + onEach: suspend (T) -> Unit = {} + ): Job = collectTillDetachView( + onError = onError, + onSuccessCompletion = onSuccessCompletion, + onEach = onEach, + ) + + companion object { + internal const val CONTAINER_VIEW_STATE_KEY = "CONTAINER_VIEW_STATE_KEY" + } +} \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/ViewControllerApi.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/ViewControllerApi.kt new file mode 100644 index 0000000..52d9eff --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/ViewControllerApi.kt @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.vc + +import com.revolut.kompot.common.EventsDispatcher +import com.revolut.kompot.di.flow.ControllerComponent +import com.revolut.kompot.navigable.LayoutOwner +import com.revolut.kompot.navigable.SavedStateOwner +import com.revolut.kompot.navigable.utils.ControllerEnvironment +import com.revolut.kompot.navigable.vc.binding.ModelBinding + +interface ViewControllerApi : EventsDispatcher, LayoutOwner, SavedStateOwner { + val environment: ControllerEnvironment + val modelBinding: ModelBinding + val component: ControllerComponent +} \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/ViewControllerModel.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/ViewControllerModel.kt new file mode 100644 index 0000000..b7fba1c --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/ViewControllerModel.kt @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.vc + +import com.revolut.kompot.common.IOData +import com.revolut.kompot.common.ModalDestination +import com.revolut.kompot.navigable.ControllerModel +import com.revolut.kompot.navigable.TransitionAnimation +import com.revolut.kompot.navigable.binder.ModelBinder +import com.revolut.kompot.navigable.flow.FlowStep +import com.revolut.kompot.navigable.vc.binding.ViewControllerModelApi +import com.revolut.kompot.navigable.vc.common.StateHolder +import com.revolut.kompot.navigable.vc.flow.FlowCoordinator +import com.revolut.kompot.navigable.vc.modal.ModalCoordinator +import com.revolut.kompot.navigable.vc.scroller.ScrollerCoordinator +import com.revolut.kompot.navigable.vc.scroller.ScrollerItem + +abstract class ViewControllerModel : ControllerModel(), ViewControllerModelApi { + + internal var _restored = false + protected val restored: Boolean get() = _restored + + private val resultCommandsBinder = ModelBinder() + private val backCommandsBinder = ModelBinder() + + override fun resultsBinder(): ModelBinder = resultCommandsBinder + override fun backPressBinder(): ModelBinder = backCommandsBinder + + protected fun postResult(result: OUTPUT) { + resultCommandsBinder.notify(result) + } + + fun postBack() { + backCommandsBinder.notify(Unit) + } + + protected fun StateHolder.update(func: S.() -> S) = update(func) + + protected fun FlowCoordinator.next( + step: Step, + addCurrentStepToBackStack: Boolean, + animation: TransitionAnimation = TransitionAnimation.SLIDE_RIGHT_TO_LEFT, + executeImmediately: Boolean = false, + ) = next( + step = step, + addCurrentStepToBackStack = addCurrentStepToBackStack, + animation = animation, + executeImmediately = executeImmediately, + ) + + protected fun ScrollerCoordinator.updateItems( + selectedItemId: String? = null, + items: List, + smoothScroll: Boolean = true, + ) = updateItems( + selectedItemId = selectedItemId, + items = items, + smoothScroll = smoothScroll, + ) + + protected fun ScrollerCoordinator.updateItems( + selectedItemId: String? = null, + smoothScroll: Boolean = true, + ) = updateItems( + selectedItemId = selectedItemId, + smoothScroll = smoothScroll, + ) + + protected fun FlowCoordinator<*, *>.quit() = quit() + protected fun ScrollerCoordinator<*>.quit() = quit() + protected fun FlowCoordinator<*, *>.clearBackStack() = clearBackStack() + protected fun FlowCoordinator.openModal( + step: Step, + style: ModalDestination.Style = ModalDestination.Style.POPUP, + ) = openModal(step, style) + + protected fun ModalCoordinator.openModal( + step: Step, + style: ModalDestination.Style = ModalDestination.Style.POPUP, + ) = openModal(step, style) +} diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/binding/ModelBinding.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/binding/ModelBinding.kt new file mode 100644 index 0000000..68b355f --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/binding/ModelBinding.kt @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.vc.binding + +import android.content.Intent +import com.revolut.kompot.common.IOData +import com.revolut.kompot.navigable.SavedStateOwner +import com.revolut.kompot.navigable.binder.ModelBinder +import com.revolut.kompot.navigable.vc.ui.PersistentModelStateStorage + +interface ViewControllerModelApi { + fun resultsBinder(): ModelBinder + fun backPressBinder(): ModelBinder +} + +interface ModelBinding : SavedStateOwner { + fun onCreate() = Unit + fun onDestroy() = Unit + fun onShow() = Unit + fun onHide() = Unit + fun onTransitionStart(enter: Boolean) = Unit + fun onTransitionEnd(enter: Boolean) = Unit + fun onTransitionCanceled() = Unit + fun onHostPaused() = Unit + fun onHostResumed() = Unit + fun onHostStarted() = Unit + fun onHostStopped() = Unit + fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) = Unit + fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) = Unit + fun handleBack(defaultHandler: () -> Boolean) = defaultHandler() + fun handleQuit(): Boolean = false + fun onParentManagerCleared() = Unit + + fun restoreStateFromStorage(stateStorage: PersistentModelStateStorage) = Unit + fun saveStateToStorage(stateStorage: PersistentModelStateStorage) = Unit +} \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/common/PersistableModelState.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/common/PersistableModelState.kt new file mode 100644 index 0000000..147ef71 --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/common/PersistableModelState.kt @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.vc.common + +import android.os.Bundle +import android.os.Parcelable +import com.revolut.kompot.common.IOData + +internal class PersistableStateHolderImpl( + initialState: S, +) : PersistableStateHolder(initialState) { + + override fun saveState(bundle: Bundle) { + bundle.putParcelable(SAVED_STATE_KEY, current) + } + + override fun restoreState(bundle: Bundle) { + bundle.classLoader = javaClass.classLoader + bundle.getParcelable(SAVED_STATE_KEY)?.let { restoredState -> + update { restoredState } + } + } + + private companion object { + private const val SAVED_STATE_KEY = "PersistableStateKey" + } +} + +@Suppress("FunctionName") +fun PersistableStateModel.ModelState( + initialState: S, +): PersistableStateHolder = PersistableStateHolderImpl( + initialState = initialState, +) \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/common/PersistableStateBindingImpl.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/common/PersistableStateBindingImpl.kt new file mode 100644 index 0000000..fcaad0e --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/common/PersistableStateBindingImpl.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.vc.common + +import android.os.Bundle +import android.os.Parcelable +import com.revolut.kompot.common.IOData + +internal class PersistableStateBindingImpl, State : Parcelable, Out : IOData.Output>( + private val controller: PersistableStateController, + private val model: M, +) : PersistableStateModelBinding { + override fun saveState(outState: Bundle) = model.state.saveState(outState) + override fun restoreState(state: Bundle) = model.state.restoreState(state) +} \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/common/PersistableStateContract.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/common/PersistableStateContract.kt new file mode 100644 index 0000000..a511a1e --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/common/PersistableStateContract.kt @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.vc.common + +import android.os.Bundle +import android.os.Parcelable +import com.revolut.kompot.common.IOData +import com.revolut.kompot.navigable.vc.ViewControllerApi +import com.revolut.kompot.navigable.vc.binding.ModelBinding +import com.revolut.kompot.navigable.vc.binding.ViewControllerModelApi +import com.revolut.kompot.navigable.vc.ui.PersistentModelStateStorage + +interface PersistableStateModelBinding : ModelBinding + +interface PersistableStateController : ViewControllerApi { + override val modelBinding: PersistableStateModelBinding +} + +interface PersistableStateModel : ViewControllerModelApi { + val state: PersistableStateHolder +} + +abstract class PersistableStateHolder( + initialState: S +) : StateHolder(initialState), PersistableState + +interface PersistableStorageState { + fun restoreStateFromStorage(stateStorage: PersistentModelStateStorage) + fun saveStateToStorage(stateStorage: PersistentModelStateStorage) +} + +interface PersistableState { + fun saveState(bundle: Bundle) + fun restoreState(bundle: Bundle) +} \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/common/StateHolder.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/common/StateHolder.kt new file mode 100644 index 0000000..2269047 --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/common/StateHolder.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.vc.common + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update + +abstract class StateHolder( + initialState: S, +) { + private val stateFlow = MutableStateFlow(initialState) + val current: S get() = stateFlow.value + + internal fun statesStream(): Flow = stateFlow + + internal fun update(func: S.() -> S) { + stateFlow.update { current.func() } + } +} \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/composite/CompositeBinding.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/composite/CompositeBinding.kt new file mode 100644 index 0000000..2f34cd6 --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/composite/CompositeBinding.kt @@ -0,0 +1,136 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.vc.composite + +import android.content.Intent +import android.os.Bundle +import com.revolut.kompot.navigable.vc.binding.ModelBinding +import com.revolut.kompot.navigable.vc.ui.PersistentModelStateStorage + +internal class CompositeModelBinding( + private val bindings: List +) : ModelBinding { + + override fun onCreate() { + bindings.forEach { binding -> + binding.onCreate() + } + } + + override fun onDestroy() { + bindings.forEach { binding -> + binding.onDestroy() + } + } + + override fun onShow() { + bindings.forEach { binding -> + binding.onShow() + } + } + + override fun onHide() { + bindings.forEach { binding -> + binding.onHide() + } + } + + override fun onTransitionStart(enter: Boolean) { + bindings.forEach { binding -> + binding.onTransitionStart(enter) + } + } + + override fun onTransitionEnd(enter: Boolean) { + bindings.forEach { binding -> + binding.onTransitionEnd(enter) + } + } + + override fun onTransitionCanceled() { + bindings.forEach { binding -> + binding.onTransitionCanceled() + } + } + + override fun onHostPaused() { + bindings.forEach { binding -> + binding.onHostPaused() + } + } + + override fun onHostResumed() { + bindings.forEach { binding -> + binding.onHostResumed() + } + } + + override fun onHostStarted() { + bindings.forEach { binding -> + binding.onHostStarted() + } + } + + override fun onHostStopped() { + bindings.forEach { binding -> + binding.onHostStopped() + } + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + bindings.forEach { binding -> + binding.onActivityResult(requestCode, resultCode, data) + } + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + bindings.forEach { binding -> + binding.onRequestPermissionsResult(requestCode, permissions, grantResults) + } + } + + override fun handleBack(defaultHandler: () -> Boolean) = + bindings.any { it.handleBack(defaultHandler) } + + override fun saveState(outState: Bundle) { + bindings.forEach { binding -> + binding.saveState(outState) + } + } + + override fun restoreState(state: Bundle) { + bindings.forEach { binding -> + binding.restoreState(state) + } + } + + override fun saveStateToStorage(stateStorage: PersistentModelStateStorage) { + bindings.forEach { binding -> + binding.saveStateToStorage(stateStorage) + } + } + + override fun restoreStateFromStorage(stateStorage: PersistentModelStateStorage) { + bindings.forEach { binding -> + binding.restoreStateFromStorage(stateStorage) + } + } +} \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/composite/modal_ui_states/ModalHostUIStatesBinding.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/composite/modal_ui_states/ModalHostUIStatesBinding.kt new file mode 100644 index 0000000..42a267a --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/composite/modal_ui_states/ModalHostUIStatesBinding.kt @@ -0,0 +1,147 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.vc.composite.modal_ui_states + +import androidx.recyclerview.widget.RecyclerView +import com.revolut.kompot.R +import com.revolut.kompot.common.IOData +import com.revolut.kompot.navigable.flow.FlowStep +import com.revolut.kompot.navigable.vc.binding.ModelBinding +import com.revolut.kompot.navigable.vc.composite.CompositeModelBinding +import com.revolut.kompot.navigable.vc.modal.ModalHostBinding +import com.revolut.kompot.navigable.vc.modal.ModalHostBindingImpl +import com.revolut.kompot.navigable.vc.modal.ModalHostController +import com.revolut.kompot.navigable.vc.modal.ModalHostViewModel +import com.revolut.kompot.navigable.vc.ui.DebounceStreamProvider +import com.revolut.kompot.navigable.vc.ui.States +import com.revolut.kompot.navigable.vc.ui.UIStateModelBindingImpl +import com.revolut.kompot.navigable.vc.ui.UIStatesController +import com.revolut.kompot.navigable.vc.ui.UIStatesModel +import com.revolut.kompot.navigable.vc.ui.UIStatesModelBinding +import com.revolut.kompot.navigable.vc.ui.list.DefaultAdapter +import com.revolut.kompot.navigable.vc.ui.list.DefaultLayoutManager +import com.revolut.kompot.navigable.vc.ui.list.LayoutManagerProvider +import com.revolut.kompot.navigable.vc.ui.list.UIListStatesController +import com.revolut.kompot.navigable.vc.ui.list.UIListStatesModel +import com.revolut.kompot.navigable.vc.ui.list.UIListStatesModelBinding +import com.revolut.kompot.navigable.vc.ui.list.UIListStatesModelBindingImpl +import com.revolut.recyclerkit.delegates.DiffAdapter +import com.revolut.recyclerkit.delegates.RecyclerViewDelegate + +interface ModalHostUIStatesBinding : UIStatesModelBinding, + ModalHostBinding + +interface ModalHostUIListStatesBinding : UIListStatesModelBinding, + ModalHostBinding + +interface ModalHostUIStatesController : UIStatesController, + ModalHostController { + override val modelBinding: ModalHostUIStatesBinding +} + +interface ModalHostUIListStatesController : UIListStatesController, + ModalHostController { + override val modelBinding: ModalHostUIListStatesBinding +} + +interface ModalHostUIStatesModel : + UIStatesModel, + ModalHostViewModel + +interface ModalHostUIListStatesModel : + UIListStatesModel, + ModalHostViewModel + +internal class ModalHostUIStatesBindingImpl( + controller: ModalHostUIStatesController, + model: ModalHostUIStatesModel, + debounceStreamProvider: DebounceStreamProvider?, +) : ModalHostUIStatesBinding, ModelBinding by CompositeModelBinding( + bindings = listOf( + UIStateModelBindingImpl( + controller = controller, + model = model, + debounceStreamProvider = debounceStreamProvider, + ), + ModalHostBindingImpl( + controller = controller, + model = model + ) + ) +) + +internal class ModalHostUIListStatesBindingImpl( + controller: ModalHostUIListStatesController, + model: ModalHostUIListStatesModel, + delegates: List>, + recyclerViewId: Int, + layoutManagerProvider: LayoutManagerProvider, + listAdapter: DiffAdapter, + debounceStreamProvider: DebounceStreamProvider?, + private val uiListStatesModelBinding: UIListStatesModelBinding = UIListStatesModelBindingImpl( + controller = controller, + model = model, + delegates = delegates, + recyclerViewId = recyclerViewId, + layoutManagerProvider = layoutManagerProvider, + listAdapter = listAdapter, + debounceStreamProvider = debounceStreamProvider, + ), +) : ModalHostUIListStatesBinding, ModelBinding by CompositeModelBinding( + bindings = listOf( + uiListStatesModelBinding, + ModalHostBindingImpl( + controller = controller, + model = model + ) + ) +) { + override val recyclerView: RecyclerView get() = uiListStatesModelBinding.recyclerView + override val layoutManager: RecyclerView.LayoutManager get() = uiListStatesModelBinding.layoutManager +} + +@Suppress("FunctionName") +fun ModalHostUIStatesController.ModelBinding( + model: ModalHostUIStatesModel, + debounceStreamProvider: DebounceStreamProvider? = null, +): ModalHostUIStatesBinding { + return ModalHostUIStatesBindingImpl( + controller = this, + model = model, + debounceStreamProvider = debounceStreamProvider, + ) +} + +@Suppress("FunctionName") +fun ModalHostUIListStatesController.ModelBinding( + model: ModalHostUIListStatesModel, + delegates: List>, + recyclerViewId: Int = R.id.recyclerView, + layoutManagerProvider: LayoutManagerProvider = { DefaultLayoutManager(this) }, + listAdapter: DiffAdapter = DefaultAdapter(), + debounceStreamProvider: DebounceStreamProvider? = null, +): ModalHostUIListStatesBinding { + return ModalHostUIListStatesBindingImpl( + controller = this, + model = model, + delegates = delegates, + recyclerViewId = recyclerViewId, + layoutManagerProvider = layoutManagerProvider, + listAdapter = listAdapter, + debounceStreamProvider = debounceStreamProvider, + ) +} \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/composite/modal_ui_states_scroller/ModalHostUIStateScrollerBinding.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/composite/modal_ui_states_scroller/ModalHostUIStateScrollerBinding.kt new file mode 100644 index 0000000..7d84a97 --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/composite/modal_ui_states_scroller/ModalHostUIStateScrollerBinding.kt @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.vc.composite.modal_ui_states_scroller + +import com.revolut.kompot.R +import com.revolut.kompot.common.IOData +import com.revolut.kompot.navigable.flow.FlowStep +import com.revolut.kompot.navigable.vc.binding.ModelBinding +import com.revolut.kompot.navigable.vc.composite.CompositeModelBinding +import com.revolut.kompot.navigable.vc.composite.modal_ui_states.ModalHostUIStatesBinding +import com.revolut.kompot.navigable.vc.composite.modal_ui_states.ModalHostUIStatesBindingImpl +import com.revolut.kompot.navigable.vc.composite.modal_ui_states.ModalHostUIStatesController +import com.revolut.kompot.navigable.vc.composite.modal_ui_states.ModalHostUIStatesModel +import com.revolut.kompot.navigable.vc.scroller.ScrollMode +import com.revolut.kompot.navigable.vc.scroller.ScrollerItem +import com.revolut.kompot.navigable.vc.scroller.ScrollerModelBinding +import com.revolut.kompot.navigable.vc.scroller.ScrollerModelBindingImpl +import com.revolut.kompot.navigable.vc.scroller.ScrollerViewController +import com.revolut.kompot.navigable.vc.scroller.ScrollerViewModel +import com.revolut.kompot.navigable.vc.ui.DebounceStreamProvider +import com.revolut.kompot.navigable.vc.ui.States + +interface ModalHostUIStatesScrollerBinding : ScrollerModelBinding, ModalHostUIStatesBinding + +interface ModalHostUIStatesScroller : ScrollerViewController, ModalHostUIStatesController { + override val modelBinding: ModalHostUIStatesScrollerBinding +} + +interface ModalHostUIStatesScrollerModel + : ScrollerViewModel, ModalHostUIStatesModel + +internal class ModalHostUIStatesScrollerModelBindingImpl( + controller: ModalHostUIStatesScroller, + itemContainerLayoutId: Int, + scrollMode: ScrollMode, + model: ModalHostUIStatesScrollerModel, + recyclerViewId: Int, + debounceStreamProvider: DebounceStreamProvider?, + private val scrollerModelBinding: ScrollerModelBinding = ScrollerModelBindingImpl( + controller = controller, + itemContainerLayoutId = itemContainerLayoutId, + scrollMode = scrollMode, + model = model, + recyclerViewId = recyclerViewId, + ), +) : ModalHostUIStatesScrollerBinding, ModelBinding by CompositeModelBinding( + bindings = listOf( + scrollerModelBinding, + ModalHostUIStatesBindingImpl( + controller = controller, + model = model, + debounceStreamProvider = debounceStreamProvider, + ), + ) +) + +@Suppress("FunctionName") +fun ModalHostUIStatesScroller.ModelBinding( + itemContainerLayoutId: Int = R.layout.flow_scroller_item_container, + scrollMode: ScrollMode = ScrollMode.PAGER, + recyclerViewId: Int = R.id.recyclerView, + debounceStreamProvider: DebounceStreamProvider? = null, + model: ModalHostUIStatesScrollerModel, +): ModalHostUIStatesScrollerBinding { + return ModalHostUIStatesScrollerModelBindingImpl( + controller = this, + itemContainerLayoutId = itemContainerLayoutId, + scrollMode = scrollMode, + model = model, + recyclerViewId = recyclerViewId, + debounceStreamProvider = debounceStreamProvider, + ) +} \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/composite/stateful_flow/StatefulFlowBinding.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/composite/stateful_flow/StatefulFlowBinding.kt new file mode 100644 index 0000000..9665afb --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/composite/stateful_flow/StatefulFlowBinding.kt @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.vc.composite.stateful_flow + +import android.os.Parcelable +import com.revolut.kompot.R +import com.revolut.kompot.common.IOData +import com.revolut.kompot.navigable.flow.FlowStep +import com.revolut.kompot.navigable.vc.binding.ModelBinding +import com.revolut.kompot.navigable.vc.common.PersistableStateBindingImpl +import com.revolut.kompot.navigable.vc.common.PersistableStateController +import com.revolut.kompot.navigable.vc.common.PersistableStateModel +import com.revolut.kompot.navigable.vc.common.PersistableStateModelBinding +import com.revolut.kompot.navigable.vc.composite.CompositeModelBinding +import com.revolut.kompot.navigable.vc.flow.FlowModelBinding +import com.revolut.kompot.navigable.vc.flow.FlowModelBindingImpl +import com.revolut.kompot.navigable.vc.flow.FlowViewController +import com.revolut.kompot.navigable.vc.flow.FlowViewModel + +interface StatefulFlowBinding : FlowModelBinding, PersistableStateModelBinding + +interface StatefulFlowViewController : FlowViewController, PersistableStateController { + override val modelBinding: StatefulFlowBinding +} + +interface StatefulFlowModel + : FlowViewModel, PersistableStateModel + +internal class StatefulFlowBindingImpl( + containerId: Int, + controller: StatefulFlowViewController, + model: StatefulFlowModel, + private val flowModelBinding: FlowModelBinding = FlowModelBindingImpl( + containerId = containerId, + controller = controller, + model = model, + ), +) : StatefulFlowBinding, ModelBinding by CompositeModelBinding( + bindings = listOf( + flowModelBinding, + PersistableStateBindingImpl( + controller = controller, + model = model + ) + ) +) { + override val hasBackStack: Boolean get() = flowModelBinding.hasBackStack +} + +@Suppress("FunctionName") +fun StatefulFlowViewController.ModelBinding( + model: StatefulFlowModel, + containerId: Int = R.id.container, +): StatefulFlowBinding { + return StatefulFlowBindingImpl( + containerId = containerId, + controller = this, + model = model, + ) +} \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/composite/ui_states_flow/UIStatesFlowBinding.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/composite/ui_states_flow/UIStatesFlowBinding.kt new file mode 100644 index 0000000..5071e18 --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/composite/ui_states_flow/UIStatesFlowBinding.kt @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.vc.composite.ui_states_flow + +import com.revolut.kompot.R +import com.revolut.kompot.common.IOData +import com.revolut.kompot.navigable.flow.FlowStep +import com.revolut.kompot.navigable.vc.binding.ModelBinding +import com.revolut.kompot.navigable.vc.composite.CompositeModelBinding +import com.revolut.kompot.navigable.vc.flow.FlowModelBinding +import com.revolut.kompot.navigable.vc.flow.FlowModelBindingImpl +import com.revolut.kompot.navigable.vc.flow.FlowViewController +import com.revolut.kompot.navigable.vc.flow.FlowViewModel +import com.revolut.kompot.navigable.vc.ui.DebounceStreamProvider +import com.revolut.kompot.navigable.vc.ui.States +import com.revolut.kompot.navigable.vc.ui.UIStateModelBindingImpl +import com.revolut.kompot.navigable.vc.ui.UIStatesController +import com.revolut.kompot.navigable.vc.ui.UIStatesModel +import com.revolut.kompot.navigable.vc.ui.UIStatesModelBinding + +interface UIStatesFlowBinding : FlowModelBinding, UIStatesModelBinding + +interface UIStatesFlowController : FlowViewController, + UIStatesController { + override val modelBinding: UIStatesFlowBinding +} + +interface UIStatesFlowModel + : FlowViewModel, UIStatesModel + +internal class UIStatesFlowModelBindingImpl( + containerId: Int, + controller: UIStatesFlowController, + model: UIStatesFlowModel, + debounceStreamProvider: DebounceStreamProvider?, + private val flowModelBinding: FlowModelBinding = FlowModelBindingImpl( + containerId = containerId, + controller = controller, + model = model, + ), +) : UIStatesFlowBinding, ModelBinding by CompositeModelBinding( + bindings = listOf( + flowModelBinding, + UIStateModelBindingImpl( + controller = controller, + model = model, + debounceStreamProvider = debounceStreamProvider, + ), + ) +) { + override val hasBackStack: Boolean get() = flowModelBinding.hasBackStack +} + +@Suppress("FunctionName") +fun UIStatesFlowController.ModelBinding( + model: UIStatesFlowModel, + containerId: Int = R.id.container, + debounceStreamProvider: DebounceStreamProvider? = null, +): UIStatesFlowBinding { + return UIStatesFlowModelBindingImpl( + controller = this, + containerId = containerId, + model = model, + debounceStreamProvider = debounceStreamProvider, + ) +} \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/composite/ui_states_scroller/UIStatesScrollerBinding.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/composite/ui_states_scroller/UIStatesScrollerBinding.kt new file mode 100644 index 0000000..2e15343 --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/composite/ui_states_scroller/UIStatesScrollerBinding.kt @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.vc.composite.ui_states_scroller + +import com.revolut.kompot.R +import com.revolut.kompot.common.IOData +import com.revolut.kompot.navigable.vc.binding.ModelBinding +import com.revolut.kompot.navigable.vc.composite.CompositeModelBinding +import com.revolut.kompot.navigable.vc.scroller.ScrollMode +import com.revolut.kompot.navigable.vc.scroller.ScrollerItem +import com.revolut.kompot.navigable.vc.scroller.ScrollerModelBinding +import com.revolut.kompot.navigable.vc.scroller.ScrollerModelBindingImpl +import com.revolut.kompot.navigable.vc.scroller.ScrollerViewController +import com.revolut.kompot.navigable.vc.scroller.ScrollerViewModel +import com.revolut.kompot.navigable.vc.ui.DebounceStreamProvider +import com.revolut.kompot.navigable.vc.ui.States +import com.revolut.kompot.navigable.vc.ui.UIStateModelBindingImpl +import com.revolut.kompot.navigable.vc.ui.UIStatesController +import com.revolut.kompot.navigable.vc.ui.UIStatesModel +import com.revolut.kompot.navigable.vc.ui.UIStatesModelBinding + +interface UIStatesScrollerBinding : ScrollerModelBinding, UIStatesModelBinding + +interface UIStatesScrollerController : ScrollerViewController, + UIStatesController { + override val modelBinding: UIStatesScrollerBinding +} + +interface UIStatesScrollerModel + : ScrollerViewModel, UIStatesModel + +internal class UIStatesScrollerModelBindingImpl( + controller: UIStatesScrollerController, + itemContainerLayoutId: Int, + scrollMode: ScrollMode, + model: UIStatesScrollerModel, + recyclerViewId: Int, + debounceStreamProvider: DebounceStreamProvider?, + private val scrollerModelBinding: ScrollerModelBinding = ScrollerModelBindingImpl( + controller = controller, + itemContainerLayoutId = itemContainerLayoutId, + scrollMode = scrollMode, + model = model, + recyclerViewId = recyclerViewId, + ), +) : UIStatesScrollerBinding, ModelBinding by CompositeModelBinding( + bindings = listOf( + scrollerModelBinding, + UIStateModelBindingImpl( + controller = controller, + model = model, + debounceStreamProvider = debounceStreamProvider, + ), + ) +) + +@Suppress("FunctionName") +fun UIStatesScrollerController.ModelBinding( + itemContainerLayoutId: Int = R.layout.flow_scroller_item_container, + scrollMode: ScrollMode = ScrollMode.PAGER, + recyclerViewId: Int = R.id.recyclerView, + debounceStreamProvider: DebounceStreamProvider? = null, + model: UIStatesScrollerModel, +): UIStatesScrollerBinding { + return UIStatesScrollerModelBindingImpl( + controller = this, + itemContainerLayoutId = itemContainerLayoutId, + scrollMode = scrollMode, + model = model, + recyclerViewId = recyclerViewId, + debounceStreamProvider = debounceStreamProvider, + ) +} \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/di/EmptyViewControllerComponent.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/di/EmptyViewControllerComponent.kt new file mode 100644 index 0000000..c7253f9 --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/di/EmptyViewControllerComponent.kt @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.vc.di + +import com.revolut.kompot.navigable.ControllerExtension +import com.revolut.kompot.navigable.ControllerModelExtension + +object EmptyViewControllerComponent : ViewControllerComponent { + override fun getControllerExtensions(): Set = setOf() + override fun getControllerModelExtensions(): Set = setOf() +} \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/di/FlowViewControllerComponent.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/di/FlowViewControllerComponent.kt new file mode 100644 index 0000000..1a05d11 --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/di/FlowViewControllerComponent.kt @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.vc.di + +import com.revolut.kompot.di.scope.FlowQualifier +import com.revolut.kompot.di.scope.FlowScope +import com.revolut.kompot.navigable.Controller +import com.revolut.kompot.navigable.ControllerExtension +import com.revolut.kompot.navigable.ControllerModelExtension +import com.revolut.kompot.navigable.vc.ViewController +import dagger.Binds +import dagger.BindsInstance +import dagger.multibindings.Multibinds + +interface FlowViewControllerComponent : ViewControllerComponent { + + @FlowQualifier + override fun getControllerModelExtensions(): Set + + @FlowQualifier + override fun getControllerExtensions(): Set + + interface Builder { + @BindsInstance + fun controller(viewController: ViewController<*>): B + fun build(): T + } +} + +interface FlowViewControllerModule { + + @[Binds FlowQualifier FlowScope] + fun provideController(controller: ViewController<*>): Controller + + @[Multibinds FlowQualifier FlowScope] + fun provideControllerExtensions(): Set + + @[Multibinds FlowQualifier FlowScope] + fun provideControllerModelExtensions(): Set +} \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/di/ScrollerViewControllerComponent.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/di/ScrollerViewControllerComponent.kt new file mode 100644 index 0000000..a289321 --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/di/ScrollerViewControllerComponent.kt @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.vc.di + +import com.revolut.kompot.navigable.vc.ViewController +import dagger.BindsInstance + +interface ScrollerViewControllerComponent: FlowViewControllerComponent { + + interface Builder { + @BindsInstance + fun controller(viewController: ViewController<*>): B + fun build(): T + } +} + +interface ScrollerViewControllerModule: FlowViewControllerModule \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/di/ViewControllerComponent.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/di/ViewControllerComponent.kt new file mode 100644 index 0000000..ade51d3 --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/di/ViewControllerComponent.kt @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.vc.di + +import com.revolut.kompot.di.flow.ControllerComponent +import com.revolut.kompot.navigable.Controller +import com.revolut.kompot.navigable.ControllerExtension +import com.revolut.kompot.navigable.ControllerModelExtension +import com.revolut.kompot.navigable.vc.ViewController +import dagger.Binds +import dagger.BindsInstance +import dagger.multibindings.Multibinds +import javax.inject.Qualifier +import javax.inject.Scope + +interface ViewControllerComponent : ControllerComponent, ViewControllerExtensionsInjector, + ViewControllerModelExtensionsInjector { + interface Builder { + @BindsInstance + fun controller(@ViewControllerQualifier viewController: ViewController<*>): B + fun build(): T + } +} + +interface ViewControllerExtensionsInjector { + fun getControllerExtensions(): Set +} + +interface ViewControllerModelExtensionsInjector { + fun getControllerModelExtensions(): Set +} + +interface ViewControllerModule { + + @[Binds ViewControllerScope ViewControllerQualifier] + fun provideController(@ViewControllerQualifier viewController: ViewController<*>): Controller + + @[Multibinds ViewControllerScope] + fun provideControllerExtensions(): Set + + @[Multibinds ViewControllerScope] + fun provideControllerModelExtensions(): Set +} + + +@Scope +annotation class ViewControllerScope + +@Qualifier +annotation class ViewControllerQualifier \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/flow/FlowContract.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/flow/FlowContract.kt new file mode 100644 index 0000000..682d455 --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/flow/FlowContract.kt @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.vc.flow + +import com.revolut.kompot.common.IOData +import com.revolut.kompot.di.flow.ParentFlow +import com.revolut.kompot.navigable.flow.FlowStep +import com.revolut.kompot.navigable.vc.ViewControllerApi +import com.revolut.kompot.navigable.vc.binding.ModelBinding +import com.revolut.kompot.navigable.vc.binding.ViewControllerModelApi + +interface FlowModelBinding : ModelBinding { + val hasBackStack: Boolean +} + +interface FlowViewController : ViewControllerApi, ParentFlow { + override val layoutId: Int get() = environment.defaultControllerContainer + override val modelBinding: FlowModelBinding + override val hasBackStack: Boolean get() = modelBinding.hasBackStack +} + +interface FlowViewModel : ViewControllerModelApi { + val flowCoordinator: FlowCoordinator +} \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/flow/FlowCoordinator.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/flow/FlowCoordinator.kt new file mode 100644 index 0000000..bc47232 --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/flow/FlowCoordinator.kt @@ -0,0 +1,319 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.vc.flow + +import android.os.Bundle +import android.os.Parcelable +import androidx.annotation.VisibleForTesting +import com.revolut.kompot.common.IOData +import com.revolut.kompot.common.ModalDestination +import com.revolut.kompot.navigable.Controller +import com.revolut.kompot.navigable.ControllerKey +import com.revolut.kompot.navigable.ControllerModel +import com.revolut.kompot.navigable.SavedStateOwner +import com.revolut.kompot.navigable.TransitionAnimation +import com.revolut.kompot.navigable.binder.ModelBinder +import com.revolut.kompot.navigable.binder.StatefulModelBinder +import com.revolut.kompot.navigable.cache.ControllerCacheStrategy +import com.revolut.kompot.navigable.flow.FlowNavigationCommand +import com.revolut.kompot.navigable.flow.FlowStep +import com.revolut.kompot.navigable.flow.Next +import com.revolut.kompot.navigable.flow.PushControllerCommand +import com.revolut.kompot.navigable.flow.Quit +import com.revolut.kompot.navigable.flow.RestorationState +import com.revolut.kompot.navigable.flow.ReusableFlowStep +import com.revolut.kompot.navigable.flow.getControllerKey +import com.revolut.kompot.navigable.vc.modal.ModalCoordinator +import kotlinx.parcelize.Parcelize + +open class FlowCoordinator( + private val hostModel: ControllerModel, + private val initialStep: STEP, + private val initialBackStack: List>, + private val controllersFactory: FlowCoordinator.(STEP) -> Controller, +) { + + val step: STEP get() = taskState.step + val hasBackStack: Boolean get() = _backStack.isNotEmpty() + val restorationState: RestorationState? + get() = when { + savedState != null -> RestorationState.REQUIRED + else -> null + } + + private val modalCoordinator = ModalCoordinator( + hostModel = hostModel, + controllersFactory = { step -> + controllersFactory(step) + } + ) + + private lateinit var taskState: FlowTaskState + private val _backStack = mutableListOf>() + private val navigationCommandsBinder = + StatefulModelBinder>() + + private var savedState: Bundle? = null + private var latestStateBackup: Backup? = null + private lateinit var parentControllerKey: ControllerKey + + @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) + fun onCreate(parentKey: ControllerKey) { + this.parentControllerKey = parentKey + setFlowModelState(savedState) + navigationCommandsBinder.notify( + PushControllerCommand( + controller = getOrCreateCurrentStateController(), + fromSavedState = false, + animation = TransitionAnimation.NONE, + backward = false, + executeImmediately = true, + ) + ) + modalCoordinator.performCreate() + } + + private fun setFlowModelState(pendingSavedState: Bundle?) { + if (pendingSavedState == null) { + setInitialState() + } else { + restoreFlowModelState(pendingSavedState) + } + } + + private fun setInitialState() { + taskState = FlowTaskState( + step = initialStep, + animation = if (initialBackStack.isEmpty()) { + TransitionAnimation.NONE + } else { + initialBackStack.last().animation + } + ) + _backStack.clear() + initialBackStack.forEach { (step, transition) -> + _backStack.add( + FlowTaskState( + step = step, + animation = transition + ) + ) + } + } + + private fun restoreFlowModelState(bundle: Bundle) { + if (this::taskState.isInitialized) { + invalidateCache(taskState, destroy = false) + } + bundle.classLoader = javaClass.classLoader + + if (bundle.containsKey(TASK_STATE_ARG) && bundle.containsKey(BACK_STACK_ARG)) { + taskState = requireNotNull(bundle.getParcelable(TASK_STATE_ARG)) + setBackStack(requireNotNull(bundle.getParcelableArrayList(BACK_STACK_ARG))) + } + } + + private fun setBackStack(backStack: List>) { + _backStack.clear() + _backStack.addAll(backStack) + } + + internal fun next( + step: STEP, + addCurrentStepToBackStack: Boolean, + animation: TransitionAnimation, + executeImmediately: Boolean, + ) { + navigationCommandsBinder.notify( + Next( + step = step, + addCurrentStepToBackStack = addCurrentStepToBackStack, + animation = animation, + executeImmediately = executeImmediately, + ) + ) + } + + @VisibleForTesting + fun handleBackStack(immediate: Boolean): Boolean { + if (!hasBackStack) return false + val backwardAnimation = taskState.animation + val prevTaskState = taskState + taskState = _backStack.removeLast() + + val targetController = getOrCreateCurrentStateController() + latestStateBackup = Backup( + state = prevTaskState, + backstackTopEntry = taskState + ) + navigationCommandsBinder.notify( + PushControllerCommand( + controller = targetController, + fromSavedState = !targetController.created, + animation = backwardAnimation, + backward = true, + executeImmediately = immediate, + ) + ) + return true + } + + internal fun onTransitionCanceled(backward: Boolean) { + if (backward) { + latestStateBackup?.let { backup -> + taskState = backup.state + _backStack.add(backup.backstackTopEntry) + } + } else { + if (hasBackStack) { + taskState = _backStack.removeLast() + } + } + } + + @VisibleForTesting + fun setNextState(fromCommand: Next, childState: Bundle?) { + if (!fromCommand.addCurrentStepToBackStack) { + invalidateCache(taskState, destroy = false) + } else { + _backStack.add(taskState.copy(childState = childState)) + } + + taskState = taskState.copy( + step = fromCommand.step, + animation = fromCommand.animation, + childState = null, + currentControllerKey = null + ) + + navigationCommandsBinder.notify( + PushControllerCommand( + controller = getOrCreateCurrentStateController(), + fromSavedState = false, + animation = fromCommand.animation, + backward = false, + executeImmediately = fromCommand.executeImmediately, + ) + ) + } + + private fun invalidateCache(state: FlowTaskState<*>, destroy: Boolean) { + if (state.currentControllerKey != null) { + hostModel.controllersCache.removeController(state.currentControllerKey, destroy) + } + } + + private fun getOrCreateCurrentStateController(): Controller { + val cachedController = getCachedController(parentControllerKey) + val controller = cachedController ?: createController(taskState.step, parentControllerKey) + if (cachedController == null && controller is SavedStateOwner) { + controller.doOnCreate { taskState.childState?.run(controller::restoreState) } + } + + taskState = taskState.copy(currentControllerKey = controller.key) + + return controller + } + + private fun getCachedController(parentKey: ControllerKey): Controller? { + val cacheKey = taskState.currentControllerKey ?: taskState.step.getReusableControllerKey(parentKey) + return cacheKey?.let { hostModel.controllersCache.getController(cacheKey) } + } + + private fun FlowStep.getReusableControllerKey(parentKey: ControllerKey): ControllerKey? = + (this as? ReusableFlowStep)?.getControllerKey(parentKey) + + private fun createController(step: STEP, parentKey: ControllerKey) = controllersFactory(taskState.step).also { controller -> + if (step is ReusableFlowStep) { + controller.cacheStrategy = ControllerCacheStrategy.DependentOn(parentKey) + controller.keyInitialization = { step.getControllerKey(parentKey) } + } + } + + @VisibleForTesting + fun getCurrentController(): Controller = controllersFactory(taskState.step) + + internal fun quit() { + clearBackStack() + navigationCommandsBinder.notify(Quit()) + } + + internal fun clearBackStack() { + if (hasBackStack) { + _backStack.forEach { state -> invalidateCache(state, destroy = true) } + _backStack.clear() + } + } + + fun navigationBinder(): ModelBinder> = + navigationCommandsBinder + + fun saveState(outBundle: Bundle, childBundle: Bundle) { + taskState = taskState.copy(childState = childBundle) + outBundle.putParcelable(TASK_STATE_ARG, taskState) + outBundle.putParcelableArrayList(BACK_STACK_ARG, ArrayList(_backStack)) + modalCoordinator.saveState(outBundle) + } + + fun restoreState(bundle: Bundle) { + savedState = bundle + modalCoordinator.restoreState(bundle) + } + + internal fun onDestroy() { + clearBackStack() + } + + internal fun openModal(step: STEP, style: ModalDestination.Style) = modalCoordinator.openModal(step, style) + + companion object { + private const val TASK_STATE_ARG = "TASK_STATE_ARG" + private const val BACK_STACK_ARG = "BACK_STACK_ARG" + } + + private inner class Backup( + val state: FlowTaskState, + val backstackTopEntry: FlowTaskState, + ) +} + +@Parcelize +internal data class FlowTaskState( + val step: S, + val childState: Bundle? = null, + val animation: TransitionAnimation = TransitionAnimation.NONE, + val currentControllerKey: ControllerKey? = null +) : Parcelable + +@Parcelize +data class BackStackEntry( + val step: S, + val animation: TransitionAnimation, +) : Parcelable + +fun T.FlowCoordinator( + initialStep: S, + initialBackStack: List> = emptyList(), + controllersFactory: FlowCoordinator.(S) -> Controller, +): FlowCoordinator where T : FlowViewModel, + T : ControllerModel = + FlowCoordinator( + hostModel = this, + initialStep = initialStep, + initialBackStack = initialBackStack, + controllersFactory = controllersFactory, + ) diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/flow/FlowModelBindingImpl.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/flow/FlowModelBindingImpl.kt new file mode 100644 index 0000000..18d5805 --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/flow/FlowModelBindingImpl.kt @@ -0,0 +1,291 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.vc.flow + +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import androidx.annotation.VisibleForTesting +import com.revolut.kompot.R +import com.revolut.kompot.common.IOData +import com.revolut.kompot.holder.ControllerViewHolder +import com.revolut.kompot.holder.DefaultControllerViewHolder +import com.revolut.kompot.holder.ModalControllerViewHolder +import com.revolut.kompot.navigable.Controller +import com.revolut.kompot.navigable.ControllerManager +import com.revolut.kompot.navigable.SavedStateOwner +import com.revolut.kompot.navigable.TransitionAnimation +import com.revolut.kompot.navigable.cache.ControllersCache +import com.revolut.kompot.navigable.findRootFlow +import com.revolut.kompot.navigable.flow.ControllerManagersHolder +import com.revolut.kompot.navigable.flow.FlowNavigationCommand +import com.revolut.kompot.navigable.flow.FlowStep +import com.revolut.kompot.navigable.flow.Next +import com.revolut.kompot.navigable.flow.PushControllerCommand +import com.revolut.kompot.navigable.flow.Quit +import com.revolut.kompot.navigable.flow.ensureAvailability +import com.revolut.kompot.navigable.flow.quitFlow +import com.revolut.kompot.navigable.root.NavActionsScheduler +import com.revolut.kompot.navigable.transition.BackwardTransitionOwner +import com.revolut.kompot.navigable.utils.Preconditions +import com.revolut.kompot.navigable.vc.ViewController +import com.revolut.kompot.navigable.vc.binding.ModelBinding +import com.revolut.kompot.navigable.vc.parent.ParentControllerModelBindingDelegate +import com.revolut.kompot.view.ControllerContainer + +internal class FlowModelBindingImpl, S : FlowStep, Out : IOData.Output>( + private val containerId: Int, + private val controller: FlowViewController, + val model: M, + private val onStepUpdated: ((step: S) -> Unit)? = null, + private val childControllerManagers: ControllerManagersHolder = ControllerManagersHolder(), + private val parentControllerModelBindingDelegate: ParentControllerModelBindingDelegate = ParentControllerModelBindingDelegate( + childControllerManagersProvider = childControllerManagers, + controller = controller, + ) +) : FlowModelBinding, ModelBinding by parentControllerModelBindingDelegate { + + private val viewController: ViewController<*> get() = controller as ViewController<*> + + private val navActionsScheduler: NavActionsScheduler + get() = viewController.findRootFlow().navActionsScheduler + private val controllersCache: ControllersCache + get() = viewController.controllersCache + + @VisibleForTesting + internal var mainControllerContainer: ControllerContainer? = null + override val hasBackStack: Boolean + get() = model.flowCoordinator.hasBackStack + + override fun onCreate() { + model.flowCoordinator.onCreate(viewController.key) + viewController.tillDestroyBinding += model.flowCoordinator.navigationBinder() + .bind(::processFlowNavigationCommand) + + (viewController.view as? BackwardTransitionOwner)?.doOnBackwardEvent { + //using immediate because backward transition have already started + handleBackStack(immediate = true) + } + } + + override fun onDestroy() { + model.flowCoordinator.onDestroy() + childControllerManagers.all.forEach { manager -> manager.onDestroy() } + navActionsScheduler.cancel(viewController.key.value) + } + + override fun handleBack(defaultHandler: () -> Boolean): Boolean { + if (parentControllerModelBindingDelegate.handleBack(defaultHandler)) { + return true + } + + return handleBackStack() + } + + private fun handleBackStack(immediate: Boolean = false) = + model.flowCoordinator.handleBackStack(immediate) + + private fun next(command: Next) { + val bundle = if (command.addCurrentStepToBackStack) { + Bundle().also { bundle -> + (requireCurrentController() as? SavedStateOwner)?.saveState(bundle) + } + } else { + null + } + model.flowCoordinator.setNextState(command, bundle) + } + + override fun handleQuit(): Boolean { + if (!handleBackStack()) { + viewController.quitFlow(navActionsScheduler) + } + return true + } + + private fun pushControllerNow( + controller: Controller, + animation: TransitionAnimation = TransitionAnimation.NONE, + backward: Boolean = false, + ) { + val container = mainControllerContainer ?: initMainControllerManager() + val controllerManager = getOrCreateChildControllerManager( + controllerContainer = container, + id = container.containerId + ) + controllerManager.show(controller, animation, backward, viewController) + onStepUpdated?.invoke(model.flowCoordinator.step) + } + + private fun pushController(command: PushControllerCommand) { + fun push() { + pushControllerNow( + controller = command.controller, + animation = command.animation, + backward = command.backward, + ) + } + if (command.executeImmediately) { + push() + } else { + scheduleNavAction { push() } + } + } + + private fun initMainControllerManager(): ControllerContainer { + val mainContainerView = viewController.view.findViewById(containerId) + requireNotNull(mainContainerView) { "${viewController.controllerName}: container for child manager should be presented" } + require(mainContainerView is ControllerContainer) { "${viewController.controllerName}: container for child manager should be ControllerContainer" } + mainContainerView.containerId = ControllerContainer.MAIN_CONTAINER_ID + mainControllerContainer = mainContainerView + return mainContainerView + } + + @VisibleForTesting + internal fun getOrCreateChildControllerManager( + controllerContainer: ControllerContainer, + id: String, + ): ControllerManager { + val containerId = controllerContainer.containerId + if (containerId == ControllerContainer.NO_CONTAINER_ID) { + throw IllegalStateException("containerId should be set for child controller containers") + } + val modal = controllerContainer.containerId == ControllerContainer.MODAL_CONTAINER_ID + return childControllerManagers.getOrAdd(id) { + ControllerManager( + modal = modal, + defaultControllerContainer = viewController.parentControllerManager.defaultControllerContainer, + controllersCache = controllersCache, + controllerViewHolder = getControllerViewHolder( + controllerContainer as ViewGroup, + modal + ), + onAttachController = ::onChildControllerAttached, + onDetachController = ::onChildControllerDetached, + onTransitionCanceled = ::onTransitionCanceled, + ).apply { + hooksProvider = viewController.parentControllerManager.hooksProvider + } + } + } + + private fun onTransitionCanceled(backward: Boolean) { + model.flowCoordinator.onTransitionCanceled(backward) + } + + private fun onChildControllerAttached(controller: Controller, controllerManager: ControllerManager) { + moveModalToLifecycleForeground(controller, controllerManager) + } + + private fun onChildControllerDetached(controller: Controller, controllerManager: ControllerManager) { + removeModalFromLifecycleForeground(controller, controllerManager) + } + + /** + * Mark every controller under a modal as detached + */ + private fun moveModalToLifecycleForeground(controller: Controller, controllerManager: ControllerManager) { + if (controllerManager.modal && (controllerManager.activeController == null || controllerManager.activeController == controller)) { + childControllerManagers.all.forEach { childControllerManager -> + if (childControllerManager != controllerManager && childControllerManager.activeController != null) { + childControllerManager.onDetach() + } + } + } + } + + /** + * Bring back attached state for the controllers under a modal + */ + private fun removeModalFromLifecycleForeground(controller: Controller, controllerManager: ControllerManager) { + if (controllerManager.modal && (controllerManager.activeController == null || controllerManager.activeController == controller)) { + childControllerManagers.all.asReversed().forEach { childControllerManager -> + if (childControllerManager != controllerManager && childControllerManager.activeController != null) { + if (controllerManager.attached) { + childControllerManager.onAttach() + } + if (childControllerManager.modal) { + return //we need only one active modal controller manager after a current + } + } + } + } + } + + private fun getControllerViewHolder( + container: ViewGroup, + modal: Boolean + ): ControllerViewHolder { + val modalAnimatable = viewController.findRootFlow().getModalAnimatable() + return if (modal && modalAnimatable != null) + ModalControllerViewHolder(container, modalAnimatable) + else + DefaultControllerViewHolder(container) + } + + private fun processFlowNavigationCommand(command: FlowNavigationCommand) { + when (command) { + is Next -> { + if (!navActionsScheduler.ensureAvailability(command)) return + Preconditions.requireMainThread("FlowCoordinator.next()") + next(command) + } + + is PushControllerCommand -> { + Preconditions.requireMainThread("Push controller") + pushController(command) + } + + is Quit -> { + if (!navActionsScheduler.ensureAvailability(command)) return + Preconditions.requireMainThread("FlowCoordinator#quit()") + viewController.quitFlow(navActionsScheduler) + } + + else -> error("$command is not supported") + } + } + + override fun saveState(outState: Bundle) { + val childBundle = Bundle() + (requireCurrentController() as? SavedStateOwner)?.saveState(childBundle) + model.flowCoordinator.saveState(outState, childBundle) + } + + private fun requireCurrentController(): Controller { + val controllerContainer = checkNotNull(mainControllerContainer) + val controllerManager = childControllerManagers.get(controllerContainer.containerId) + return checkNotNull(controllerManager?.activeController) + } + + override fun restoreState(state: Bundle) { + model.flowCoordinator.restoreState(state) + } + + private fun scheduleNavAction(action: () -> Unit) { + navActionsScheduler.schedule(viewController.key.value, action) + } +} + +@Suppress("FunctionName") +fun , S : FlowStep, Out : IOData.Output> FlowViewController.ModelBinding( + model: M, + containerId: Int = R.id.container, + onStepUpdated: ((step: S) -> Unit)? = null +): FlowModelBinding { + return FlowModelBindingImpl(containerId, this, model, onStepUpdated) +} \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/modal/ModalCoordinator.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/modal/ModalCoordinator.kt new file mode 100644 index 0000000..8f4cf73 --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/modal/ModalCoordinator.kt @@ -0,0 +1,132 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.vc.modal + +import android.os.Bundle +import android.os.Parcelable +import com.revolut.kompot.common.IOData +import com.revolut.kompot.common.ModalDestination +import com.revolut.kompot.common.handleNavigationEvent +import com.revolut.kompot.navigable.Controller +import com.revolut.kompot.navigable.ControllerModel +import com.revolut.kompot.navigable.SavedStateOwner +import com.revolut.kompot.navigable.flow.FlowStep +import kotlinx.parcelize.Parcelize + +class ModalCoordinator( + private val hostModel: ControllerModel, + private val controllersFactory: ModalCoordinator.(Step) -> Controller +) { + + private var pendingBundle: Bundle? = null + private val modalsStack = mutableListOf>() + + internal fun openModal(step: Step, style: ModalDestination.Style) { + val controller = controllersFactory(step) + hostModel.eventsDispatcher.handleNavigationEvent( + destination = ModalDestination.CallbackController( + controller = controller, + style = style, + ) + ) + registerModalStackEntry(controller, step) + } + + fun performCreate() { + pendingBundle?.let(::restoreModalsStack) + pendingBundle = null + } + + fun saveState(outBundle: Bundle) { + if (modalsStack.isEmpty()) return + + val stackSnapshot = modalsStack.map { entry -> + val controllerState = Bundle() + (entry.controller as? SavedStateOwner)?.saveState(controllerState) + ModalStackEntrySnapshot( + index = entry.index, + step = entry.step, + controllerState = controllerState + ) + } + outBundle.putParcelableArrayList(MODALS_STACK_ARG, ArrayList(stackSnapshot)) + } + + fun restoreState(bundle: Bundle) { + pendingBundle = bundle + } + + private fun restoreModalsStack(bundle: Bundle) { + val modalsStack: List> = bundle.getParcelableArrayList(MODALS_STACK_ARG) ?: return + + modalsStack.forEach { entry -> + val controller = controllersFactory(entry.step) + val controllerState = entry.controllerState + if (controller is SavedStateOwner) { + controller.doOnCreate { controller.restoreState(controllerState) } + } + val style = ModalDestination.Style.POPUP + hostModel.eventsDispatcher.handleEvent( + event = ModalRestorationRequest( + index = entry.index, + modalController = controller, + style = style, + ) + ) + registerModalStackEntry(controller, entry.step) + } + } + + private fun registerModalStackEntry(controller: Controller, step: Step) { + val modalStackEntry = ModalStackEntry( + index = modalsIndex++, + step = step, + controller = controller, + ) + controller.doOnCreate { + modalsStack.add(modalStackEntry) + } + controller.doOnDestroy { modalsStack.remove(modalStackEntry) } + } + + private class ModalStackEntry( + val index: Int, + val step: Step, + val controller: Controller, + ) + + @Parcelize + private class ModalStackEntrySnapshot( + val index: Int, + val step: Step, + val controllerState: Bundle, + ) : Parcelable + + companion object { + private const val MODALS_STACK_ARG = "MODALS_STACK_ARG" + private var modalsIndex = 0 + } +} + +fun T.ModalCoordinator( + controllersFactory: ModalCoordinator.(S) -> Controller, +): ModalCoordinator where T : ModalHostViewModel, + T : ControllerModel = + ModalCoordinator( + hostModel = this, + controllersFactory = controllersFactory + ) diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/modal/ModalHostBindingImpl.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/modal/ModalHostBindingImpl.kt new file mode 100644 index 0000000..bee8b8e --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/modal/ModalHostBindingImpl.kt @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.vc.modal + +import android.os.Bundle +import com.revolut.kompot.common.IOData +import com.revolut.kompot.navigable.flow.FlowStep +import com.revolut.kompot.navigable.vc.binding.ModelBinding + +interface ModalHostBinding : ModelBinding + +internal class ModalHostBindingImpl, S : FlowStep, Out : IOData.Output>( + private val controller: ModalHostController, + val model: M, +) : ModalHostBinding { + + override fun onCreate() { + model.modalCoordinator.performCreate() + } + + override fun saveState(outState: Bundle) { + model.modalCoordinator.saveState(outState) + } + override fun restoreState(state: Bundle) { + model.modalCoordinator.restoreState(state) + } +} + +@Suppress("FunctionName") +fun , S : FlowStep, Out : IOData.Output> ModalHostController.ModelBinding( + model: M, +): ModalHostBinding = ModalHostBindingImpl(this, model) \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/modal/ModalHostContract.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/modal/ModalHostContract.kt new file mode 100644 index 0000000..a97940b --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/modal/ModalHostContract.kt @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.vc.modal + +import com.revolut.kompot.common.Event +import com.revolut.kompot.common.IOData +import com.revolut.kompot.common.ModalDestination +import com.revolut.kompot.navigable.Controller +import com.revolut.kompot.navigable.flow.FlowStep +import com.revolut.kompot.navigable.vc.ViewControllerApi +import com.revolut.kompot.navigable.vc.binding.ViewControllerModelApi + +interface ModalHostController : ViewControllerApi { + override val modelBinding: ModalHostBinding +} + +interface ModalHostViewModel : ViewControllerModelApi { + val modalCoordinator: ModalCoordinator +} + +internal data class ModalRestorationRequest( + val index: Int, + val modalController: Controller, + val style: ModalDestination.Style, +) : Event() \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/parent/ParentControllerModelBindingDelegate.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/parent/ParentControllerModelBindingDelegate.kt new file mode 100644 index 0000000..2376541 --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/parent/ParentControllerModelBindingDelegate.kt @@ -0,0 +1,129 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.vc.parent + +import android.content.Intent +import android.os.Bundle +import com.revolut.kompot.navigable.flow.ControllerManagersProvider +import com.revolut.kompot.navigable.vc.ViewController +import com.revolut.kompot.navigable.vc.ViewControllerApi +import com.revolut.kompot.navigable.vc.binding.ModelBinding + +internal class ParentControllerModelBindingDelegate( + private val childControllerManagersProvider: ControllerManagersProvider, + private val controller: ViewControllerApi, +) : ModelBinding { + + private val viewController: ViewController<*> get() = controller as ViewController<*> + + override fun onDestroy() { + childControllerManagersProvider.all.forEach { manager -> manager.onDestroy() } + } + + override fun handleBack(defaultHandler: () -> Boolean): Boolean { + for (manager in childControllerManagersProvider.all.asReversed()) { + if (manager.handleBack()) { + return true + } + } + + return defaultHandler() + } + + override fun onShow() { + val childManagers = childControllerManagersProvider.all.asReversed() + for (manager in childManagers) { + if (manager.activeController != null) { + manager.onAttach() + if (manager.modal) break + } + } + } + + override fun onHide() { + childControllerManagersProvider.all.asReversed().forEach { manager -> manager.onDetach() } + } + + override fun onTransitionStart(enter: Boolean) { + childControllerManagersProvider.all.forEach { manager -> + manager.activeController?.onTransitionStart(enter) + } + } + + override fun onTransitionEnd(enter: Boolean) { + childControllerManagersProvider.all.forEach { manager -> + manager.activeController?.onTransitionEnd(enter) + } + } + + override fun onTransitionCanceled() { + childControllerManagersProvider.all.forEach { manager -> + manager.activeController?.onTransitionCanceled() + } + } + + override fun onHostPaused() { + childControllerManagersProvider.all.forEach { manager -> manager.onHostPaused() } + } + + override fun onHostResumed() { + childControllerManagersProvider.all.forEach { manager -> manager.onHostResumed() } + } + + override fun onHostStarted() { + childControllerManagersProvider.all.forEach { manager -> manager.onHostStarted() } + } + + override fun onHostStopped() { + childControllerManagersProvider.all.forEach { manager -> manager.onHostStopped() } + } + + override fun onParentManagerCleared() { + childControllerManagersProvider.all.asReversed().forEach { + it.activeController?.onParentManagerCleared() + } + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + viewController.doOnAttach { + childControllerManagersProvider.all.forEach { manager -> + manager.onActivityResult( + requestCode, + resultCode, + data + ) + } + } + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + childControllerManagersProvider.all.forEach { manager -> + manager.onRequestPermissionsResult( + requestCode, + permissions, + grantResults + ) + } + } + + override fun saveState(outState: Bundle) = Unit + override fun restoreState(state: Bundle) = Unit +} \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/scroller/ScrollerContract.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/scroller/ScrollerContract.kt new file mode 100644 index 0000000..a8312e3 --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/scroller/ScrollerContract.kt @@ -0,0 +1,66 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.vc.scroller + +import android.os.Parcelable +import com.revolut.kompot.common.IOData +import com.revolut.kompot.di.flow.ParentFlow +import com.revolut.kompot.navigable.vc.ViewControllerApi +import com.revolut.kompot.navigable.vc.binding.ModelBinding +import com.revolut.kompot.navigable.vc.binding.ViewControllerModelApi + +interface ScrollerModelBinding : ModelBinding + +interface ScrollerViewController : ViewControllerApi, ParentFlow { + override val modelBinding: ScrollerModelBinding + override val hasBackStack: Boolean get() = false + + fun onCompletelyVisibleItemChanged(scrollerItem: S) = Unit +} + +interface ScrollerViewModel : ViewControllerModelApi { + val scrollerCoordinator: ScrollerCoordinator +} + +data class ScrollerItems( + val items: List, + val selectedItemId: String = requireNotNull(items.firstOrNull()?.id) { "Non empty list should be provided for items" } +) { + + companion object { + operator fun invoke(vararg items: S): ScrollerItems = ScrollerItems(items = items.toList()) + } +} + +data class ScrollerItemsUpdate( + val items: List, + val selectedItemId: String?, + val smoothScroll: Boolean, +) + +interface ScrollerItem : Parcelable { + val id: String +} + +interface FixedIdScrollerItem : ScrollerItem { + + override val id: String get() = this.javaClass.name +} + +enum class ScrollMode { + HORIZONTAL, VERTICAL, PAGER +} \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/scroller/ScrollerCoordinator.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/scroller/ScrollerCoordinator.kt new file mode 100644 index 0000000..9b4609e --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/scroller/ScrollerCoordinator.kt @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.vc.scroller + +import androidx.annotation.VisibleForTesting +import androidx.annotation.VisibleForTesting.PACKAGE_PRIVATE +import com.revolut.kompot.common.IOData +import com.revolut.kompot.navigable.Controller +import com.revolut.kompot.navigable.ControllerModel +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow + +open class ScrollerCoordinator( + initialItems: ScrollerItems, + private val controllersFactory: ScrollerCoordinator.(S) -> Controller, +) { + + private val _itemUpdates = MutableStateFlow( + ScrollerItemsUpdate( + items = initialItems.items, + selectedItemId = initialItems.selectedItemId, + smoothScroll = false, + ) + ) + private val _scrollerCommands = MutableSharedFlow( + replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + private val latestItemUpdate get() = _itemUpdates.value + + fun itemUpdatesStream(): Flow> = _itemUpdates + + @VisibleForTesting(otherwise = PACKAGE_PRIVATE) + fun getController(scrollerItem: S): Controller = controllersFactory(scrollerItem) + + internal fun scrollerCommandsStream(): Flow = _scrollerCommands + + internal fun updateItems( + selectedItemId: String? = null, + items: List = latestItemUpdate.items, + smoothScroll: Boolean = true, + ) { + _itemUpdates.tryEmit( + ScrollerItemsUpdate( + items = items, + selectedItemId = selectedItemId, + smoothScroll = smoothScroll + ) + ) + } + + internal fun quit() { + _scrollerCommands.tryEmit(ScrollerCommand.Quit) + } +} + +@Suppress("FunctionName") +fun T.ScrollerCoordinator( + initialItems: ScrollerItems, + controllersFactory: ScrollerCoordinator.(S) -> Controller, +): ScrollerCoordinator where T : ScrollerViewModel, + T : ControllerModel = + com.revolut.kompot.navigable.vc.scroller.ScrollerCoordinator( + initialItems = initialItems, + controllersFactory = controllersFactory, + ) + +sealed interface ScrollerCommand { + object Quit : ScrollerCommand +} \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/scroller/ScrollerModelBindingImpl.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/scroller/ScrollerModelBindingImpl.kt new file mode 100644 index 0000000..50d287f --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/scroller/ScrollerModelBindingImpl.kt @@ -0,0 +1,206 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.vc.scroller + +import android.content.Context +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.PagerSnapHelper +import androidx.recyclerview.widget.RecyclerView +import com.revolut.kompot.R +import com.revolut.kompot.common.IOData +import com.revolut.kompot.coroutines.Direct +import com.revolut.kompot.navigable.Controller +import com.revolut.kompot.navigable.findRootFlow +import com.revolut.kompot.navigable.flow.ensureAvailability +import com.revolut.kompot.navigable.flow.quitFlow +import com.revolut.kompot.navigable.flow.scroller.ScrollerFlowControllersAdapter +import com.revolut.kompot.navigable.root.NavActionsScheduler +import com.revolut.kompot.navigable.utils.Preconditions +import com.revolut.kompot.navigable.vc.ViewController +import com.revolut.kompot.navigable.vc.binding.ModelBinding +import com.revolut.kompot.navigable.vc.parent.ParentControllerModelBindingDelegate +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +@OptIn(ExperimentalCoroutinesApi::class) +internal class ScrollerModelBindingImpl, S : ScrollerItem, Out : IOData.Output>( + private val controller: ScrollerViewController, + private val itemContainerLayoutId: Int, + private val scrollMode: ScrollMode, + val model: M, + private val recyclerViewId: Int, + private val controllersAdapter: ScrollerFlowControllersAdapter = ScrollerFlowControllersAdapter( + layoutContainerId = itemContainerLayoutId, + parentController = controller as Controller, + controllersCache = controller.controllersCache, + controllersFactory = model.scrollerCoordinator::getController, + ), + private val parentControllerModelBindingDelegate: ParentControllerModelBindingDelegate = ParentControllerModelBindingDelegate( + childControllerManagersProvider = controllersAdapter, + controller = controller, + ) +) : ScrollerModelBinding, ModelBinding by parentControllerModelBindingDelegate { + + private val viewController: ViewController<*> get() = controller as ViewController<*> + + private val navActionsScheduler: NavActionsScheduler + get() = viewController.findRootFlow().navActionsScheduler + + private val layoutManager by lazy(LazyThreadSafetyMode.NONE) { + val orientation = when (scrollMode) { + ScrollMode.VERTICAL -> RecyclerView.VERTICAL + ScrollMode.HORIZONTAL, + ScrollMode.PAGER -> RecyclerView.HORIZONTAL + } + LinearLayoutManagerImpl(viewController.activity, orientation, false, 1) + } + + private var _recyclerView: RecyclerView? = null + private val recyclerView get() = checkNotNull(_recyclerView) + + override fun onCreate() { + setupScrollerRecycler() + bindCoordinator() + } + + override fun onDestroy() { + parentControllerModelBindingDelegate.onDestroy() + controllersAdapter.updateCache(controllersAdapter.currentList, emptyList()) + } + + private fun bindCoordinator() { + model.scrollerCoordinator.scrollerCommandsStream() + .onEach { command -> + processScrollerCommand(command) + } + .flowOn(Dispatchers.Direct) + .launchIn(viewController.createdScope) + model.scrollerCoordinator.itemUpdatesStream() + .onEach { itemsUpdate -> submitItemsUpdate(itemsUpdate) } + .launchIn(viewController.createdScope) + } + + private fun setupScrollerRecycler() { + _recyclerView = viewController.view.findViewById(recyclerViewId) + ?: throw IllegalStateException("${this::class.java.simpleName}: recyclerViewId is not valid. Forgot to override?") + recyclerView.apply { + layoutManager = this@ScrollerModelBindingImpl.layoutManager + adapter = controllersAdapter + if (scrollMode == ScrollMode.PAGER) { + setScrollingTouchSlop(RecyclerView.TOUCH_SLOP_PAGING) + PagerSnapHelper().attachToRecyclerView(this) + } + + addOnScrollListener(ScrollStateListener()) + } + } + + private fun submitItemsUpdate(itemsUpdate: ScrollerItemsUpdate) { + if (controllersAdapter.currentList != itemsUpdate.items) { + controllersAdapter.updateCache(controllersAdapter.currentList, itemsUpdate.items) + controllersAdapter.submitList(itemsUpdate.items) { + scrollToSelectedItem(itemsUpdate) + } + } else { + scrollToSelectedItem(itemsUpdate) + } + } + + private fun scrollToSelectedItem(itemsUpdate: ScrollerItemsUpdate) { + val itemId = itemsUpdate.selectedItemId ?: return + + val position = controllersAdapter.currentList.indexOfFirst { it.id == itemId } + if (position in 0..layoutManager.itemCount) { + if (itemsUpdate.smoothScroll) { + //We need to queue the smooth scrolls until everything is laid out + //other wise can end up in weird states + recyclerView.post { + recyclerView.smoothScrollToPosition(position) + } + } else { + //because we are queuing smooth ones, we have to queue normal scrolls as well + //so we don't have jumps in-between + recyclerView.post { + layoutManager.scrollToPosition(position) + } + } + } + } + + private fun processScrollerCommand(command: ScrollerCommand) { + when (command) { + is ScrollerCommand.Quit -> { + if (!navActionsScheduler.ensureAvailability(command)) return + Preconditions.requireMainThread("ScrollerCoordinator#quit()") + viewController.quitFlow(navActionsScheduler) + } + } + } + + private inner class ScrollStateListener : RecyclerView.OnScrollListener() { + private var latestCompletelyVisiblePosition = -1 + + override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { + if (newState == RecyclerView.SCROLL_STATE_IDLE) { + val position = this@ScrollerModelBindingImpl.layoutManager.findFirstCompletelyVisibleItemPosition() + if (latestCompletelyVisiblePosition != position && position in (0..controllersAdapter.currentList.size)) { + latestCompletelyVisiblePosition = position + controller.onCompletelyVisibleItemChanged(controllersAdapter.currentList[position]) + } + } + } + } + + private class LinearLayoutManagerImpl( + context: Context, + @RecyclerView.Orientation orientation: Int, + reverseLayout: Boolean, + private val preloadItems: Int, + ): LinearLayoutManager(context, orientation, reverseLayout) { + + override fun calculateExtraLayoutSpace(state: RecyclerView.State, extraLayoutSpace: IntArray) { + val pageSize = getPageSize() + val offscreenSpace = pageSize * preloadItems + extraLayoutSpace[0] = offscreenSpace + extraLayoutSpace[1] = offscreenSpace + } + + private fun getPageSize(): Int { + return if (orientation == RecyclerView.HORIZONTAL) width - paddingLeft - paddingRight else height - paddingTop - paddingBottom + } + + } +} + +@Suppress("FunctionName") +fun , S : ScrollerItem, Out : IOData.Output> ScrollerViewController.ModelBinding( + itemContainerLayoutId: Int = R.layout.flow_scroller_item_container, + scrollMode: ScrollMode = ScrollMode.PAGER, + recyclerViewId: Int = R.id.recyclerView, + model: M, +): ScrollerModelBinding { + return ScrollerModelBindingImpl( + controller = this, + itemContainerLayoutId = itemContainerLayoutId, + scrollMode = scrollMode, + model = model, + recyclerViewId = recyclerViewId, + ) +} \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/test/ModelStateTestExt.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/test/ModelStateTestExt.kt new file mode 100644 index 0000000..126ed3f --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/test/ModelStateTestExt.kt @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.vc.test + +import androidx.annotation.VisibleForTesting +import com.revolut.kompot.common.IOData +import com.revolut.kompot.navigable.vc.ViewControllerModel +import com.revolut.kompot.navigable.vc.ui.States +import com.revolut.kompot.navigable.vc.ui.UIStatesModel +import kotlinx.coroutines.flow.Flow + +@VisibleForTesting +fun T.testDomainStateStream() + : Flow where T : UIStatesModel, + T : ViewControllerModel = + this.state.domainStateStream() \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/ui/ModelStatesHolder.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/ui/ModelStatesHolder.kt new file mode 100644 index 0000000..baeef03 --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/ui/ModelStatesHolder.kt @@ -0,0 +1,123 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.vc.ui + +import android.os.Bundle +import android.os.Parcelable +import com.revolut.kompot.common.IOData +import com.revolut.kompot.navigable.vc.common.PersistableState +import com.revolut.kompot.navigable.vc.common.PersistableStorageState +import kotlinx.coroutines.CoroutineDispatcher + +internal class UIStatesImpl( + private val initialState: Domain, + stateMapper: States.Mapper, + mapStatesInBackground: Boolean, + private val saveStateDelegate: SaveStateDelegate?, +) : ModelState(initialState, stateMapper, mapStatesInBackground), PersistableState { + + override fun saveState(bundle: Bundle) { + saveStateDelegate?.getRetainedState(current)?.let { retainedState -> + bundle.putParcelable(DOMAIN_STATE_SNAPSHOT_KEY, retainedState) + } + } + + override fun restoreState(bundle: Bundle) { + bundle.classLoader = javaClass.classLoader + bundle.getParcelable(DOMAIN_STATE_SNAPSHOT_KEY)?.let { retainedState -> + val restoredDomainState = requireNotNull(saveStateDelegate) + .restoreDomainStateInternal(initialState, retainedState) + + update { restoredDomainState } + } + } + + private companion object { + const val DOMAIN_STATE_SNAPSHOT_KEY = "DomainStateSnapshotKey" + } +} + +internal class PersistentUIStatesImpl( + private val key: PersistentModelStateKey, + private val restoredStateReducer: (Domain, Domain) -> Domain, + private val initialState: Domain, + stateMapper: States.Mapper, + mapStatesInBackground: Boolean, +) : ModelState(initialState, stateMapper, mapStatesInBackground), PersistableStorageState { + + override fun restoreStateFromStorage(stateStorage: PersistentModelStateStorage) { + stateStorage.get(key)?.let { restoredState -> + update { + restoredStateReducer(initialState, restoredState) + } + } + } + + override fun saveStateToStorage(stateStorage: PersistentModelStateStorage) { + stateStorage.put(key, current) + } +} + +abstract class SaveStateDelegate { + + abstract fun getRetainedState(currentState: T): R? + + abstract fun restoreDomainState(initialState: T, retainedState: R): T + + @Suppress("UNCHECKED_CAST") + internal fun restoreDomainStateInternal(initialState: T, retainedState: Any): T = + restoreDomainState(initialState, retainedState as R) + +} + +@Suppress("FunctionName") +fun UIStatesModel.ModelState( + initialState: Domain, + stateMapper: States.Mapper, + saveStateDelegate: SaveStateDelegate? = null, + mapStatesInBackground: Boolean = false, +): ModelState = UIStatesImpl( + stateMapper = stateMapper, + initialState = initialState, + mapStatesInBackground = mapStatesInBackground, + saveStateDelegate = saveStateDelegate, +) + +@Suppress("FunctionName") +fun UIStatesModel.PersistentModelState( + key: PersistentModelStateKey, + initialState: Domain, + stateMapper: States.Mapper, + mapStatesInBackground: Boolean = false, + restoredStateReducer: (Domain, Domain) -> Domain = { _, restored -> restored }, +): ModelState = PersistentUIStatesImpl( + key = key, + stateMapper = stateMapper, + initialState = initialState, + mapStatesInBackground = mapStatesInBackground, + restoredStateReducer = restoredStateReducer, +) + +@JvmInline +value class PersistentModelStateKey(val keyValue: String) + +interface PersistentModelStateStorage { + fun get(key: PersistentModelStateKey): T? + fun remove(key: PersistentModelStateKey) + fun put(key: PersistentModelStateKey, state: States.PersistentDomain) + suspend fun prefetchAll() +} \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/ui/UIStateModelBinding.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/ui/UIStateModelBinding.kt new file mode 100644 index 0000000..cf43ae0 --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/ui/UIStateModelBinding.kt @@ -0,0 +1,148 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.vc.ui + +import android.os.Bundle +import com.revolut.kompot.common.IOData +import com.revolut.kompot.common.LifecycleEvent +import com.revolut.kompot.navigable.Controller +import com.revolut.kompot.navigable.vc.ViewController +import com.revolut.kompot.navigable.vc.binding.ModelBinding +import com.revolut.kompot.navigable.vc.common.PersistableState +import com.revolut.kompot.navigable.vc.common.PersistableStorageState +import com.revolut.kompot.utils.DEFAULT_EXTRA_BUFFER_CAPACITY +import com.revolut.kompot.utils.debounceButEmitFirst +import com.revolut.kompot.utils.withPrevious +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.take + +typealias DebounceStreamProvider = () -> Flow + +interface UIStatesModelBinding : ModelBinding + +internal class UIStateModelBindingImpl, D : States.Domain, UI : States.UI, Out : IOData.Output>( + private val controller: UIStatesController, + val model: M, + private val debounceStreamProvider: DebounceStreamProvider?, +) : UIStatesModelBinding { + + private val viewController: ViewController<*> get() = controller as ViewController<*> + private val bindScreenFlow = MutableSharedFlow( + replay = 1, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + private val transitionEndFlow = MutableSharedFlow(extraBufferCapacity = DEFAULT_EXTRA_BUFFER_CAPACITY) + var doBeforeRender: ((UI) -> Unit)? = null + + override fun onCreate() { + model.state.onLifecycleEvent(LifecycleEvent.CREATED) + startCollectingUIState() + } + + override fun onShow() { + model.state.onLifecycleEvent(LifecycleEvent.SHOWN) + bindUIState() + } + + override fun onHide() { + model.state.onLifecycleEvent(LifecycleEvent.HIDDEN) + } + + override fun onDestroy() { + model.state.onLifecycleEvent(LifecycleEvent.FINISHED) + } + + private fun startCollectingUIState() { + val debouncedUIStateStream = debounceStreamProvider?.invoke()?.let { flow -> + combine( + flow.onStart { emit(Unit) }, + model.state.uiStates() + ) { _, uiState -> + uiState + }.debounceButEmitFirst(300) + } + + (debouncedUIStateStream ?: model.state.uiStates()) + .onEach(bindScreenFlow::emit) + .launchIn(viewController.createdScope) + } + + @OptIn(ExperimentalCoroutinesApi::class) + private fun bindUIState() { + bindScreenFlow + .flatMapLatest { state -> + if (viewController.activeTransition == Controller.ActiveTransition.NONE) { + flowOf(state) + } else { + transitionEndFlow + .filter { enter -> enter } + .take(1) + .map { state } + } + } + .withPrevious() + .onEach { (prevState, state) -> + doBeforeRender?.invoke(state) + controller.render(state, prevState?.let(state::calculatePayload)) + } + .launchIn(viewController.attachedScope) + } + + override fun onTransitionEnd(enter: Boolean) { + transitionEndFlow.tryEmit(enter) + } + + override fun saveState(outState: Bundle) { + (model.state as? PersistableState)?.saveState(outState) + } + + override fun restoreState(state: Bundle) { + (model.state as? PersistableState)?.restoreState(state) + } + + override fun restoreStateFromStorage(stateStorage: PersistentModelStateStorage) { + (model.state as? PersistableStorageState)?.restoreStateFromStorage(stateStorage) + } + + override fun saveStateToStorage(stateStorage: PersistentModelStateStorage) { + (model.state as? PersistableStorageState)?.saveStateToStorage(stateStorage) + } +} + +@Suppress("FunctionName") +fun , D : States.Domain, UI : States.UI, Out : IOData.Output> UIStatesController.ModelBinding( + model: M, + debounceStreamProvider: DebounceStreamProvider? = null, +): UIStatesModelBinding { + return UIStateModelBindingImpl( + controller = this, + model = model, + debounceStreamProvider = debounceStreamProvider, + ) +} \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/ui/UIStatesContract.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/ui/UIStatesContract.kt new file mode 100644 index 0000000..1cd82cc --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/ui/UIStatesContract.kt @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.vc.ui + +import com.revolut.kompot.common.IOData +import com.revolut.kompot.common.LifecycleEvent +import com.revolut.kompot.navigable.vc.ViewControllerApi +import com.revolut.kompot.navigable.vc.binding.ViewControllerModelApi +import com.revolut.kompot.navigable.vc.common.StateHolder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapConcat +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import java.util.concurrent.atomic.AtomicBoolean + +interface UIStatesController : ViewControllerApi { + fun render(uiState: UI, payload: Any?) + override val modelBinding: UIStatesModelBinding +} + +interface UIStatesModel : + ViewControllerModelApi { + val state: ModelState +} + +abstract class ModelState( + initialState: Domain, + private val stateMapper: States.Mapper, + private val mapStateInBackground: Boolean, +) : StateHolder(initialState) { + + private val firstEmmitHappened = AtomicBoolean(false) + + @OptIn(FlowPreview::class) + fun uiStates(): Flow = + if (mapStateInBackground) { + statesStream() + .flatMapConcat { + flowOf(it) + .map(stateMapper::mapState) + .run { + if (firstEmmitHappened.getAndSet(true)) { + flowOn(Dispatchers.Default) + } else { + this + } + } + } + } else { + statesStream() + .map(stateMapper::mapState) + } + + internal fun domainStateStream(): Flow = statesStream() + + internal fun onLifecycleEvent(event: LifecycleEvent) { + if (event == LifecycleEvent.SHOWN) { + firstEmmitHappened.set(false) + } + } +} \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/ui/ViewStates.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/ui/ViewStates.kt new file mode 100644 index 0000000..779bcd2 --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/ui/ViewStates.kt @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.vc.ui + +import android.os.Parcelable +import com.revolut.recyclerkit.delegates.ListItem + +interface States { + interface Domain + + interface PersistentDomain: Domain, Parcelable + + interface UI { + fun calculatePayload(oldState: UI): UIPayload? = null + } + + interface UIPayload + + interface UIList : UI { + val items: List + } + + object EmptyDomain : Domain + + object EmptyUI : UI + + object EmptyUIList : UIList { + override val items: List = emptyList() + } + + fun interface Mapper { + fun mapState(domainState: IN): OUT + } +} \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/ui/list/UIListStatesController.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/ui/list/UIListStatesController.kt new file mode 100644 index 0000000..2d933d8 --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/ui/list/UIListStatesController.kt @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.vc.ui.list + +import com.revolut.kompot.navigable.vc.ui.States +import com.revolut.kompot.navigable.vc.ui.UIStatesController + +interface UIListStatesController : UIStatesController { + override val modelBinding: UIListStatesModelBinding + override fun render(uiState: UI, payload: Any?) = Unit +} \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/ui/list/UIListStatesModel.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/ui/list/UIListStatesModel.kt new file mode 100644 index 0000000..29c471e --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/ui/list/UIListStatesModel.kt @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.vc.ui.list + +import com.revolut.kompot.common.IOData +import com.revolut.kompot.navigable.vc.ui.States +import com.revolut.kompot.navigable.vc.ui.UIStatesModel + +interface UIListStatesModel : UIStatesModel \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/ui/list/UIListStatesModelBinding.kt b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/ui/list/UIListStatesModelBinding.kt new file mode 100644 index 0000000..7b9c0b4 --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/navigable/vc/ui/list/UIListStatesModelBinding.kt @@ -0,0 +1,124 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.vc.ui.list + +import android.content.Context +import androidx.recyclerview.widget.DefaultItemAnimator +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.LayoutManager +import com.revolut.decorations.dividers.DelegatesDividerItemDecoration +import com.revolut.decorations.overlay.DelegatesOverlayItemDecoration +import com.revolut.kompot.R +import com.revolut.kompot.common.IOData +import com.revolut.kompot.navigable.screen.BaseRecyclerViewLayoutManager +import com.revolut.kompot.navigable.vc.ViewController +import com.revolut.kompot.navigable.vc.ViewControllerApi +import com.revolut.kompot.navigable.vc.ui.DebounceStreamProvider +import com.revolut.kompot.navigable.vc.ui.States +import com.revolut.kompot.navigable.vc.ui.UIStateModelBindingImpl +import com.revolut.kompot.navigable.vc.ui.UIStatesModelBinding +import com.revolut.recyclerkit.delegates.DelegatesManager +import com.revolut.recyclerkit.delegates.DiffAdapter +import com.revolut.recyclerkit.delegates.RecyclerViewDelegate + +internal typealias LayoutManagerProvider = (Context) -> LayoutManager + +interface UIListStatesModelBinding : UIStatesModelBinding { + val recyclerView: RecyclerView + val layoutManager: LayoutManager +} + +internal class UIListStatesModelBindingImpl, D : States.Domain, UI : States.UIList, Out : IOData.Output>( + private val controller: UIListStatesController, + private val model: M, + private val debounceStreamProvider: DebounceStreamProvider?, + private val recyclerViewId: Int, + private val layoutManagerProvider: LayoutManagerProvider, + private val delegates: List>, + private val listAdapter: DiffAdapter, + private val uiStatesModelBinding: UIStateModelBindingImpl = UIStateModelBindingImpl( + controller, + model, + debounceStreamProvider + ) +) : UIListStatesModelBinding, UIStatesModelBinding by uiStatesModelBinding { + + private val viewController: ViewController<*> get() = controller as ViewController<*> + private var _recyclerView: RecyclerView? = null + + override val layoutManager: LayoutManager by lazy(LazyThreadSafetyMode.NONE) { + layoutManagerProvider(viewController.activity) + } + override val recyclerView: RecyclerView by lazy(LazyThreadSafetyMode.NONE) { + requireNotNull(_recyclerView) + } + + override fun onCreate() { + uiStatesModelBinding.onCreate() + + _recyclerView = viewController.view.findViewById(recyclerViewId) + ?: throw IllegalStateException("${this::class.java.simpleName}: recyclerViewId is not valid. Forgot to override?") + + recyclerView.apply { + adapter = listAdapter.also { adapter -> + adapter.delegatesManager.addDelegates(delegates) + } + layoutManager = this@UIListStatesModelBindingImpl.layoutManager + itemAnimator = DefaultItemAnimator().apply { + supportsChangeAnimations = false + } + addItemDecoration(DelegatesDividerItemDecoration()) + addItemDecoration(DelegatesOverlayItemDecoration()) + } + + uiStatesModelBinding.doBeforeRender = { listAdapter.setItems(it.items) } + } +} + +@Suppress("FunctionName") +fun , D : States.Domain, UI : States.UIList, Out : IOData.Output> UIListStatesController.ModelBinding( + model: M, + delegates: List>, + recyclerViewId: Int = R.id.recyclerView, + layoutManagerProvider: LayoutManagerProvider = { DefaultLayoutManager(this) }, + listAdapter: DiffAdapter = DefaultAdapter(), + debounceStreamProvider: DebounceStreamProvider? = null, +): UIListStatesModelBinding { + return UIListStatesModelBindingImpl( + controller = this, + model = model, + debounceStreamProvider = debounceStreamProvider, + recyclerViewId = recyclerViewId, + layoutManagerProvider = layoutManagerProvider, + delegates = delegates, + listAdapter = listAdapter + ) +} + +@Suppress("FunctionName") +internal fun DefaultLayoutManager(controller: ViewControllerApi): LayoutManager = + BaseRecyclerViewLayoutManager((controller as ViewController<*>).activity).apply { + enablePredictiveItemAnimations = true + } + +@Suppress("FunctionName") +internal fun DefaultAdapter(): DiffAdapter = + DiffAdapter( + delegatesManager = DelegatesManager(emptyList()), + async = false, + autoScrollToTop = false, + ) \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/utils/Events.kt b/kompot/src/main/kotlin/com/revolut/kompot/utils/Events.kt new file mode 100644 index 0000000..5a84766 --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/utils/Events.kt @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.utils + +import com.revolut.kompot.common.Event +import com.revolut.kompot.common.EventResult + +internal class PostponedRestorationTriggeredEvent : Event() + +internal object EventHandledResult : EventResult \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/utils/Exceptions.kt b/kompot/src/main/kotlin/com/revolut/kompot/utils/Exceptions.kt new file mode 100644 index 0000000..12b6c0f --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/utils/Exceptions.kt @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.utils + +class KompotIllegalLifecycleException(override val message: String?) : IllegalStateException() \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/utils/FlowExt.kt b/kompot/src/main/kotlin/com/revolut/kompot/utils/FlowExt.kt index 4303576..afa2287 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/utils/FlowExt.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/utils/FlowExt.kt @@ -19,7 +19,6 @@ package com.revolut.kompot.utils import com.revolut.kompot.coroutines.AppCoroutineScope import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -32,7 +31,6 @@ import kotlin.coroutines.EmptyCoroutineContext @Suppress("FunctionName") fun ControllerScope(context: CoroutineContext = EmptyCoroutineContext): CoroutineScope = AppCoroutineScope(Dispatchers.Main.immediate + context) -@OptIn(FlowPreview::class) internal fun Flow.debounceButEmitFirst(timeoutMillis: Long): Flow = debounce(object : (T) -> Long { val firstEmission = AtomicBoolean(true) diff --git a/kompot/src/main/kotlin/com/revolut/kompot/view/ControllerContainer.kt b/kompot/src/main/kotlin/com/revolut/kompot/view/ControllerContainer.kt index cebf6dd..46eb7bc 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/view/ControllerContainer.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/view/ControllerContainer.kt @@ -16,9 +16,42 @@ package com.revolut.kompot.view -import com.revolut.kompot.navigable.transition.TransitionCallbacks +import android.os.Parcelable +import android.util.SparseArray +import android.view.MotionEvent +import android.view.View +import android.view.WindowInsets +import kotlinx.parcelize.Parcelize -interface ControllerContainer : TransitionCallbacks { +interface ControllerContainer { var fitStatusBar: Boolean var fitNavigationBar: Boolean -} \ No newline at end of file + var containerId: String + val controllersTransitionActive: Boolean + val latestDispatchedInsets: WindowInsets? + + var insetsInterceptor: ((View, WindowInsets) -> WindowInsets)? + + fun handleViewAttachedToWindow(containerView: View) + fun handleDispatchApplyWindowInsets(controllerContainer: ControllerContainer, insets: WindowInsets): WindowInsets + fun handleDispatchTouchEvent(ev: MotionEvent): Boolean + + fun saveState(outState: SparseArray) = Unit + fun restoreState(state: SparseArray) = Unit + + fun allowSavedStateDispatch() + fun useSavedStateDispatchAllowance(): Boolean + + fun onControllersTransitionStart(indefinite: Boolean) + fun onControllersTransitionEnd(indefinite: Boolean) + fun onControllersTransitionCanceled(indefinite: Boolean) + + companion object { + const val MAIN_CONTAINER_ID = "MAIN_CONTAINER_ID" + const val MODAL_CONTAINER_ID = "MODAL_CONTAINER_ID" + const val NO_CONTAINER_ID = "NO_CONTAINER_ID" + } +} + +@Parcelize +internal object DummyParcelable : Parcelable \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/view/ControllerContainerConstraintLayout.kt b/kompot/src/main/kotlin/com/revolut/kompot/view/ControllerContainerConstraintLayout.kt index 2eb99f9..271aafc 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/view/ControllerContainerConstraintLayout.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/view/ControllerContainerConstraintLayout.kt @@ -18,53 +18,50 @@ package com.revolut.kompot.view import android.annotation.SuppressLint import android.content.Context +import android.os.Parcelable import android.util.AttributeSet +import android.util.SparseArray import android.view.MotionEvent import android.view.WindowInsets import androidx.constraintlayout.widget.ConstraintLayout -import com.revolut.kompot.R @SuppressLint("CustomViewStyleable") -class ControllerContainerConstraintLayout @JvmOverloads constructor( +open class ControllerContainerConstraintLayout @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 -) : ConstraintLayout(context, attrs, defStyleAttr), ControllerContainer { - private var transitionActive: Boolean = false - private var lastTransitionStartTime = 0L - - override var fitStatusBar = false - override var fitNavigationBar = true - - init { - val ta = context.obtainStyledAttributes(attrs, R.styleable.ControllerContainer) - fitStatusBar = ta.getBoolean(R.styleable.ControllerContainer_fitStatusBar, fitStatusBar) - fitNavigationBar = ta.getBoolean(R.styleable.ControllerContainer_fitNavigationBar, fitNavigationBar) - ta.recycle() - } - +) : ConstraintLayout(context, attrs, defStyleAttr), ControllerContainer by ControllerContainerDelegate(context, attrs) { override fun onAttachedToWindow() { - EdgeToEdgeViewDelegate.onViewAttachedToWindow(this) + handleViewAttachedToWindow(this) super.onAttachedToWindow() } override fun dispatchApplyWindowInsets(insets: WindowInsets): WindowInsets = - EdgeToEdgeViewDelegate.dispatchApplyWindowInsets(this, insets) + handleDispatchApplyWindowInsets(this, insets) + + override fun dispatchTouchEvent(ev: MotionEvent): Boolean = + handleDispatchTouchEvent(ev) || super.dispatchTouchEvent(ev) - override fun onInterceptTouchEvent(event: MotionEvent?): Boolean = transitionActive || (System.currentTimeMillis() - lastTransitionStartTime <= 200) || - super.onInterceptTouchEvent(event) + override fun saveState(outState: SparseArray) { + allowSavedStateDispatch() + dispatchSaveInstanceState(outState) + } - override fun onTransitionRunUp(enter: Boolean) { - lastTransitionStartTime = System.currentTimeMillis() + override fun restoreState(state: SparseArray) { + allowSavedStateDispatch() + dispatchRestoreInstanceState(state) } - override fun onTransitionStart(enter: Boolean) { - transitionActive = true - lastTransitionStartTime = System.currentTimeMillis() + override fun dispatchSaveInstanceState(container: SparseArray) { + if (useSavedStateDispatchAllowance()) { + super.dispatchSaveInstanceState(container) + } } - override fun onTransitionEnd(enter: Boolean) { - transitionActive = false + override fun dispatchRestoreInstanceState(container: SparseArray) { + if (useSavedStateDispatchAllowance()) { + super.dispatchRestoreInstanceState(container) + } } } \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/view/ControllerContainerCoordinatorLayout.kt b/kompot/src/main/kotlin/com/revolut/kompot/view/ControllerContainerCoordinatorLayout.kt index 2a496d1..4cb45c4 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/view/ControllerContainerCoordinatorLayout.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/view/ControllerContainerCoordinatorLayout.kt @@ -18,52 +18,50 @@ package com.revolut.kompot.view import android.annotation.SuppressLint import android.content.Context +import android.os.Parcelable import android.util.AttributeSet +import android.util.SparseArray import android.view.MotionEvent import android.view.WindowInsets import androidx.coordinatorlayout.widget.CoordinatorLayout -import com.revolut.kompot.R @SuppressLint("CustomViewStyleable") open class ControllerContainerCoordinatorLayout @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 -) : CoordinatorLayout(context, attrs, defStyleAttr), ControllerContainer { - private var transitionActive: Boolean = false - private var lastTransitionStartTime = 0L - - final override var fitStatusBar = false - final override var fitNavigationBar = true - - init { - val ta = context.obtainStyledAttributes(attrs, R.styleable.ControllerContainer) - fitStatusBar = ta.getBoolean(R.styleable.ControllerContainer_fitStatusBar, fitStatusBar) - fitNavigationBar = ta.getBoolean(R.styleable.ControllerContainer_fitNavigationBar, fitNavigationBar) - ta.recycle() - } +) : CoordinatorLayout(context, attrs, defStyleAttr), ControllerContainer by ControllerContainerDelegate(context, attrs) { override fun onAttachedToWindow() { - EdgeToEdgeViewDelegate.onViewAttachedToWindow(this) + handleViewAttachedToWindow(this) super.onAttachedToWindow() } override fun dispatchApplyWindowInsets(insets: WindowInsets): WindowInsets = - EdgeToEdgeViewDelegate.dispatchApplyWindowInsets(this, insets) + handleDispatchApplyWindowInsets(this, insets) - override fun onInterceptTouchEvent(event: MotionEvent?): Boolean = transitionActive || (System.currentTimeMillis() - lastTransitionStartTime <= 200) || - super.onInterceptTouchEvent(event) + override fun dispatchTouchEvent(ev: MotionEvent): Boolean = + handleDispatchTouchEvent(ev) || super.dispatchTouchEvent(ev) + + override fun saveState(outState: SparseArray) { + allowSavedStateDispatch() + dispatchSaveInstanceState(outState) + } - override fun onTransitionRunUp(enter: Boolean) { - lastTransitionStartTime = System.currentTimeMillis() + override fun restoreState(state: SparseArray) { + allowSavedStateDispatch() + dispatchRestoreInstanceState(state) } - override fun onTransitionStart(enter: Boolean) { - transitionActive = true - lastTransitionStartTime = System.currentTimeMillis() + override fun dispatchSaveInstanceState(container: SparseArray) { + if (useSavedStateDispatchAllowance()) { + super.dispatchSaveInstanceState(container) + } } - override fun onTransitionEnd(enter: Boolean) { - transitionActive = false + override fun dispatchRestoreInstanceState(container: SparseArray) { + if (useSavedStateDispatchAllowance()) { + super.dispatchRestoreInstanceState(container) + } } } \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/view/ControllerContainerDelegate.kt b/kompot/src/main/kotlin/com/revolut/kompot/view/ControllerContainerDelegate.kt new file mode 100644 index 0000000..f400611 --- /dev/null +++ b/kompot/src/main/kotlin/com/revolut/kompot/view/ControllerContainerDelegate.kt @@ -0,0 +1,160 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.view + +import android.content.Context +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.view.WindowInsets +import androidx.core.graphics.Insets +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.children +import androidx.core.view.updatePadding +import com.revolut.kompot.R + +class ControllerContainerDelegate(context: Context, attrs: AttributeSet?) : ControllerContainer { + + override var fitStatusBar = false + override var fitNavigationBar = true + override var containerId: String = ControllerContainer.NO_CONTAINER_ID + override var insetsInterceptor: ((View, WindowInsets) -> WindowInsets)? = null + private var activeTransition: ActiveTransition? = null + override val controllersTransitionActive: Boolean get() = activeTransition != null + + private var _latestDispatchedInsets: WindowInsets? = null + override val latestDispatchedInsets: WindowInsets? + get() = _latestDispatchedInsets + + private var savedStateDispatchAllowed: Boolean = false + + init { + val ta = context.obtainStyledAttributes(attrs, R.styleable.ControllerContainer) + fitStatusBar = ta.getBoolean(R.styleable.ControllerContainer_fitStatusBar, fitStatusBar) + fitNavigationBar = ta.getBoolean(R.styleable.ControllerContainer_fitNavigationBar, fitNavigationBar) + ta.recycle() + } + + override fun handleViewAttachedToWindow(containerView: View) { + ViewCompat.requestApplyInsets(containerView) + } + + override fun handleDispatchApplyWindowInsets(controllerContainer: ControllerContainer, insets: WindowInsets): WindowInsets { + val interceptedInsets = insetsInterceptor?.invoke(controllerContainer.asViewGroup(), insets) + if (interceptedInsets != null && interceptedInsets.isConsumed) { + return interceptedInsets + } + val handledInsets = controllerContainer.handleInsets(interceptedInsets ?: insets) ?: insets + if (handledInsets.isConsumed) { + return handledInsets + } + _latestDispatchedInsets = handledInsets + return dispatchInsetsToChildren(controllerContainer.asViewGroup(), handledInsets) + } + + /** + * On the older versions of android (<30) there is a broken logic for propagating insets to children + * This method applies a correct behaviour so it is available in all the versions + * https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/view/ViewGroup.java;l=7341-7371;drc=6411c81462e3594c38a1be5d7c27d67294139ab8 + */ + private fun dispatchInsetsToChildren(containerView: ViewGroup, insets: WindowInsets): WindowInsets { + containerView.children.forEach { child -> + child.dispatchApplyWindowInsets(insets) + } + return insets + } + + private fun ControllerContainer.handleInsets(insets: WindowInsets): WindowInsets? { + val compatInsets = WindowInsetsCompat.toWindowInsetsCompat(insets) + val handledInsetsType = WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime() + val insetDimensions = compatInsets.getInsets(handledInsetsType) + + val containerView = asViewGroup() + + containerView.updatePadding( + top = if (fitStatusBar) insetDimensions.top else 0, + bottom = if (fitNavigationBar) insetDimensions.bottom else 0, + ) + + val newInsets = Insets.of( + insetDimensions.left, + if (fitStatusBar) 0 else insetDimensions.top, + insetDimensions.right, + if (fitNavigationBar) 0 else insetDimensions.bottom, + ) + + return WindowInsetsCompat.Builder(compatInsets) + .setInsets(handledInsetsType, newInsets) + .build() + .toWindowInsets() + } + + private fun ControllerContainer.asViewGroup() = this as ViewGroup + + override fun handleDispatchTouchEvent(ev: MotionEvent): Boolean { + val activeTransition = activeTransition + return activeTransition != null && !activeTransition.indefinite + } + + /** + * Kompot has its own solution for storing views state. It creates a separate bundle for every controller + * and keeps views state in this bundle. To avoid duplicated data in the saved state, we'll need to ensure that: + * - Controller keeps the state of its own views only (e.g flows don't keep view states of their children) + * - Activity doesn't persist state of the controller views + * + * In order to achieve that, we control whether container saved state is allowed using [savedStateDispatchAllowed] flag. Controller containers handle + * this flag to understand if the save state instruction came from the owning controller and not from the + * android framework. Controller containers handle saved state only when the [savedStateDispatchAllowed] flag is enabled. + */ + override fun allowSavedStateDispatch() { + savedStateDispatchAllowed = true + } + + /** + * Use [savedStateDispatchAllowed] flag to determine if saved state is allowed. Controllers use this flag + * to check if it's allowed to call methods like [View.dispatchSaveInstanceState]. + * If the flag is enabled, we'll clear it right away to ensure the flag is clean for the next saved state invocations. + * [View.dispatchSaveInstanceState] and [View.dispatchRestoreInstanceState] traverse all underlying views hierarchy, so using the flag helps us to stop traversal + * when we reach the next controller container. More info in the [ControllerContainer.allowSavedStateDispatch], + * + * @return true if saved state dispatch is allowed and we can proceed with the saved state. false otherwise. + */ + override fun useSavedStateDispatchAllowance(): Boolean = + if (savedStateDispatchAllowed) { + savedStateDispatchAllowed = false + true + } else { + false + } + + override fun onControllersTransitionStart(indefinite: Boolean) { + activeTransition = ActiveTransition(indefinite) + } + + override fun onControllersTransitionEnd(indefinite: Boolean) { + activeTransition = null + } + + override fun onControllersTransitionCanceled(indefinite: Boolean) { + activeTransition = null + } + + @JvmInline + private value class ActiveTransition(val indefinite: Boolean) +} \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/view/ControllerContainerFrameLayout.kt b/kompot/src/main/kotlin/com/revolut/kompot/view/ControllerContainerFrameLayout.kt index 779c532..a9bb607 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/view/ControllerContainerFrameLayout.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/view/ControllerContainerFrameLayout.kt @@ -18,52 +18,50 @@ package com.revolut.kompot.view import android.annotation.SuppressLint import android.content.Context +import android.os.Parcelable import android.util.AttributeSet +import android.util.SparseArray import android.view.MotionEvent import android.view.WindowInsets import android.widget.FrameLayout -import com.revolut.kompot.R @SuppressLint("CustomViewStyleable") open class ControllerContainerFrameLayout @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 -) : FrameLayout(context, attrs, defStyleAttr), ControllerContainer { - private var transitionActive: Boolean = false - private var lastTransitionStartTime = 0L - - final override var fitStatusBar = false - final override var fitNavigationBar = true - - init { - val ta = context.obtainStyledAttributes(attrs, R.styleable.ControllerContainer) - fitStatusBar = ta.getBoolean(R.styleable.ControllerContainer_fitStatusBar, fitStatusBar) - fitNavigationBar = ta.getBoolean(R.styleable.ControllerContainer_fitNavigationBar, fitNavigationBar) - ta.recycle() - } +) : FrameLayout(context, attrs, defStyleAttr), ControllerContainer by ControllerContainerDelegate(context, attrs) { override fun onAttachedToWindow() { - EdgeToEdgeViewDelegate.onViewAttachedToWindow(this) + handleViewAttachedToWindow(this) super.onAttachedToWindow() } override fun dispatchApplyWindowInsets(insets: WindowInsets): WindowInsets = - EdgeToEdgeViewDelegate.dispatchApplyWindowInsets(this, insets) + handleDispatchApplyWindowInsets(this, insets) - override fun onInterceptTouchEvent(event: MotionEvent?): Boolean = transitionActive || (System.currentTimeMillis() - lastTransitionStartTime <= 200) || - super.onInterceptTouchEvent(event) + override fun dispatchTouchEvent(ev: MotionEvent): Boolean = + handleDispatchTouchEvent(ev) || super.dispatchTouchEvent(ev) + + override fun saveState(outState: SparseArray) { + allowSavedStateDispatch() + dispatchSaveInstanceState(outState) + } - override fun onTransitionRunUp(enter: Boolean) { - lastTransitionStartTime = System.currentTimeMillis() + override fun restoreState(state: SparseArray) { + allowSavedStateDispatch() + dispatchRestoreInstanceState(state) } - override fun onTransitionStart(enter: Boolean) { - transitionActive = true - lastTransitionStartTime = System.currentTimeMillis() + override fun dispatchSaveInstanceState(container: SparseArray) { + if (useSavedStateDispatchAllowance()) { + super.dispatchSaveInstanceState(container) + } } - override fun onTransitionEnd(enter: Boolean) { - transitionActive = false + override fun dispatchRestoreInstanceState(container: SparseArray) { + if (useSavedStateDispatchAllowance()) { + super.dispatchRestoreInstanceState(container) + } } } \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/view/ControllerContainerLinearLayout.kt b/kompot/src/main/kotlin/com/revolut/kompot/view/ControllerContainerLinearLayout.kt index 747209d..2ed2870 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/view/ControllerContainerLinearLayout.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/view/ControllerContainerLinearLayout.kt @@ -18,52 +18,50 @@ package com.revolut.kompot.view import android.annotation.SuppressLint import android.content.Context +import android.os.Parcelable import android.util.AttributeSet +import android.util.SparseArray import android.view.MotionEvent import android.view.WindowInsets import android.widget.LinearLayout -import com.revolut.kompot.R @SuppressLint("CustomViewStyleable") open class ControllerContainerLinearLayout @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 -) : LinearLayout(context, attrs, defStyleAttr), ControllerContainer { - private var transitionActive: Boolean = false - private var lastTransitionStartTime = 0L - - final override var fitStatusBar = false - final override var fitNavigationBar = true - - init { - val ta = context.obtainStyledAttributes(attrs, R.styleable.ControllerContainer) - fitStatusBar = ta.getBoolean(R.styleable.ControllerContainer_fitStatusBar, fitStatusBar) - fitNavigationBar = ta.getBoolean(R.styleable.ControllerContainer_fitNavigationBar, fitNavigationBar) - ta.recycle() - } +) : LinearLayout(context, attrs, defStyleAttr), ControllerContainer by ControllerContainerDelegate(context, attrs) { override fun onAttachedToWindow() { - EdgeToEdgeViewDelegate.onViewAttachedToWindow(this) + handleViewAttachedToWindow(this) super.onAttachedToWindow() } override fun dispatchApplyWindowInsets(insets: WindowInsets): WindowInsets = - EdgeToEdgeViewDelegate.dispatchApplyWindowInsets(this, insets) + handleDispatchApplyWindowInsets(this, insets) - override fun onInterceptTouchEvent(event: MotionEvent?): Boolean = transitionActive || (System.currentTimeMillis() - lastTransitionStartTime <= 200) || - super.onInterceptTouchEvent(event) + override fun dispatchTouchEvent(ev: MotionEvent): Boolean = + handleDispatchTouchEvent(ev) || super.dispatchTouchEvent(ev) + + override fun saveState(outState: SparseArray) { + allowSavedStateDispatch() + dispatchSaveInstanceState(outState) + } - override fun onTransitionRunUp(enter: Boolean) { - lastTransitionStartTime = System.currentTimeMillis() + override fun restoreState(state: SparseArray) { + allowSavedStateDispatch() + dispatchRestoreInstanceState(state) } - override fun onTransitionStart(enter: Boolean) { - transitionActive = true - lastTransitionStartTime = System.currentTimeMillis() + override fun dispatchSaveInstanceState(container: SparseArray) { + if (useSavedStateDispatchAllowance()) { + super.dispatchSaveInstanceState(container) + } } - override fun onTransitionEnd(enter: Boolean) { - transitionActive = false + override fun dispatchRestoreInstanceState(container: SparseArray) { + if (useSavedStateDispatchAllowance()) { + super.dispatchRestoreInstanceState(container) + } } } \ No newline at end of file diff --git a/kompot/src/main/kotlin/com/revolut/kompot/view/RootFrameLayout.kt b/kompot/src/main/kotlin/com/revolut/kompot/view/RootFrameLayout.kt index 3c2ab44..6841f61 100644 --- a/kompot/src/main/kotlin/com/revolut/kompot/view/RootFrameLayout.kt +++ b/kompot/src/main/kotlin/com/revolut/kompot/view/RootFrameLayout.kt @@ -17,11 +17,25 @@ package com.revolut.kompot.view import android.content.Context +import android.os.Parcelable import android.util.AttributeSet +import android.util.SparseArray import android.widget.FrameLayout internal class RootFrameLayout @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 -) : FrameLayout(context, attrs, defStyleAttr) \ No newline at end of file +) : FrameLayout(context, attrs, defStyleAttr) { + + /** + * Kompot has its own solution for storing views state. It creates a separate bundle for every controller + * and keeps views state in this bundle. To avoid duplicated data in the saved state, we'll need to ensure that host + * activity doesn't persist state of the controller views. + * + * Root flow has empty implementation of the dispatchSaveInstanceState and dispatchRestoreInstanceState to ignore + * framework's native saved state. + */ + override fun dispatchSaveInstanceState(container: SparseArray?) = Unit + override fun dispatchRestoreInstanceState(container: SparseArray?) = Unit +} \ No newline at end of file diff --git a/kompot/src/test/kotlin/com/revolut/kompot/ControllersLifecycleTest.kt b/kompot/src/test/kotlin/com/revolut/kompot/ControllersLifecycleTest.kt new file mode 100644 index 0000000..90385e7 --- /dev/null +++ b/kompot/src/test/kotlin/com/revolut/kompot/ControllersLifecycleTest.kt @@ -0,0 +1,137 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot + +import android.os.Build +import com.revolut.kompot.navigable.Controller +import com.revolut.kompot.navigable.TransitionAnimation +import com.revolut.kompot.navigable.components.TestFlowStep +import com.revolut.kompot.navigable.components.TestFlowViewController +import com.revolut.kompot.navigable.components.TestFlowViewControllerModel +import com.revolut.kompot.navigable.components.TestRootFlow +import com.revolut.kompot.navigable.components.TestStep +import com.revolut.kompot.navigable.flow.ReusableFlowStep +import com.revolut.kompot.utils.StubMainThreadRule +import kotlinx.parcelize.Parcelize +import org.junit.Rule +import org.junit.Test +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [Build.VERSION_CODES.N]) +internal class ControllersLifecycleTest { + + @[Rule JvmField] + val stubMainThreadRule = StubMainThreadRule() + + private val flowModel = TestFlowViewControllerModel() + private val flow = TestFlowViewController(flowModel) + private val rootFlow = TestRootFlow(flow) + + @Test + fun `GIVEN backstack WHEN root controller manager destroyed THEN destroy all controllers, clear cache`() { + rootFlow.show() + flow.onAttach() + rootFlow.onAttach() + + flowModel.flowCoordinator.next( + TestStep(2), + addCurrentStepToBackStack = true, + animation = TransitionAnimation.NONE, + executeImmediately = false, + ) + + val allControllers = buildList { + this += rootFlow + this += flow + this += flowModel.initialisedControllers + } + assertEquals(4, allControllers.size) + allControllers.forEach { controller -> + assertFalse(controller.destroyed) + assertTrue(rootFlow.controllerManager.controllersCache.isControllerCached(controller.key)) + } + + rootFlow.controllerManager.onDestroy() + + allControllers.forEach { controller -> + assertTrue(controller.destroyed) + assertFalse(rootFlow.controllerManager.controllersCache.isControllerCached(controller.key)) + } + } + + @Test + fun `GIVEN flow with multiple reusable steps initialised WHEN root controller manager destroyed THEN destroy all controllers, clear cache`() { + val flowModel = TestFlowViewControllerModel(ReusableStep(1)) + val flow = TestFlowViewController(flowModel) + val rootFlow = TestRootFlow(flow) + + rootFlow.show() + flow.onAttach() + rootFlow.onAttach() + + flowModel.flowCoordinator.next( + ReusableStep(2), + addCurrentStepToBackStack = true, + animation = TransitionAnimation.NONE, + executeImmediately = false, + ) + flow.assertStateRendered(stateValue = 2) + flowModel.flowCoordinator.next( + ReusableStep(1), + addCurrentStepToBackStack = true, + animation = TransitionAnimation.NONE, + executeImmediately = false, + ) + flow.assertStateRendered(stateValue = 1) + flowModel.flowCoordinator.next( + ReusableStep(2), + addCurrentStepToBackStack = true, + animation = TransitionAnimation.NONE, + executeImmediately = false, + ) + flow.assertStateRendered(stateValue = 2) + + val allControllers = buildList { + this += rootFlow + this += flow + this += flowModel.initialisedControllers + } + assertEquals(4, allControllers.size) //reusable controllers initialised only once + allControllers.forEach { controller -> + assertFalse(controller.destroyed) + assertTrue(rootFlow.controllerManager.controllersCache.isControllerCached(controller.key)) + } + + rootFlow.controllerManager.onDestroy() + + allControllers.forEach { controller -> + assertTrue(controller.destroyed) + assertFalse(rootFlow.controllerManager.controllersCache.isControllerCached(controller.key)) + } + } + + @Parcelize + private data class ReusableStep(override val value: Int) : TestFlowStep, ReusableFlowStep { + override val key: String get() = value.toString() + } +} \ No newline at end of file diff --git a/kompot/src/test/kotlin/com/revolut/kompot/DefaultFeatureRegistryTest.kt b/kompot/src/test/kotlin/com/revolut/kompot/DefaultFeatureRegistryTest.kt new file mode 100644 index 0000000..b380738 --- /dev/null +++ b/kompot/src/test/kotlin/com/revolut/kompot/DefaultFeatureRegistryTest.kt @@ -0,0 +1,131 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot + +import android.content.Context +import com.revolut.kompot.common.ControllerDescriptor +import com.revolut.kompot.common.ControllerHolder +import com.revolut.kompot.common.IOData +import com.revolut.kompot.common.NavigationDestination +import com.revolut.kompot.common.NavigationRequest +import com.revolut.kompot.navigable.Controller +import com.revolut.kompot.navigable.components.TestFlowModel +import com.revolut.kompot.navigable.components.TestViewController +import com.revolut.kompot.navigable.flow.BaseFlowModel +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock + +class DefaultFeatureRegistryTest { + + private val gateway1 = FakeFeatureGateway(gatewayController = null) + private val gateway2 = FakeFeatureGateway(gatewayController = null) + private val gateway3WithContent = FakeFeatureGateway( + gatewayController = TestViewController("", instrumented = false), + destination = object : NavigationDestination {} + ) + + private val registry = DefaultFeaturesRegistry().apply { + registerFeatureHolders(listOf(gateway1)) + registerFeatures(listOf(gateway2, gateway3WithContent)) + } + + @Test + fun `GIVEN gateways AND no sign out WHEN clear THEN clear references only`() { + registry.clearFeatures(mock(), false) + + assertTrue(gateway1.referenceCleared) + assertTrue(gateway2.referenceCleared) + assertTrue(gateway3WithContent.referenceCleared) + + assertFalse(gateway1.dataCleared) + assertFalse(gateway2.dataCleared) + assertFalse(gateway3WithContent.dataCleared) + } + + @Test + fun `GIVEN gateways AND sign out WHEN clear THEN clear references and data`() { + registry.clearFeatures(mock(), true) + + assertTrue(gateway1.referenceCleared) + assertTrue(gateway2.referenceCleared) + assertTrue(gateway3WithContent.referenceCleared) + + assertTrue(gateway1.dataCleared) + assertTrue(gateway2.dataCleared) + assertTrue(gateway3WithContent.dataCleared) + } + + @Test + fun `GIVEN gateways WHEN getControllerOrThrow THEN get first available controller`() { + val navDestination = object : NavigationDestination {} + val controller = registry.getControllerOrThrow(navDestination, TestFlowModel()) + assertEquals(gateway3WithContent.gatewayController, controller) + } + + @Test + fun `GIVEN gateways WHEN provideController by descriptor THEN provide first available controller`() { + val descriptor = object : ControllerDescriptor {} + val result = registry.provideControllerOrThrow(descriptor) + assertEquals(ControllerHolder(gateway3WithContent.gatewayController!!), result) + } + + @Test + fun `GIVEN gateways WHEN interceptDestination THEN get first intercepted destination`() { + val navDestination = object : NavigationDestination {} + val interceptedDestination = registry.interceptDestination(navDestination) + assertEquals(gateway3WithContent.destination, interceptedDestination) + } + + @Test + fun `GIVEN gateways WHEN getDestination THEN get first available destination`() = dispatchBlockingTest { + val navigationRequest = object : NavigationRequest {} + val resolvedDestination = registry.getDestinationOrThrow(navigationRequest) + assertEquals(gateway3WithContent.destination, resolvedDestination) + } + + private class FakeFeatureGateway( + val gatewayController: TestViewController? = null, + val destination: NavigationDestination? = null, + ) : FeatureGateway { + var dataCleared = false + private set + var referenceCleared = false + private set + + override fun getController(destination: NavigationDestination, flowModel: BaseFlowModel<*, *, *>): Controller? = gatewayController + + override fun provideController(descriptor: ControllerDescriptor): ControllerHolder? = + gatewayController?.let { ControllerHolder(it) } + + override fun interceptDestination(destination: NavigationDestination): NavigationDestination? = + this.destination + + override suspend fun getDestination(request: NavigationRequest): NavigationDestination? = + destination + + override fun clearData(context: Context) { + dataCleared = true + } + + override fun clearReference() { + referenceCleared = true + } + } +} \ No newline at end of file diff --git a/kompot/src/test/kotlin/com/revolut/kompot/cache/DefaultControllersCacheTest.kt b/kompot/src/test/kotlin/com/revolut/kompot/cache/DefaultControllersCacheTest.kt index ce68674..b1656aa 100644 --- a/kompot/src/test/kotlin/com/revolut/kompot/cache/DefaultControllersCacheTest.kt +++ b/kompot/src/test/kotlin/com/revolut/kompot/cache/DefaultControllersCacheTest.kt @@ -459,7 +459,7 @@ class DefaultControllersCacheTest { } override val component: BaseFlowComponent get() = throw IllegalStateException() - override val controllerDelegates: Set + override val controllerExtensions: Set get() = emptySet() private val stateWrapper = createSampleStateWrapper() diff --git a/kompot/src/test/kotlin/com/revolut/kompot/dialog/DialogDisplayerTest.kt b/kompot/src/test/kotlin/com/revolut/kompot/dialog/DialogDisplayerTest.kt index 8fc4516..f4f32e7 100644 --- a/kompot/src/test/kotlin/com/revolut/kompot/dialog/DialogDisplayerTest.kt +++ b/kompot/src/test/kotlin/com/revolut/kompot/dialog/DialogDisplayerTest.kt @@ -58,6 +58,18 @@ internal class DialogDisplayerTest { verify(secondDelegate).hideDialog() } + @Test + fun `GIVEN dialog model WHEN hide dialog THEN proxy call to correct delegate`() { + val firstDelegate = spy(FirstFakeDialogDisplayerDelegate()) + val dialogModel = FirstFakeDialogModel("") + + val displayer = DialogDisplayer(FakeLoadingDialogDisplayer, listOf(firstDelegate)) + + displayer.hideDialog(dialogModel) + + verify(firstDelegate).hideDialog() + } + @Test fun `throw an exception with unhandled delegate model`() { val firstDelegate = spy(FirstFakeDialogDisplayerDelegate()) @@ -67,5 +79,14 @@ internal class DialogDisplayerTest { assertThrows(IllegalStateException::class.java, { displayer.showDialog(UnknownDialogModel("")) }) } + @Test + fun `GIVEN unknown dialog model WHEN hide dialog THEN throw an exception`() { + val firstDelegate = spy(FirstFakeDialogDisplayerDelegate()) + val secondDelegate = spy(SecondFakeDialogDisplayerDelegate()) + val displayer = DialogDisplayer(FakeLoadingDialogDisplayer, listOf(firstDelegate, secondDelegate)) + + assertThrows(IllegalStateException::class.java) { displayer.hideDialog(UnknownDialogModel("")) } + } + private data class UnknownDialogModel(val message: String) : DialogModel } \ No newline at end of file diff --git a/kompot/src/test/kotlin/com/revolut/kompot/holder/ControllerTransactionTest.kt b/kompot/src/test/kotlin/com/revolut/kompot/holder/ControllerTransactionTest.kt new file mode 100644 index 0000000..b98de78 --- /dev/null +++ b/kompot/src/test/kotlin/com/revolut/kompot/holder/ControllerTransactionTest.kt @@ -0,0 +1,332 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.holder + +import com.nhaarman.mockitokotlin2.clearInvocations +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.inOrder +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.verify +import com.revolut.kompot.navigable.ChildControllerListener +import com.revolut.kompot.navigable.Controller +import com.revolut.kompot.navigable.ControllerManager +import com.revolut.kompot.navigable.cache.DefaultControllersCache +import com.revolut.kompot.navigable.components.TestController +import com.revolut.kompot.view.ControllerContainer +import com.revolut.kompot.view.ControllerContainerFrameLayout +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +internal class ControllerTransactionTest { + + private val onAttachControllerListener = FakeChildControllerListener() + private val onDetachControllerListener = FakeChildControllerListener() + + private val controllerManager = ControllerManager( + modal = false, + defaultControllerContainer = null, + controllersCache = DefaultControllersCache(1), + controllerViewHolder = mock { + on { container } doReturn mock() + }, + onAttachController = onAttachControllerListener, + onDetachController = onDetachControllerListener, + ) + + @Test + fun `GIVEN starting controller attached WHEN onTransitionCreated THEN detach starting controller`() { + val from = TestController("1").apply { + onAttach() + } + val to = TestController("2") + val transaction = createControllerTransaction( + from = from, + to = to, + indefinite = false, + ) + controllerManager.onAttach() + + transaction.onTransitionCreated() + + assertTrue(from.detached) + assertTrue(onDetachControllerListener.invoked) + } + + @Test + fun `WHEN onTransitionCreated THEN attach new controller`() { + val from = TestController("1") + val to = TestController("2") + val transaction = createControllerTransaction( + from = from, + to = to, + indefinite = false, + ) + controllerManager.onAttach() + + transaction.onTransitionCreated() + + assertTrue(to.attached) + assertTrue(onAttachControllerListener.invoked) + } + + @Test + fun `GIVEN starting controller detached WHEN onTransitionCreated THEN don't detach`() { + val from = TestController("1").apply { + onAttach() + onDetach() + } + val to = TestController("2") + val transaction = createControllerTransaction( + from = from, + to = to, + indefinite = false, + ) + controllerManager.onAttach() + + transaction.onTransitionCreated() + + assertTrue(from.detached) + assertFalse(onDetachControllerListener.invoked) + } + + @Test + fun `GIVEN new controller attached WHEN onTransitionCreated THEN don't attach`() { + val from = TestController("1") + val to = TestController("2").apply { + onAttach() + } + val transaction = createControllerTransaction( + from = from, + to = to, + indefinite = false, + ) + controllerManager.onAttach() + + transaction.onTransitionCreated() + + assertTrue(to.attached) + assertFalse(onAttachControllerListener.invoked) + } + + @Test + fun `GIVEN indefinite transaction AND starting controller attached WHEN onTransitionCreated THEN don't detach`() { + val from = TestController("1").apply { + onAttach() + } + val to = TestController("2") + val transaction = createControllerTransaction( + from = from, + to = to, + indefinite = true, + ) + controllerManager.onAttach() + + transaction.onTransitionCreated() + + assertFalse(from.detached) + assertFalse(onDetachControllerListener.invoked) + } + + @Test + fun `GIVEN indefinite transaction WHEN onTransitionCreated THEN attach new controller`() { + val from = TestController("1") + val to = TestController("2") + val transaction = createControllerTransaction( + from = from, + to = to, + indefinite = true, + ) + controllerManager.onAttach() + + transaction.onTransitionCreated() + + assertTrue(to.attached) + assertTrue(onAttachControllerListener.invoked) + } + + @Test + fun `GIVEN finite transition WHEN onTransitionFinished THEN don't trigger controllers lifecycle`() { + val from = TestController("1") + val to = TestController("2") + val transaction = createControllerTransaction( + from = from, + to = to, + indefinite = false, + ) + controllerManager.onAttach() + + transaction.onTransitionFinished() + + assertFalse(from.attached) + assertFalse(from.detached) + assertFalse(to.attached) + assertFalse(to.detached) + assertFalse(onAttachControllerListener.invoked) + assertFalse(onDetachControllerListener.invoked) + } + + @Test + fun `GIVEN indefinite transition AND starting controller attached WHEN onTransitionFinished THEN detach starting controller`() { + val from = TestController("1").apply { + onAttach() + } + val to = TestController("2") + val transaction = createControllerTransaction( + from = from, + to = to, + indefinite = true, + ) + controllerManager.onAttach() + + transaction.onTransitionFinished() + + assertTrue(from.detached) + assertTrue(onDetachControllerListener.invoked) + } + + @Test + fun `GIVEN forward transaction WHEN onTransitionCanceled THEN detach AND destroy new controller`() { + val from = TestController("1") + val to = TestController("2").apply { + onAttach() + } + val transaction = createControllerTransaction( + from = from, + to = to, + backward = false, + ) + controllerManager.onAttach() + + transaction.onTransitionCanceled() + + assertTrue(to.detached) + assertTrue(onDetachControllerListener.invoked) + verify(controllerManager.controllerViewHolder).remove(to.view) + assertTrue(to.destroyed) + } + + @Test + fun `GIVEN forward transaction WHEN onTransitionCanceled THEN attach starting controller`() { + val from = TestController("1").apply { + onAttach() + onDetach() + } + val to = TestController("2") + val transaction = createControllerTransaction( + from = from, + to = to, + backward = false, + ) + controllerManager.onAttach() + + transaction.onTransitionCanceled() + + assertTrue(from.attached) + assertTrue(onAttachControllerListener.invoked) + } + + @Test + fun `GIVEN backward transaction WHEN onTransitionCanceled THEN detach, not destroy new controller`() { + val from = TestController("1") + val to = TestController("2").apply { + onAttach() + } + val transaction = createControllerTransaction( + from = from, + to = to, + backward = true, + ) + controllerManager.onAttach() + + transaction.onTransitionCanceled() + + assertTrue(to.detached) + assertTrue(onDetachControllerListener.invoked) + verify(controllerManager.controllerViewHolder).remove(to.view) + assertFalse(to.destroyed) + } + + @Test + fun `GIVEN backward transaction WHEN onTransitionCanceled THEN attach starting controller`() { + val from = TestController("1").apply { + onAttach() + onDetach() + } + val to = TestController("2") + val transaction = createControllerTransaction( + from = from, + to = to, + backward = true, + ) + controllerManager.onAttach() + + transaction.onTransitionCanceled() + + assertTrue(from.attached) + assertTrue(onAttachControllerListener.invoked) + } + + @Test + fun `WHEN transaction lifecycle events triggered THEN pass events to controller container`() { + val container = controllerManager.controllerViewHolder.container as ControllerContainer + val transaction = createControllerTransaction( + from = TestController("1"), + to = TestController("2"), + indefinite = true, + ) + controllerManager.onAttach() + + transaction.onTransitionStart() + transaction.onTransitionCanceled() + + container.inOrder { + verify(container).onControllersTransitionStart(true) + verify(container).onControllersTransitionCanceled(true) + } + clearInvocations(container) + + transaction.onTransitionStart() + transaction.onTransitionEnd() + + container.inOrder { + verify(container).onControllersTransitionStart(true) + verify(container).onControllersTransitionEnd(true) + } + } + + private fun createControllerTransaction( + from: Controller? = null, + to: Controller? = null, + controllerManager: ControllerManager = this.controllerManager, + backward: Boolean = false, + indefinite: Boolean = false, + ) = ControllerTransaction( + from = from, + to = to, + controllerManager = controllerManager, + backward = backward, + indefinite = indefinite, + ) + + private class FakeChildControllerListener : ChildControllerListener { + var invoked = false + override fun invoke(p1: Controller, p2: ControllerManager) { + invoked = true + } + } +} \ No newline at end of file diff --git a/kompot/src/test/kotlin/com/revolut/kompot/navigable/BaseFlowModelTest.kt b/kompot/src/test/kotlin/com/revolut/kompot/navigable/BaseFlowModelTest.kt index ca900ee..72aa431 100644 --- a/kompot/src/test/kotlin/com/revolut/kompot/navigable/BaseFlowModelTest.kt +++ b/kompot/src/test/kotlin/com/revolut/kompot/navigable/BaseFlowModelTest.kt @@ -18,26 +18,35 @@ package com.revolut.kompot.navigable import android.os.Bundle import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.verify import com.revolut.kompot.common.IOData import com.revolut.kompot.common.LifecycleEvent import com.revolut.kompot.dispatchBlockingTest import com.revolut.kompot.navigable.binder.asFlow +import com.revolut.kompot.navigable.cache.DefaultControllersCache import com.revolut.kompot.navigable.flow.Back -import com.revolut.kompot.navigable.flow.BaseFlowModel -import com.revolut.kompot.navigable.flow.FlowState -import com.revolut.kompot.navigable.flow.FlowStep +import com.revolut.kompot.navigable.flow.FlowNavigationCommand import com.revolut.kompot.navigable.flow.Next import com.revolut.kompot.navigable.flow.PostFlowResult +import com.revolut.kompot.navigable.flow.PushControllerCommand import com.revolut.kompot.navigable.flow.Quit import com.revolut.kompot.navigable.flow.RestorationPolicy +import com.revolut.kompot.navigable.flow.RestorationState import com.revolut.kompot.navigable.flow.StartPostponedStateRestore +import com.revolut.kompot.navigable.components.TestController +import com.revolut.kompot.navigable.components.TestFlowModel +import com.revolut.kompot.navigable.components.TestFlowStep +import com.revolut.kompot.navigable.components.TestStep import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.last +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch -import kotlinx.parcelize.Parcelize import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.junit.jupiter.params.ParameterizedTest @@ -46,6 +55,26 @@ import org.junit.jupiter.params.provider.MethodSource internal class BaseFlowModelTest { + @Test + fun `should push initial controller after created`() = dispatchBlockingTest { + val flowModel = createTestFlowModel() + + val expectedCommand = PushControllerCommand( + controller = TestController("1"), + fromSavedState = false, + animation = TransitionAnimation.NONE, + backward = false, + executeImmediately = true, + ) + + launch { + val actualCommand = flowModel.navigationBinder().asFlow().first() + assertEquals(expectedCommand, actualCommand) + } + + flowModel.onLifecycleEvent(LifecycleEvent.CREATED) + } + @Test fun `update navigation stream when next step requested`() = dispatchBlockingTest { val flowModel = createTestFlowModel() @@ -57,8 +86,8 @@ internal class BaseFlowModelTest { ) launch { - val actual = flowModel.navigationBinder().asFlow().take(1).toList() - assertEquals(listOf(expectedCommand), actual) + val actual = flowModel.navigationBinder().asFlow().take(2).last() + assertEquals(expectedCommand, actual) } flowModel.next(TestStep(1), addCurrentStepToBackStack = true, animation = TransitionAnimation.NONE) @@ -69,8 +98,8 @@ internal class BaseFlowModelTest { val flowModel = createTestFlowModel() launch { - val actual = flowModel.navigationBinder().asFlow().take(1).toList() - assertTrue(actual[0] is Back) + val actual = flowModel.navigationBinder().asFlow().take(2).last() + assertTrue(actual is Back) } flowModel.back() @@ -81,8 +110,8 @@ internal class BaseFlowModelTest { val flowModel = createTestFlowModel() launch { - val actual = flowModel.navigationBinder().asFlow().take(1).toList() - assertTrue(actual[0] is Quit) + val actual = flowModel.navigationBinder().asFlow().take(2).last() + assertTrue(actual is Quit) } flowModel.quitFlow() @@ -92,13 +121,12 @@ internal class BaseFlowModelTest { fun `update navigation stream when result posted`() = dispatchBlockingTest { val flowModel = createTestFlowModel() - val expectedCommands = listOf>( - PostFlowResult(IOData.EmptyOutput), PostFlowResult(IOData.EmptyOutput) - ) + val expectedFlowResultCommand = PostFlowResult(IOData.EmptyOutput) launch { - val actual = flowModel.navigationBinder().asFlow().take(2).toList() - assertEquals(expectedCommands, actual) + val actual = flowModel.navigationBinder().asFlow().take(3).toList() + assertEquals(expectedFlowResultCommand, actual[1]) + assertEquals(expectedFlowResultCommand, actual[2]) } flowModel.postFlowResult(IOData.EmptyOutput) @@ -108,31 +136,125 @@ internal class BaseFlowModelTest { @Test fun `should go to the next state`() { with(createTestFlowModel()) { - testNext(stateValue = 2) + simulateNext(stateValue = 2) + + assertFlowState(2) + } + } + + @Test + fun `should clear cache if addCurrentStepToBackStack set to false`() { + with(createTestFlowModel()) { + activate() + simulateNext(stateValue = 2, addCurrentToBackStack = false) + + verify(controllersCache).removeController( + controllerKey = ControllerKey("1"), + finish = false + ) + } + } + + @Test + fun `should dispatch push controller command when handles back stack`() = dispatchBlockingTest { + with(createTestFlowModel()) { + simulateNext(stateValue = 2) + + val expectedCommand = PushControllerCommand( + controller = TestController("1"), + fromSavedState = true, + animation = TransitionAnimation.NONE, + backward = true, + executeImmediately = true, + ) + launch { + val actual = navigationBinder().asFlow().take(2).last() + assertEquals(expectedCommand, actual) + } + + assertTrue(handleBackStack(immediate = true)) + assertFlowState(1) + } + } + + @Test + fun `should revert to latest state if forward transition was canceled`() = dispatchBlockingTest { + with(createTestFlowModel()) { + simulateNext(stateValue = 2) + + val expectedCommand = PushControllerCommand( + controller = TestController("1"), + fromSavedState = true, + animation = TransitionAnimation.NONE, + backward = true, + executeImmediately = true, + ) + launch { + val actual = navigationBinder().asFlow().take(2).last() + assertEquals(expectedCommand, actual) + } + + onTransitionCanceled(backward = false) + + assertFlowState(1) + } + } + + @Test + fun `should revert state dismiss if backward transition was canceled`() = dispatchBlockingTest { + with(createTestFlowModel()) { + simulateNext(stateValue = 2) + handleBackStack(immediate = true) + + assertFlowState(stateValue = 1) + + onTransitionCanceled(backward = true) + + assertFlowState(stateValue = 2) + + //check that back stack is still correct + handleBackStack(immediate = true) + assertFlowState(stateValue = 1) + } + } + + @Test + fun `don't perform back navigation if back stack is empty`() = dispatchBlockingTest { + with(createTestFlowModel()) { + val actualCommands = mutableListOf>() + val commandsCollection = launch { + navigationBinder().asFlow() + .onEach { actualCommands.add(it) } + .launchIn(this) + } + assertFalse(handleBackStack(immediate = true)) - assertFlowModelState(2) + assertTrue(actualCommands.size == 1) //has only initial command + assertFalse((actualCommands.first() as PushControllerCommand).backward) + + commandsCollection.cancel() } } @Test - fun `should navigate to previous step`() { + fun `should update state after navigation to the previous step`() { with(createTestFlowModel()) { - testNext(stateValue = 2) - testBack() + simulateNext(stateValue = 2) + handleBackStack(immediate = true) - assertFlowModelState(1) + assertFlowState(1) } } @Test fun `should restore to the selected step`() { with(createTestFlowModel()) { - testNext(stateValue = 2) - testNext(stateValue = 3) + simulateNext(stateValue = 2) + simulateNext(stateValue = 3) restoreToStep(1) - assertFlowModelState(1) + assertFlowState(1) } } @@ -141,14 +263,36 @@ internal class BaseFlowModelTest { fun `should decide if restoration needed based on restoration policy and postponed state`( restorationPolicy: RestorationPolicy, postponeStateRestore: Boolean, - restorationNeeded: Boolean + restorationState: RestorationState, ) { - val flowModel = TestFlowModel(postponeStateRestore = postponeStateRestore).apply { + val flowModel = TestFlowModel(postponeSavedStateRestore = postponeStateRestore).apply { + setInitialState() restoreState(restorationPolicy) onLifecycleEvent(LifecycleEvent.CREATED) } - assertEquals(restorationNeeded, flowModel.restorationNeeded) + assertEquals(restorationState, flowModel.currentRestorationState) + } + + @Test + fun `GIVEN restoration required WHEN performCreate THEN push screen with required saved state restoration`() = dispatchBlockingTest { + val expectedCommand = PushControllerCommand( + controller = TestController("1"), + fromSavedState = true, + animation = TransitionAnimation.NONE, + backward = false, + executeImmediately = true, + ) + + TestFlowModel(postponeSavedStateRestore = false).apply { + launch { + val actualCommand = navigationBinder().asFlow().first() + assertEquals(expectedCommand, actualCommand) + } + setInitialState() + restoreState(RestorationPolicy.FromBundle(Bundle())) + onLifecycleEvent(LifecycleEvent.CREATED) + } } @Test @@ -156,18 +300,18 @@ internal class BaseFlowModelTest { val flowModel = TestFlowModel().apply { onLifecycleEvent(LifecycleEvent.CREATED) } - assertFalse(flowModel.restorationNeeded) + assertNull(flowModel.currentRestorationState) } @Test fun `start postponed state restoration if restore was previously postponed`() = dispatchBlockingTest { - val flowModel = TestFlowModel(postponeStateRestore = true).apply { + val flowModel = TestFlowModel(postponeSavedStateRestore = true).apply { restoreState(RestorationPolicy.FromBundle(Bundle())) onLifecycleEvent(LifecycleEvent.CREATED) } launch { - val actualCommand = flowModel.navigationBinder().asFlow().first() + val actualCommand = flowModel.navigationBinder().asFlow().take(2).last() assertTrue(actualCommand is StartPostponedStateRestore) } @@ -176,7 +320,7 @@ internal class BaseFlowModelTest { @Test fun `don't start postponed state restoration twice`() = dispatchBlockingTest { - val flowModel = TestFlowModel(postponeStateRestore = true).apply { + val flowModel = TestFlowModel(postponeSavedStateRestore = true).apply { restoreState(RestorationPolicy.FromBundle(Bundle())) setInitialState() } @@ -185,11 +329,6 @@ internal class BaseFlowModelTest { assertFalse(flowModel.startPostponedSavedStateRestore()) } - private fun TestFlowModel.assertFlowModelState(stateValue: Int) { - assertEquals(TestStep(stateValue), step) - assertEquals(TestState(stateValue), curState) - } - private fun createTestFlowModel() = TestFlowModel().apply { onLifecycleEvent(LifecycleEvent.CREATED) } @@ -202,74 +341,21 @@ internal class BaseFlowModelTest { Arguments { val restorationPolicy = RestorationPolicy.FromBundle(Bundle()) val postponeStateRestore = false - val restorationNeeded = true - arrayOf(restorationPolicy, postponeStateRestore, restorationNeeded) + arrayOf(restorationPolicy, postponeStateRestore, RestorationState.REQUIRED) }, Arguments { val restorationPolicy = RestorationPolicy.FromBundle(Bundle()) val postponeStateRestore = true - val restorationNeeded = false - arrayOf(restorationPolicy, postponeStateRestore, restorationNeeded) + arrayOf(restorationPolicy, postponeStateRestore, RestorationState.POSTPONED) }, Arguments { val restorationPolicy = RestorationPolicy.FromParent(mock()) val postponeStateRestore = true - val restorationNeeded = false - arrayOf(restorationPolicy, postponeStateRestore, restorationNeeded) + arrayOf(restorationPolicy, postponeStateRestore, RestorationState.POSTPONED) } ) } - - class TestFlowModel - (private val postponeStateRestore: Boolean = false - ) : BaseFlowModel() { - - override val initialStep: TestStep = TestStep(1) - override val initialState: TestState = TestState(1) - - private val childFlowModel: TestFlowModel by lazy(LazyThreadSafetyMode.NONE) { - TestFlowModel().apply { this.setInitialState() } - } - - val curState: TestState get() = currentState - - override fun getController(step: TestStep): Controller = mock() - - fun testNext(stateValue: Int) { - val step = TestStep(stateValue) - val addCurrentToBackStack = true - next(step, addCurrentToBackStack) - setNextState(step, TransitionAnimation.NONE, addCurrentToBackStack, childFlowModel) - currentState = TestState(stateValue) - } - - fun testBack() { - restorePreviousState() - } - - fun restoreToStep(stateValue: Int) { - restoreToStep( - StepRestorationCriteria.RestoreByStep( - condition = { - (it as TestStep).value == stateValue - }, - removeCurrent = true - ) - ) - testBack() - } - - override fun postponeSavedStateRestore(): Boolean = postponeStateRestore - - } - - @Parcelize - data class TestState(val value: Int) : FlowState - - @Parcelize - data class TestStep(val value: Int) : FlowStep - } \ No newline at end of file diff --git a/kompot/src/test/kotlin/com/revolut/kompot/navigable/BaseScreenTest.kt b/kompot/src/test/kotlin/com/revolut/kompot/navigable/BaseScreenTest.kt index c4ca658..f32be61 100644 --- a/kompot/src/test/kotlin/com/revolut/kompot/navigable/BaseScreenTest.kt +++ b/kompot/src/test/kotlin/com/revolut/kompot/navigable/BaseScreenTest.kt @@ -322,7 +322,7 @@ internal class BaseScreenTest { ) : ControllerModel(), ScreenModel { override fun uiStateStream(): Flow = uiStateFlow - override fun saveState(): Bundle = Bundle.EMPTY + override fun saveState(outState: Bundle) = Unit override fun restoreState(state: Bundle) = Unit @@ -333,11 +333,12 @@ internal class BaseScreenTest { object FakeScreenComponent : BaseScreenComponent { override fun getControllerExtensions(): Set = setOf() + override fun getControllerModelExtensions(): Set = setOf() } data class TestPayload(val value: Int) : ScreenStates.UIPayload data class TestUIState(val value: Int) : ScreenStates.UI { - override fun calculatePayload(oldState: ScreenStates.UI): ScreenStates.UIPayload? = + override fun calculatePayload(oldState: ScreenStates.UI): ScreenStates.UIPayload = TestPayload((oldState as TestUIState).value) } diff --git a/kompot/src/test/kotlin/com/revolut/kompot/navigable/ControllerEnvironmentTest.kt b/kompot/src/test/kotlin/com/revolut/kompot/navigable/ControllerEnvironmentTest.kt new file mode 100644 index 0000000..69864fa --- /dev/null +++ b/kompot/src/test/kotlin/com/revolut/kompot/navigable/ControllerEnvironmentTest.kt @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable + +import android.view.LayoutInflater +import android.view.View +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.mock +import com.revolut.kompot.di.flow.ControllerComponent +import com.revolut.kompot.di.flow.ParentFlow +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +internal class ControllerEnvironmentTest { + + @Test + fun `controller is modal root when it is the only screen in the modal container`() { + val controller = TestController().apply { + bind( + controllerManager = createControllerManager(modal = true), + parentController = TestParentController(hasBackStack = false) + ) + } + assertTrue(controller.env.isModalRoot()) + } + + @Test + fun `controller is modal root when it is the only screen in the modal flow`() { + val controller = TestController().apply { + bind( + controllerManager = createControllerManager(modal = false), + parentController = TestParentController(hasBackStack = false).apply { + bind( + controllerManager = createControllerManager(modal = true), + parentController = TestParentController(hasBackStack = false) + ) + } + ) + } + assertTrue(controller.env.isModalRoot()) + } + + @Test + fun `controller is not modal root when it is not displayed in the modal`() { + val controller = TestController().apply { + bind( + controllerManager = createControllerManager(modal = false), + parentController = TestParentController(hasBackStack = false) + ) + } + assertFalse(controller.env.isModalRoot()) + } + + @Test + fun `controller is not modal root when parent flow has back stack`() { + val controller = TestController().apply { + bind( + controllerManager = createControllerManager(modal = false), + parentController = TestParentController(hasBackStack = true).apply { + bind( + controllerManager = createControllerManager(modal = true), + parentController = TestParentController(hasBackStack = false) + ) + } + ) + } + assertFalse(controller.env.isModalRoot()) + } + + private fun createControllerManager(modal: Boolean): ControllerManager = mock { + on { this.modal } doReturn modal + } + + private class TestController : Controller() { + override val layoutId: Int = 0 + override fun createView(inflater: LayoutInflater): View = mock() + val env get() = environment + } + + private class TestParentController(override val hasBackStack: Boolean) : Controller(), ParentFlow { + override val layoutId: Int = 0 + override fun createView(inflater: LayoutInflater): View = mock() + override val component: ControllerComponent get() = error("Not available") + } +} \ No newline at end of file diff --git a/kompot/src/test/kotlin/com/revolut/kompot/navigable/ControllerExtensionTest.kt b/kompot/src/test/kotlin/com/revolut/kompot/navigable/ControllerExtensionTest.kt new file mode 100644 index 0000000..d4aa84b --- /dev/null +++ b/kompot/src/test/kotlin/com/revolut/kompot/navigable/ControllerExtensionTest.kt @@ -0,0 +1,118 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable + +import androidx.lifecycle.Lifecycle +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.whenever +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +internal class ControllerExtensionTest { + + @Test + fun `onParentLifecycleEvent calls corresponding extension's lifecycle events`() { + with(TestControllerExtension()) { + onCreate() + onAttach() + onDetach() + onDestroy() + + assertTrue(onCreateCalled) + assertTrue(onAttachCalled) + assertTrue(onDetachCalled) + assertTrue(onDestroyCalled) + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `GIVEN scope injected WHEN observe clicks THEN clicks consumed`() = runTest(UnconfinedTestDispatcher()) { + val clicksProvider = mock() + whenever(clicksProvider.observeClicks()).thenReturn(flowOf(42)) + with(TestControllerWithClickListener(clicksProvider)) { + init(this@runTest) + onParentLifecycleEvent(Lifecycle.Event.ON_RESUME) + + observeClicks() + + assertEquals(consumedClick, 42) + } + } + + @Test + fun `GIVEN no scope WHEN observe clicks THEN collect till detach view throws an error`() { + val clicksProvider = mock() + whenever(clicksProvider.observeClicks()).thenReturn(flowOf()) + with(TestControllerWithClickListener(clicksProvider)) { + onParentLifecycleEvent(Lifecycle.Event.ON_RESUME) + + assertThrows { + observeClicks() + } + } + } + + open inner class TestControllerExtension : ControllerExtension() { + var onCreateCalled: Boolean = false + var onAttachCalled: Boolean = false + var onDetachCalled: Boolean = false + var onDestroyCalled: Boolean = false + override fun onCreate() { + onCreateCalled = true + } + + override fun onAttach() { + onAttachCalled = true + } + + override fun onDetach() { + onDetachCalled = true + } + + override fun onDestroy() { + onDestroyCalled = true + } + + } + + internal interface ClicksProvider { + fun observeClicks(): Flow + } + + inner class TestControllerWithClickListener( + private val clicksProvider: ClicksProvider, + ) : TestControllerExtension() { + + var consumedClick = 0 + + fun observeClicks() { + clicksProvider.observeClicks() + .onEach { consumedClick = it } + .collectTillDetachView() + } + } + +} \ No newline at end of file diff --git a/kompot/src/test/kotlin/com/revolut/kompot/navigable/ControllerModelExtensionTest.kt b/kompot/src/test/kotlin/com/revolut/kompot/navigable/ControllerModelExtensionTest.kt new file mode 100644 index 0000000..38601e0 --- /dev/null +++ b/kompot/src/test/kotlin/com/revolut/kompot/navigable/ControllerModelExtensionTest.kt @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable + +import com.revolut.kompot.common.LifecycleEvent +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +internal class ControllerModelExtensionTest { + + @Test + fun `onParentLifecycleEvent with CREATED event should call corresponding extension's methods`() { + val extension = TestControllerModelExtension() + extension.onParentLifecycleEvent(LifecycleEvent.CREATED) + + assertTrue(extension.onCreatedCalled) + } + + @Test + fun `onParentLifecycleEvent with SHOWN event should call corresponding extension's methods`() { + val extension = TestControllerModelExtension() + extension.onParentLifecycleEvent(LifecycleEvent.SHOWN) + + assertTrue(extension.onShownCalled) + } + + inner class TestControllerModelExtension : ControllerModelExtension() { + var onCreatedCalled: Boolean = false + var onShownCalled: Boolean = false + var onHiddenCalled: Boolean = false + var onFinishedCalled: Boolean = false + + override fun onCreated() { + onCreatedCalled = true + } + + override fun onShown() { + onShownCalled = true + } + + override fun onHidden() { + onHiddenCalled = true + } + + override fun onFinished() { + onFinishedCalled = true + } + } + +} \ No newline at end of file diff --git a/kompot/src/test/kotlin/com/revolut/kompot/navigable/ControllerModelTest.kt b/kompot/src/test/kotlin/com/revolut/kompot/navigable/ControllerModelTest.kt index 32f23ad..e421218 100644 --- a/kompot/src/test/kotlin/com/revolut/kompot/navigable/ControllerModelTest.kt +++ b/kompot/src/test/kotlin/com/revolut/kompot/navigable/ControllerModelTest.kt @@ -17,13 +17,30 @@ package com.revolut.kompot.navigable import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.doReturn import com.nhaarman.mockitokotlin2.inOrder import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.never import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.whenever +import com.revolut.kompot.common.ControllerDescriptor +import com.revolut.kompot.common.ControllerHolder +import com.revolut.kompot.common.ControllerRequest import com.revolut.kompot.common.ErrorEvent +import com.revolut.kompot.common.ErrorInterceptedEventResult +import com.revolut.kompot.common.ErrorInterceptionEvent +import com.revolut.kompot.common.IOData import com.revolut.kompot.common.LifecycleEvent +import com.revolut.kompot.common.ModalDestination +import com.revolut.kompot.common.NavigationDestination +import com.revolut.kompot.common.NavigationEvent +import com.revolut.kompot.common.NavigationRequest +import com.revolut.kompot.common.NavigationRequestEvent +import com.revolut.kompot.common.NavigationRequestResult +import com.revolut.kompot.dispatchBlockingTest +import com.revolut.kompot.navigable.utils.showModal +import com.revolut.kompot.navigable.vc.ViewController import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.flow.Flow @@ -33,12 +50,14 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.isActive import kotlinx.coroutines.job +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.UnconfinedTestDispatcher import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows internal class ControllerModelTest { @@ -284,6 +303,29 @@ internal class ControllerModelTest { } } + @Test + fun `GIVEN error intercepted WHEN collect THEN do not call handle error`() { + val testException = IllegalStateException() + val testFlow = flow { + throw testException + } + with(TestControllerModel()) { + whenever(this.eventsDispatcher.handleEvent(any())) doReturn ErrorInterceptedEventResult + onLifecycleEvent(LifecycleEvent.CREATED) + + var handleErrorCalled = false + testFlow.collectTillFinishTest( + handleError = { + handleErrorCalled = true + false + } + ) + assertFalse(handleErrorCalled) + verify(eventsDispatcher).handleEvent(ErrorInterceptionEvent(testException)) + verify(eventsDispatcher, never()).handleEvent(any()) + } + } + @Test fun `handle error from the collector for flow launched in created scope`() { val testException = IllegalStateException() @@ -564,20 +606,96 @@ internal class ControllerModelTest { } } + @Test + fun `extensions are initiated when controller model injectDependencies is called`() { + with(TestControllerModel()) { + verify(extensions.first()).init(this) + } + } + + @Test + fun `extension's onParentLifecycleScope is called whenever parent's lifecycle is changed`() { + with(TestControllerModel()) { + onLifecycleEvent(LifecycleEvent.SHOWN) + verify(extensions.first()).onParentLifecycleEvent(LifecycleEvent.SHOWN) + onLifecycleEvent(LifecycleEvent.HIDDEN) + verify(extensions.first()).onParentLifecycleEvent(LifecycleEvent.HIDDEN) + onLifecycleEvent(LifecycleEvent.CREATED) + verify(extensions.first()).onParentLifecycleEvent(LifecycleEvent.CREATED) + onLifecycleEvent(LifecycleEvent.FINISHED) + verify(extensions.first()).onParentLifecycleEvent(LifecycleEvent.FINISHED) + } + } + + @Test + fun `GIVEN controller bound to descriptor WHEN show as modal THEN dispatch modal command`() { + val descriptor = object : ControllerDescriptor {} + val vc = mock>() + + val controllerRequestResult = ControllerHolder(vc) + + with(TestControllerModel()) { + whenever(eventsDispatcher.handleEvent(ControllerRequest(descriptor))) + .thenReturn(controllerRequestResult) + + descriptor.getController().showModalTest() + + verify(eventsDispatcher).handleEvent( + NavigationEvent(ModalDestination.CallbackController(vc)) + ) + } + } + + @Test + fun `GIVEN nav command bound to nav request WHEN navigate via request THEN dispatch nav command`() = dispatchBlockingTest { + val navRequest = object : NavigationRequest {} + val navCommand = object : NavigationDestination {} + + with(TestControllerModel()) { + whenever(eventsDispatcher.handleEvent(NavigationRequestEvent(navRequest))) + .thenReturn(NavigationRequestResult { navCommand }) + + navRequest.navigate() + + verify(eventsDispatcher).handleEvent(NavigationEvent(navCommand)) + } + } + + @Test + fun `GIVEN nav request failure WHEN navigate via request THEN propagate exception`() { + val navRequest = object : NavigationRequest {} + + with(TestControllerModel()) { + whenever(eventsDispatcher.handleEvent(NavigationRequestEvent(navRequest))) + .thenThrow(IllegalStateException()) + + assertThrows { + runBlocking { navRequest.navigate() } + } + } + } + private fun CoroutineScope.childrenCount() = coroutineContext.job.children.count() - @OptIn(ExperimentalCoroutinesApi::class) inner class TestControllerModel : ControllerModel() { + val extensions: Set = setOf(mock()) + init { injectDependencies( dialogDisplayer = mock(), eventsDispatcher = mock(), controllersCache = mock(), - mainDispatcher = UnconfinedTestDispatcher() + mainDispatcher = UnconfinedTestDispatcher(), + controllerModelExtensions = extensions, ) } + fun ViewController.showModalTest( + style: ModalDestination.Style = ModalDestination.Style.FULLSCREEN_FADE, + onResult: ((T) -> Unit)? = null + ) = showModal(eventsDispatcher, style, onResult) + fun Flow.collectTillHideTest( handleError: suspend (Throwable) -> Boolean = { false }, onSuccessCompletion: suspend () -> Unit = {}, diff --git a/kompot/src/test/kotlin/com/revolut/kompot/navigable/ControllerTest.kt b/kompot/src/test/kotlin/com/revolut/kompot/navigable/ControllerTest.kt index db48117..3941b03 100644 --- a/kompot/src/test/kotlin/com/revolut/kompot/navigable/ControllerTest.kt +++ b/kompot/src/test/kotlin/com/revolut/kompot/navigable/ControllerTest.kt @@ -19,8 +19,11 @@ package com.revolut.kompot.navigable import android.app.Activity import android.view.LayoutInflater import android.view.View +import androidx.lifecycle.Lifecycle import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.inOrder import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.verify import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -244,6 +247,51 @@ internal class ControllerTest { } } + @Test + fun `controller extensions are initiated on attach`() { + with(TestController()) { + onCreate() + onAttach() + + verify(controllerExtensions.first()).init(attachedScope) + } + } + + @Test + fun `call controller extensions' onParentLifecycleEvent method on any controller's lifecycle method call`() { + with(TestController()) { + onCreate() + onAttach() + onDetach() + onDestroy() + + controllerExtensions.first().inOrder { + verify().onParentLifecycleEvent(Lifecycle.Event.ON_CREATE) + verify().onParentLifecycleEvent(Lifecycle.Event.ON_RESUME) + verify().onParentLifecycleEvent(Lifecycle.Event.ON_PAUSE) + verify().onParentLifecycleEvent(Lifecycle.Event.ON_DESTROY) + } + } + } + + @Test + fun `GIVEN registered exit transition callback WHEN exit transition ends multiple times THEN trigger callback once`() { + with(TestController()) { + var callbackInvocationsCount = 0 + doOnNextExitTransition { callbackInvocationsCount ++ } + + onTransitionStart(enter = false) + onTransitionEnd(enter = false) + + assertEquals(1, callbackInvocationsCount) + + onTransitionStart(enter = false) + onTransitionEnd(enter = false) + + assertEquals(1, callbackInvocationsCount) + } + } + private fun CoroutineScope.childrenCount() = coroutineContext.job.children.count() inner class TestController : Controller() { @@ -254,6 +302,8 @@ internal class ControllerTest { return view } + override val controllerExtensions: Set = setOf(mock()) + init { val mockedActivity = mock { on { window } doReturn mock() diff --git a/kompot/src/test/kotlin/com/revolut/kompot/navigable/FunctionalStubs.kt b/kompot/src/test/kotlin/com/revolut/kompot/navigable/FunctionalStubs.kt deleted file mode 100644 index 79ef118..0000000 --- a/kompot/src/test/kotlin/com/revolut/kompot/navigable/FunctionalStubs.kt +++ /dev/null @@ -1,108 +0,0 @@ -package com.revolut.kompot.navigable - -import android.app.Activity -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import com.nhaarman.mockitokotlin2.doReturn -import com.nhaarman.mockitokotlin2.mock -import com.revolut.kompot.common.IOData -import com.revolut.kompot.di.flow.BaseFlowComponent -import com.revolut.kompot.navigable.flow.BaseFlow -import com.revolut.kompot.navigable.flow.BaseFlowModel -import com.revolut.kompot.navigable.flow.FlowModel -import com.revolut.kompot.navigable.flow.FlowState -import com.revolut.kompot.navigable.flow.FlowStep -import com.revolut.kompot.navigable.root.NavActionsScheduler -import com.revolut.kompot.navigable.root.RootFlow -import kotlinx.parcelize.Parcelize - -internal sealed class TestStep : FlowStep { - @Parcelize - object Step1 : TestStep() - - @Parcelize - object Step2 : TestStep() -} - -@Parcelize -internal data class TestState(val value: Int) : FlowState - -internal class TestFlowModel( - private val firstStepController: Controller = TestController(), - private val postponeSavedStateRestore: Boolean = false, -) : BaseFlowModel() { - - override val initialStep: TestStep = TestStep.Step1 - override val initialState: TestState = TestState(1) - - override fun postponeSavedStateRestore(): Boolean = postponeSavedStateRestore - - override fun getController(step: TestStep): Controller = when (step) { - TestStep.Step1 -> firstStepController - TestStep.Step2 -> { - currentState = TestState(2) - TestController() - } - } - - fun changeState(newValue: Int) { - currentState = currentState.copy(value = newValue) - } - -} - -internal class TestFlow(testFlowModel: TestFlowModel) : BaseFlow(IOData.EmptyInput) { - - override val flowModel: FlowModel = testFlowModel - - override fun updateUi(step: TestStep) = Unit - - override val component: BaseFlowComponent = object : BaseFlowComponent { - override fun getControllerExtensions(): Set = emptySet() - } - - init { - val parentControllerManager: ControllerManager = mock { - on { controllersCache } doReturn mock() - } - val mockedActivity = mock { - on { window } doReturn mock() - } - view = mock { - on { context } doReturn mockedActivity - } - childManagerContainerView = mock() - val rootFlow: RootFlow<*, *> = mock { - on { rootDialogDisplayer } doReturn mock() - on { navActionsScheduler } doReturn NavActionsScheduler() - } - bind(parentControllerManager, parentController = rootFlow) - } - - override fun getChildControllerManager(container: ViewGroup, extraKey: String): ControllerManager = mock() - -} - -internal class TestController : Controller() { - - override val layoutId: Int = 0 - - override fun createView(inflater: LayoutInflater): View { - return view - } - - init { - val mockedActivity = mock { - on { window } doReturn mock() - } - view = mock { - on { context } doReturn mockedActivity - } - val parentControllerManager: ControllerManager = mock { - on { controllersCache } doReturn mock() - } - bind(parentControllerManager, parentController = mock()) - } - -} \ No newline at end of file diff --git a/kompot/src/test/kotlin/com/revolut/kompot/navigable/NavigationQueueTest.kt b/kompot/src/test/kotlin/com/revolut/kompot/navigable/NavigationQueueTest.kt index 982daed..2c099b8 100644 --- a/kompot/src/test/kotlin/com/revolut/kompot/navigable/NavigationQueueTest.kt +++ b/kompot/src/test/kotlin/com/revolut/kompot/navigable/NavigationQueueTest.kt @@ -1,5 +1,25 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package com.revolut.kompot.navigable +import android.os.Build +import com.revolut.kompot.navigable.components.TestFlow +import com.revolut.kompot.navigable.components.TestFlowModel +import com.revolut.kompot.navigable.components.TestStep import com.revolut.kompot.navigable.utils.Preconditions import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -12,8 +32,13 @@ import org.junit.Test import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.assertThrows +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config @OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [Build.VERSION_CODES.N]) class NavigationQueueTest { private val testDispatcher = StandardTestDispatcher() @@ -37,7 +62,7 @@ class NavigationQueueTest { flow.onCreate() flow.onAttach() - flowModel.next(TestStep.Step2, true) + flowModel.next(TestStep(2), true) assertThrows { flowModel.back() } @@ -50,11 +75,11 @@ class NavigationQueueTest { flow.onCreate() flow.onAttach() - flowModel.next(TestStep.Step2, true) + flowModel.next(TestStep(2), true) testDispatcher.scheduler.runCurrent() - flowModel.next(TestStep.Step1, true) + flowModel.next(TestStep(1), true) - assertEquals(TestStep.Step1, flowModel.step) + assertEquals(TestStep(1), flowModel.step) } @Test @@ -64,7 +89,7 @@ class NavigationQueueTest { flow.onCreate() flow.onAttach() - flowModel.next(TestStep.Step2, true) + flowModel.next(TestStep(2), true) flow.onDestroy() testDispatcher.scheduler.runCurrent() diff --git a/kompot/src/test/kotlin/com/revolut/kompot/navigable/TransitionAnimationTest.kt b/kompot/src/test/kotlin/com/revolut/kompot/navigable/TransitionAnimationTest.kt new file mode 100644 index 0000000..b389ff8 --- /dev/null +++ b/kompot/src/test/kotlin/com/revolut/kompot/navigable/TransitionAnimationTest.kt @@ -0,0 +1,130 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable + +import com.revolut.kompot.common.ModalDestination +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource + +internal class TransitionAnimationTest { + + @ParameterizedTest + @MethodSource("modalStyleToTransitionAnimationMapping") + fun `GIVEN modal destination style WHEN map to modal transition THEN return corresponding transition animation`( + style: ModalDestination.Style, + showImmediately: Boolean, + modalTransitionAnimation: ModalTransitionAnimation, + ) { + val actualTransitionAnimation = style.toModalTransitionAnimation(showImmediately) + assertEquals(modalTransitionAnimation, actualTransitionAnimation) + } + + @ParameterizedTest + @MethodSource("modalStyleToTransitionAnimationMapping") + fun `GIVEN modal transition WHEN map to modal style THEN return corresponding style`( + style: ModalDestination.Style, + showImmediately: Boolean, + modalTransitionAnimation: ModalTransitionAnimation, + ) { + val actualStyle = modalTransitionAnimation.extractModalStyle() + assertEquals(style, actualStyle) + } + + @Test + fun `GIVEN non-modal transition WHEN map to modal style THEN return null`() { + assertNull(TransitionAnimation.SLIDE_LEFT_TO_RIGHT.extractModalStyle()) + } + + companion object { + + @JvmStatic + fun modalStyleToTransitionAnimationMapping() = arrayOf( + run { + val style = ModalDestination.Style.POPUP + val showImmediately = true + val modalTransitionAnimation = ModalTransitionAnimation.ModalPopup(true) + Arguments.of(style, showImmediately, modalTransitionAnimation) + }, + run { + val style = ModalDestination.Style.POPUP + val showImmediately = false + val modalTransitionAnimation = ModalTransitionAnimation.ModalPopup(false) + Arguments.of(style, showImmediately, modalTransitionAnimation) + }, + run { + val style = ModalDestination.Style.FULLSCREEN_FADE + val showImmediately = true + val modalTransitionAnimation = ModalTransitionAnimation.ModalFullscreenFade( + showImmediately = true, + style = style, + ) + Arguments.of(style, showImmediately, modalTransitionAnimation) + }, + run { + val style = ModalDestination.Style.FULLSCREEN_FADE + val showImmediately = false + val modalTransitionAnimation = ModalTransitionAnimation.ModalFullscreenFade( + showImmediately = false, + style = style, + ) + Arguments.of(style, showImmediately, modalTransitionAnimation) + }, + run { + val style = ModalDestination.Style.FULLSCREEN_SLIDE_FROM_BOTTOM + val showImmediately = true + val modalTransitionAnimation = ModalTransitionAnimation.ModalFullscreenSlideFromBottom(true) + Arguments.of(style, showImmediately, modalTransitionAnimation) + }, + run { + val style = ModalDestination.Style.FULLSCREEN_SLIDE_FROM_BOTTOM + val showImmediately = false + val modalTransitionAnimation = ModalTransitionAnimation.ModalFullscreenSlideFromBottom(false) + Arguments.of(style, showImmediately, modalTransitionAnimation) + }, + run { + val style = ModalDestination.Style.BOTTOM_DIALOG + val showImmediately = true + val modalTransitionAnimation = ModalTransitionAnimation.BottomDialog(true) + Arguments.of(style, showImmediately, modalTransitionAnimation) + }, + run { + val style = ModalDestination.Style.BOTTOM_DIALOG + val showImmediately = false + val modalTransitionAnimation = ModalTransitionAnimation.BottomDialog(false) + Arguments.of(style, showImmediately, modalTransitionAnimation) + }, + run { + val style = ModalDestination.Style.FULLSCREEN_IMMEDIATE + val showImmediately = true + //using immediate fade transition to resolve immediate style + val modalTransitionAnimation = ModalTransitionAnimation.ModalFullscreenFade(true, style) + Arguments.of(style, showImmediately, modalTransitionAnimation) + }, + run { + val style = ModalDestination.Style.FULLSCREEN_IMMEDIATE + val showImmediately = false + //showImmediately flag overwritten + val modalTransitionAnimation = ModalTransitionAnimation.ModalFullscreenFade(true, style) + Arguments.of(style, showImmediately, modalTransitionAnimation) + }, + ) + } +} \ No newline at end of file diff --git a/kompot/src/test/kotlin/com/revolut/kompot/navigable/binder/ModelBinderTest.kt b/kompot/src/test/kotlin/com/revolut/kompot/navigable/binder/ModelBinderTest.kt index a644ab7..fb783ec 100644 --- a/kompot/src/test/kotlin/com/revolut/kompot/navigable/binder/ModelBinderTest.kt +++ b/kompot/src/test/kotlin/com/revolut/kompot/navigable/binder/ModelBinderTest.kt @@ -80,7 +80,7 @@ class ModelBinderTest { fun `should not notify a removed observer`() { var emissionsCount = 0 val observer = ModelObserver { - emissionsCount ++ + emissionsCount++ } binder.bind(observer) @@ -89,4 +89,23 @@ class ModelBinderTest { assertEquals(0, emissionsCount) } + @Test + fun `stateful model binder propagates latest value to the new subscribers`() { + val binder = StatefulModelBinder() + + val firstObserverEmissions = mutableListOf() + val secondObserverEmissions = mutableListOf() + binder.bind { firstObserverEmissions.add(it) } + + binder.notify("1") + binder.notify("2") + + binder.bind { secondObserverEmissions.add(it) } + + binder.notify("3") + + assertEquals(listOf("1", "2", "3"), firstObserverEmissions) + assertEquals(listOf("2", "3"), secondObserverEmissions) + } + } \ No newline at end of file diff --git a/kompot/src/test/kotlin/com/revolut/kompot/navigable/components/FlowViewController.kt b/kompot/src/test/kotlin/com/revolut/kompot/navigable/components/FlowViewController.kt new file mode 100644 index 0000000..0b69043 --- /dev/null +++ b/kompot/src/test/kotlin/com/revolut/kompot/navigable/components/FlowViewController.kt @@ -0,0 +1,105 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.components + +import android.view.LayoutInflater +import android.view.View +import androidx.test.core.app.ApplicationProvider +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.mock +import com.revolut.kompot.common.IOData +import com.revolut.kompot.navigable.Controller +import com.revolut.kompot.navigable.ControllerManager +import com.revolut.kompot.navigable.cache.DefaultControllersCache +import com.revolut.kompot.navigable.root.NavActionsScheduler +import com.revolut.kompot.navigable.root.RootFlow +import com.revolut.kompot.navigable.vc.ViewController +import com.revolut.kompot.navigable.vc.ViewControllerModel +import com.revolut.kompot.navigable.vc.di.EmptyViewControllerComponent +import com.revolut.kompot.navigable.vc.flow.BackStackEntry +import com.revolut.kompot.navigable.vc.flow.FlowCoordinator +import com.revolut.kompot.navigable.vc.flow.FlowModelBindingImpl +import com.revolut.kompot.navigable.vc.flow.FlowViewController +import com.revolut.kompot.navigable.vc.flow.FlowViewModel +import com.revolut.kompot.navigable.vc.flow.ModelBinding +import com.revolut.kompot.view.ControllerContainer +import com.revolut.kompot.view.ControllerContainerFrameLayout +import org.junit.Assert + +internal class TestFlowViewController( + internal val model: TestFlowViewControllerModel, +) : ViewController(), FlowViewController { + + override val controllerModel = model + override val modelBinding by lazy { ModelBinding(controllerModel) } + override val component = EmptyViewControllerComponent + override val viewSavedStateEnabled: Boolean = true + + @Suppress("unchecked_cast") + private val modelBindingImpl get() = modelBinding as FlowModelBindingImpl<*, TestFlowStep, IOData.EmptyOutput> + internal val mainControllerManager: ControllerManager + get() { + val mainContainer = checkNotNull(modelBindingImpl.mainControllerContainer) + return modelBindingImpl.getOrCreateChildControllerManager( + controllerContainer = mainContainer, + id = mainContainer.containerId, + ) + } + + private val testControllerManager: ControllerManager = mock { + on { controllersCache } doReturn DefaultControllersCache(20) + } + + internal val currentController get() = mainControllerManager.activeController + + init { + val rootFlow: RootFlow<*, *> = mock { + on { rootDialogDisplayer } doReturn mock() + on { navActionsScheduler } doReturn NavActionsScheduler() + } + bind(testControllerManager, parentController = rootFlow) + + val mainControllerContainer = ControllerContainerFrameLayout(ApplicationProvider.getApplicationContext()) + modelBindingImpl.mainControllerContainer = mainControllerContainer + mainControllerContainer.containerId = ControllerContainer.MAIN_CONTAINER_ID + + view = TestControllerView(TestControllerActivity(), marker = 1).apply { + id = 22 + } + } + + override fun createView(inflater: LayoutInflater): View = view + + fun assertStateRendered(stateValue: Int) { + val currentFlowController = currentController + require(currentFlowController is TestViewController) + Assert.assertEquals(stateValue.toString(), currentFlowController.input) + Assert.assertEquals(stateValue, model.flowCoordinator.step.value) + } +} + +internal class TestFlowViewControllerModel( + initialStep: TestFlowStep = TestStep(1), + initialBackStack: List> = emptyList(), +) : ViewControllerModel(), FlowViewModel { + + internal val initialisedControllers = mutableListOf() + + override val flowCoordinator = FlowCoordinator(initialStep, initialBackStack) { step -> + TestViewController(input = step.value.toString()).also { initialisedControllers.add(it) } + } +} \ No newline at end of file diff --git a/kompot/src/test/kotlin/com/revolut/kompot/navigable/components/Legacy.kt b/kompot/src/test/kotlin/com/revolut/kompot/navigable/components/Legacy.kt new file mode 100644 index 0000000..d3755d6 --- /dev/null +++ b/kompot/src/test/kotlin/com/revolut/kompot/navigable/components/Legacy.kt @@ -0,0 +1,182 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.components + +import android.app.Activity +import android.view.LayoutInflater +import android.view.View +import androidx.test.core.app.ApplicationProvider +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.mock +import com.revolut.kompot.common.IOData +import com.revolut.kompot.di.flow.BaseFlowComponent +import com.revolut.kompot.di.screen.EmptyFlowComponent +import com.revolut.kompot.navigable.Controller +import com.revolut.kompot.navigable.ControllerKey +import com.revolut.kompot.navigable.ControllerManager +import com.revolut.kompot.navigable.TransitionAnimation +import com.revolut.kompot.navigable.cache.ControllersCache +import com.revolut.kompot.navigable.flow.BaseFlow +import com.revolut.kompot.navigable.flow.BaseFlowModel +import com.revolut.kompot.navigable.flow.FlowModel +import com.revolut.kompot.navigable.flow.FlowState +import com.revolut.kompot.navigable.flow.FlowStep +import com.revolut.kompot.navigable.root.NavActionsScheduler +import com.revolut.kompot.navigable.root.RootFlow +import com.revolut.kompot.view.ControllerContainer.Companion.MAIN_CONTAINER_ID +import com.revolut.kompot.view.ControllerContainerFrameLayout +import kotlinx.parcelize.Parcelize +import org.junit.jupiter.api.Assertions + +@Parcelize +data class TestState(val value: Int) : FlowState + +interface TestFlowStep : FlowStep { + val value: Int +} + +@Parcelize +data class TestStep(override val value: Int) : TestFlowStep + +open class TestFlowModel( + private val firstStepController: Controller = TestController("1"), + private val postponeSavedStateRestore: Boolean = false, +) : BaseFlowModel() { + + override val initialStep: TestStep = TestStep(1) + override val initialState: TestState = TestState(1) + val currentRestorationState get() = restorationState + val curState: TestState get() = currentState + + var randomiseControllerKey: Boolean = false + + init { + @Suppress("LeakingThis") + injectDependencies(mock(), mock(), mock()) + } + + private val childFlowModel: TestFlowModel by lazy(LazyThreadSafetyMode.NONE) { + TestFlowModel().apply { this.setInitialState() } + } + + override fun postponeSavedStateRestore(): Boolean = postponeSavedStateRestore + + override fun getController(step: TestStep): Controller = when (step.value) { + 1 -> firstStepController + else -> TestController(if (randomiseControllerKey) "${lastRandomisedControllerKey++}" else "${step.value}", controllersCache) + } + + fun changeState(newValue: Int) { + currentState = currentState.copy(value = newValue) + } + + fun simulateNext(stateValue: Int, addCurrentToBackStack: Boolean = true) { + val step = TestStep(stateValue) + next(step, addCurrentToBackStack) + setNextState(step, TransitionAnimation.NONE, addCurrentToBackStack, childFlowModel) + + getController() //triggered by a flow to get controller that corresponds to the state + currentState = TestState(stateValue) + } + + fun restoreToStep(stateValue: Int) { + restoreToStep( + StepRestorationCriteria.RestoreByStep( + condition = { + (it as TestStep).value == stateValue + }, + removeCurrent = true + ) + ) + handleBackStack(immediate = true) + } + + fun activate() { + getController() + } + + fun assertFlowState(stateValue: Int) { + Assertions.assertEquals(TestStep(stateValue), step) + Assertions.assertEquals(TestState(stateValue), curState) + } + + companion object { + private var lastRandomisedControllerKey = Int.MIN_VALUE + } +} + +internal class TestFlow( + testFlowModel: TestFlowModel, + controllersCacheValue: ControllersCache? = null +) : BaseFlow(IOData.EmptyInput) { + + override val flowModel: FlowModel = testFlowModel + + override fun updateUi(step: TestStep) = Unit + + override val component: BaseFlowComponent = EmptyFlowComponent + + init { + val parentControllerManager: ControllerManager = mock { + on { controllersCache } doReturn (controllersCacheValue ?: mock()) + } + val mockedActivity = mock { + on { window } doReturn mock() + } + view = mock { + on { context } doReturn mockedActivity + } + mainControllerContainer = ControllerContainerFrameLayout(ApplicationProvider.getApplicationContext()) + mainControllerContainer.containerId = MAIN_CONTAINER_ID + val rootFlow: RootFlow<*, *> = mock { + on { rootDialogDisplayer } doReturn mock() + on { navActionsScheduler } doReturn NavActionsScheduler() + } + bind(parentControllerManager, parentController = rootFlow) + } + + override fun createView(inflater: LayoutInflater): View { + return view + } +} + +internal data class TestController(private val strKey: String = "", private val controllersCacheValue: ControllersCache? = null) : Controller() { + + init { + this.keyInitialization = { ControllerKey(strKey) } + } + + override val layoutId: Int = 0 + + override fun createView(inflater: LayoutInflater): View { + return view + } + + init { + val mockedActivity = mock { + on { window } doReturn mock() + } + view = mock { + on { context } doReturn mockedActivity + } + val parentControllerManager: ControllerManager = mock { + on { controllersCache } doReturn (controllersCacheValue ?: mock()) + } + bind(parentControllerManager, parentController = mock()) + } + +} \ No newline at end of file diff --git a/kompot/src/test/kotlin/com/revolut/kompot/navigable/components/RootFlowController.kt b/kompot/src/test/kotlin/com/revolut/kompot/navigable/components/RootFlowController.kt new file mode 100644 index 0000000..01b0f6c --- /dev/null +++ b/kompot/src/test/kotlin/com/revolut/kompot/navigable/components/RootFlowController.kt @@ -0,0 +1,90 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.components + +import android.app.Activity +import android.view.LayoutInflater +import android.view.View +import androidx.test.core.app.ApplicationProvider +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.mock +import com.revolut.kompot.common.IOData +import com.revolut.kompot.di.flow.BaseFlowComponent +import com.revolut.kompot.di.screen.EmptyFlowComponent +import com.revolut.kompot.dialog.DialogDisplayer +import com.revolut.kompot.navigable.Controller +import com.revolut.kompot.navigable.RootControllerManager +import com.revolut.kompot.navigable.cache.DefaultControllersCache +import com.revolut.kompot.navigable.flow.EmptyFlowState +import com.revolut.kompot.navigable.root.BaseRootFlowModel +import com.revolut.kompot.navigable.root.RootFlow +import com.revolut.kompot.view.ControllerContainer +import com.revolut.kompot.view.ControllerContainerFrameLayout + +class TestRootFlow(child: Controller, postponeStateRestore: Boolean = false) : RootFlow(IOData.EmptyInput) { + + internal val model = TestRootFlowModel(child, postponeStateRestore) + + override val rootDialogDisplayer: DialogDisplayer = mock() + override val containerForModalNavigation: ControllerContainerFrameLayout = mock() + override val flowModel = model + override val component: BaseFlowComponent = EmptyFlowComponent + + internal val controllerManager = RootControllerManager( + rootFlow = this, + activityLauncher = mock(), + permissionsRequester = mock(), + defaultControllerContainer = 1, + controllersCache = DefaultControllersCache(20), + hooksProvider = mock() + ) + + init { + val mockedActivity = mock { + on { window } doReturn mock() + } + view = mock { + on { context } doReturn mockedActivity + } + mainControllerContainer = ControllerContainerFrameLayout(ApplicationProvider.getApplicationContext()) + mainControllerContainer.containerId = ControllerContainer.MAIN_CONTAINER_ID + bind(controllerManager, parentController = null) + model.rootNavigator = mock() + } + + internal fun show() { + controllerManager.showRootFlow( + savedState = null, + hostContainer = ControllerContainerFrameLayout(ApplicationProvider.getApplicationContext()) + ) + } + + override fun createView(inflater: LayoutInflater): View { + return view + } + + override fun onCreateFlowView(view: View) = Unit +} + +class TestRootFlowModel(var child: Controller, private val postponeStateRestore: Boolean) : BaseRootFlowModel() { + override val initialStep = TestStep(1) + override val initialState = EmptyFlowState + + override fun getController(step: TestStep): Controller = child + + override fun postponeSavedStateRestore(): Boolean = postponeStateRestore +} \ No newline at end of file diff --git a/kompot/src/test/kotlin/com/revolut/kompot/navigable/components/UIStatesController.kt b/kompot/src/test/kotlin/com/revolut/kompot/navigable/components/UIStatesController.kt new file mode 100644 index 0000000..3cb1c9e --- /dev/null +++ b/kompot/src/test/kotlin/com/revolut/kompot/navigable/components/UIStatesController.kt @@ -0,0 +1,147 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.components + +import android.app.Activity +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.mock +import com.revolut.kompot.common.IOData +import com.revolut.kompot.navigable.ControllerManager +import com.revolut.kompot.navigable.cache.DefaultControllersCache +import com.revolut.kompot.navigable.hooks.HooksProvider +import com.revolut.kompot.navigable.root.NavActionsScheduler +import com.revolut.kompot.navigable.root.RootFlow +import com.revolut.kompot.navigable.vc.ViewController +import com.revolut.kompot.navigable.vc.ViewControllerModel +import com.revolut.kompot.navigable.vc.di.EmptyViewControllerComponent +import com.revolut.kompot.navigable.vc.ui.ModelBinding +import com.revolut.kompot.navigable.vc.ui.ModelState +import com.revolut.kompot.navigable.vc.ui.PersistentModelState +import com.revolut.kompot.navigable.vc.ui.PersistentModelStateKey +import com.revolut.kompot.navigable.vc.ui.SaveStateDelegate +import com.revolut.kompot.navigable.vc.ui.States +import com.revolut.kompot.navigable.vc.ui.UIStatesController +import com.revolut.kompot.navigable.vc.ui.UIStatesModel +import kotlinx.parcelize.Parcelize +import java.util.concurrent.CopyOnWriteArrayList + +internal class TestUIStatesViewController( + model: UIStatesModel, + private val hooksProviderValue: HooksProvider? = null +) : ViewController(), UIStatesController { + + internal val renderedStates = mutableListOf() + + override val controllerModel = model + override val modelBinding by lazy { ModelBinding(controllerModel) } + override val component = EmptyViewControllerComponent + override val layoutId: Int = 0 + + override fun render(uiState: TestUIState, payload: Any?) { + renderedStates += uiState + } + + init { + val parentControllerManager: ControllerManager = mock { + on { controllersCache } doReturn DefaultControllersCache(20) + on { hooksProvider } doReturn hooksProviderValue + } + val rootFlow: RootFlow<*, *> = mock { + on { rootDialogDisplayer } doReturn mock() + on { navActionsScheduler } doReturn NavActionsScheduler() + } + bind(parentControllerManager, parentController = rootFlow) + + val mockedActivity = mock { + on { window } doReturn mock() + } + view = mock { + on { context } doReturn mockedActivity + } + } + +} + +internal class TestUIStatesViewControllerModel( + val mapper: TestStateMapper = TestStateMapper(), + mapStatesInBackground: Boolean = false, +) + : ViewControllerModel(), UIStatesModel { + + override val state = ModelState( + initialState = TestDomainState( + value = 1, + ), + stateMapper = mapper, + saveStateDelegate = ModelSaveStateDelegate(), + mapStatesInBackground = mapStatesInBackground, + ) + + private class ModelSaveStateDelegate : SaveStateDelegate() { + override fun getRetainedState(currentState: TestDomainState): TestRetainedDomainState = + TestRetainedDomainState(currentState.value) + + override fun restoreDomainState(initialState: TestDomainState, retainedState: TestRetainedDomainState) = + TestDomainState(retainedState.value) + } +} + +internal class TestUIPersistentStatesViewControllerModel( + val initialState: TestPersistentDomainState = TestPersistentDomainState(1), + val stateReducer: (TestPersistentDomainState, TestPersistentDomainState) -> TestPersistentDomainState +) + : ViewControllerModel(), UIStatesModel { + + override val state = PersistentModelState( + key = PersistentModelStateKey(STORAGE_KEY), + initialState = initialState, + stateMapper = TestPersistentStateMapper(), + restoredStateReducer = stateReducer, + ) + + companion object { + const val STORAGE_KEY = "key" + } +} + +internal class TestStateMapper : States.Mapper { + + val mappingThreads = CopyOnWriteArrayList() + + override fun mapState(domainState: TestDomainState): TestUIState { + mappingThreads.add(Thread.currentThread().name) + return TestUIState(domainState.value) + } +} + +internal class TestPersistentStateMapper : States.Mapper { + + val mappingThreads = CopyOnWriteArrayList() + + override fun mapState(domainState: TestPersistentDomainState): TestUIState { + mappingThreads.add(Thread.currentThread().name) + return TestUIState(domainState.value) + } +} + +data class TestDomainState(val value: Int) : States.Domain +@Parcelize +data class TestPersistentDomainState(val value: Int) : States.PersistentDomain +data class TestUIState(val value: Int) : States.UI + +@Parcelize +data class TestRetainedDomainState(val value: Int) : States.PersistentDomain \ No newline at end of file diff --git a/kompot/src/test/kotlin/com/revolut/kompot/navigable/components/ViewController.kt b/kompot/src/test/kotlin/com/revolut/kompot/navigable/components/ViewController.kt new file mode 100644 index 0000000..431c596 --- /dev/null +++ b/kompot/src/test/kotlin/com/revolut/kompot/navigable/components/ViewController.kt @@ -0,0 +1,108 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.components + +import android.app.Activity +import android.content.Context +import android.os.Parcelable +import android.view.LayoutInflater +import android.view.View +import androidx.test.core.app.ApplicationProvider +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.mock +import com.revolut.kompot.common.IOData +import com.revolut.kompot.navigable.ControllerManager +import com.revolut.kompot.navigable.cache.DefaultControllersCache +import com.revolut.kompot.navigable.root.NavActionsScheduler +import com.revolut.kompot.navigable.root.RootFlow +import com.revolut.kompot.navigable.vc.SimpleViewController +import com.revolut.kompot.navigable.vc.SimpleViewControllerModel +import com.revolut.kompot.view.ControllerContainerFrameLayout +import kotlinx.parcelize.Parcelize + +internal class TestViewController( + val input: String, + override val themeId: Int? = null, + override val viewSavedStateEnabled: Boolean = true, + val viewMarker: Int = 1, + val instrumented: Boolean = true, +) : SimpleViewController() { + + val model = SimpleViewControllerModel() + override val controllerModel: SimpleViewControllerModel = model + override val layoutId: Int = 0 + + init { + val parentControllerManager: ControllerManager = mock { + on { controllersCache } doReturn DefaultControllersCache(20) + } + val rootFlow: RootFlow<*, *> = mock { + on { rootDialogDisplayer } doReturn mock() + on { navActionsScheduler } doReturn NavActionsScheduler() + } + bind(parentControllerManager, parentController = rootFlow) + + view = if (instrumented) { + TestControllerView(TestControllerActivity(), viewMarker).apply { + id = 11 + } + } else { + val mockedActivity = mock { + on { window } doReturn mock() + } + mock { + on { context } doReturn mockedActivity + } + } + } + + override fun createView(inflater: LayoutInflater): View = view +} + +internal class TestControllerActivity : Activity() { + init { + attachBaseContext(ApplicationProvider.getApplicationContext()) + } +} + +class TestControllerView(context: Context, val marker: Int) : ControllerContainerFrameLayout(context) { + + var restoredMarker: Int? = null + + override fun onSaveInstanceState(): Parcelable { + val superState = super.onSaveInstanceState() + return SavedState( + marker = marker, + baseState = superState, + ) + } + + override fun onRestoreInstanceState(state: Parcelable?) { + if (state is SavedState) { + super.onRestoreInstanceState(state.superState) + restoredMarker = state.marker + } else { + super.onRestoreInstanceState(state) + } + } + + @Parcelize + data class SavedState( + val marker: Int, + val baseState: Parcelable?, + ) : BaseSavedState(baseState) +} \ No newline at end of file diff --git a/kompot/src/test/kotlin/com/revolut/kompot/navigable/flow/BaseFlowTest.kt b/kompot/src/test/kotlin/com/revolut/kompot/navigable/flow/BaseFlowTest.kt new file mode 100644 index 0000000..cdfd652 --- /dev/null +++ b/kompot/src/test/kotlin/com/revolut/kompot/navigable/flow/BaseFlowTest.kt @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.flow + +import android.os.Build +import com.revolut.kompot.navigable.components.TestFlow +import com.revolut.kompot.navigable.components.TestFlowModel +import com.revolut.kompot.navigable.components.TestStep +import com.revolut.kompot.navigable.utils.Preconditions +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [Build.VERSION_CODES.N]) +internal class BaseFlowTest { + + @Before + fun setUp() { + Preconditions.mainThreadRequirementEnabled = false + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @After + fun tearDown() { + Preconditions.mainThreadRequirementEnabled = true + Dispatchers.resetMain() + } + + @Test + fun `WHEN transition canceled THEN revert model state`() { + val flowModel = TestFlowModel() + val flow = TestFlow(flowModel) + + flow.onCreate() + flow.onAttach() + + flowModel.next(TestStep(2), addCurrentStepToBackStack = true) + flowModel.changeState(2) + flowModel.assertFlowState(2) + + flow.getOrCreateChildControllerManager( + controllerContainer = flow.mainControllerContainer, + id = flow.mainControllerContainer.containerId, + ).also { + it.onTransitionCanceled( + from = flow, + backward = false, + ) + } + + flowModel.assertFlowState(1) + } +} \ No newline at end of file diff --git a/kompot/src/test/kotlin/com/revolut/kompot/navigable/flow/ControllerManagersHolderTest.kt b/kompot/src/test/kotlin/com/revolut/kompot/navigable/flow/ControllerManagersHolderTest.kt new file mode 100644 index 0000000..26e110e --- /dev/null +++ b/kompot/src/test/kotlin/com/revolut/kompot/navigable/flow/ControllerManagersHolderTest.kt @@ -0,0 +1,113 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.flow + +import android.view.View +import android.view.ViewGroup +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.mock +import com.revolut.kompot.holder.ControllerViewHolder +import com.revolut.kompot.navigable.ControllerManager +import com.revolut.kompot.navigable.TransitionAnimation +import com.revolut.kompot.navigable.cache.DefaultControllersCache +import com.revolut.kompot.navigable.transition.TransitionListener +import com.revolut.kompot.view.ControllerContainerFrameLayout +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +internal class ControllerManagersHolderTest { + + private val holder = ControllerManagersHolder() + + @Test + fun `WHEN add manager THEN add manager to the list`() { + val manager = createControllerManager(modal = false) + holder.add(manager, id = "1") + + assertEquals(listOf(manager), holder.all) + assertEquals(listOf(manager), holder.allNonModal) + } + + @Test + fun `GIVEN modal manager WHEN add manager THEN add to the end of the list`() { + val manager = createControllerManager(modal = false) + val modalManager = createControllerManager(modal = true) + holder.add(manager, id = "1") + holder.add(modalManager, id = "2") + + assertEquals(listOf(manager, modalManager), holder.all) + assertEquals(listOf(manager), holder.allNonModal) + } + + @Test + fun `GIVEN holder with modal manager WHEN add manager THEN add manager before modal`() { + val manager = createControllerManager(modal = false) + val modalManager = createControllerManager(modal = true) + holder.add(modalManager, id = "1") + holder.add(manager, id = "2") + + assertEquals(listOf(manager, modalManager), holder.all) + assertEquals(listOf(manager), holder.allNonModal) + } + + @Test + fun `GIVEN empty holder WHEN gerOrAdd THEN add and return manager`() { + val manager = createControllerManager(modal = false) + val resultManager = holder.getOrAdd("1") { manager } + + assertEquals(resultManager, manager) + assertEquals(listOf(manager), holder.all) + assertEquals(listOf(manager), holder.allNonModal) + } + + @Test + fun `GIVEN manager added WHEN gerOrAdd THEN return existing manager`() { + val manager = createControllerManager(modal = false) + holder.add(manager, "1") + val resultManager = holder.getOrAdd("1") { + createControllerManager(modal = false) + } + + assertEquals(resultManager, manager) + assertEquals(listOf(manager), holder.all) + assertEquals(listOf(manager), holder.allNonModal) + } + + private fun createControllerManager( + modal: Boolean, + containerId: String = "containerId", + ) = ControllerManager( + modal = modal, + defaultControllerContainer = null, + controllersCache = DefaultControllersCache(0), + controllerViewHolder = TestControllerViewHolder(containerId), + onTransitionCanceled = {}, + ) + + private class TestControllerViewHolder(private val containerId: String) : ControllerViewHolder { + override val container: ViewGroup = mock { + on { this.containerId } doReturn containerId + } + + override fun add(view: View) = Unit + override fun addToBottom(view: View) = Unit + override fun makeTransition(from: View?, to: View?, animation: TransitionAnimation, backward: Boolean, transitionListener: TransitionListener) = Unit + override fun remove(view: View) = Unit + override fun setOnDismissListener(onDismiss: () -> Unit) = Unit + } + +} \ No newline at end of file diff --git a/kompot/src/test/kotlin/com/revolut/kompot/navigable/root/BaseRootFlowModelTest.kt b/kompot/src/test/kotlin/com/revolut/kompot/navigable/root/BaseRootFlowModelTest.kt index 5bae881..67b045a 100644 --- a/kompot/src/test/kotlin/com/revolut/kompot/navigable/root/BaseRootFlowModelTest.kt +++ b/kompot/src/test/kotlin/com/revolut/kompot/navigable/root/BaseRootFlowModelTest.kt @@ -38,8 +38,8 @@ internal class BaseRootFlowModelTest { @Parcelize class TestStep : FlowStep - val testNavigationDestination: NavigationDestination = ModalDestination.ExplicitScreen(mock()) - val testNavigationEvent = NavigationEvent(testNavigationDestination) + private val testNavigationDestination: NavigationDestination = ModalDestination.ExplicitScreen(mock()) + private val testNavigationEvent = NavigationEvent(testNavigationDestination) @Test fun `GIVEN model has overriden destination handling WHEN handle overriden destination THEN super handling is not called`() { @@ -57,7 +57,7 @@ internal class BaseRootFlowModelTest { model.tryHandleEvent(testNavigationEvent) - verify(rootNavigator, never()).openModal(any(), any()) + verify(rootNavigator, never()).openModal(any(), any(), any()) } @Test @@ -78,6 +78,6 @@ internal class BaseRootFlowModelTest { event._controller = mock() model.tryHandleEvent(event) - verify(rootNavigator).openModal(any(), any()) + verify(rootNavigator).openModal(any(), any(), any()) } } \ No newline at end of file diff --git a/kompot/src/test/kotlin/com/revolut/kompot/navigable/saved_state/BaseScreenModelSavedStateTest.kt b/kompot/src/test/kotlin/com/revolut/kompot/navigable/saved_state/BaseScreenModelSavedStateTest.kt index bfa37b2..bacd470 100644 --- a/kompot/src/test/kotlin/com/revolut/kompot/navigable/saved_state/BaseScreenModelSavedStateTest.kt +++ b/kompot/src/test/kotlin/com/revolut/kompot/navigable/saved_state/BaseScreenModelSavedStateTest.kt @@ -16,6 +16,7 @@ package com.revolut.kompot.navigable.saved_state +import android.os.Bundle import com.revolut.kompot.common.IOData import com.revolut.kompot.dispatchBlockingTest import com.revolut.kompot.navigable.screen.BaseScreenModel @@ -47,7 +48,8 @@ internal class BaseScreenModelSavedStateTest { ) ) - val bundle = screenModel.saveState() + val bundle = Bundle() + screenModel.saveState(bundle) val restoredScreenModel = TestBaseScreenModel() diff --git a/kompot/src/test/kotlin/com/revolut/kompot/navigable/saved_state/FlowSavedStateTest.kt b/kompot/src/test/kotlin/com/revolut/kompot/navigable/saved_state/FlowSavedStateTest.kt index 99c058d..a98608b 100644 --- a/kompot/src/test/kotlin/com/revolut/kompot/navigable/saved_state/FlowSavedStateTest.kt +++ b/kompot/src/test/kotlin/com/revolut/kompot/navigable/saved_state/FlowSavedStateTest.kt @@ -16,14 +16,16 @@ package com.revolut.kompot.navigable.saved_state +import android.os.Build import android.os.Bundle import com.nhaarman.mockitokotlin2.doReturn import com.nhaarman.mockitokotlin2.mock import com.revolut.kompot.navigable.ControllerManager -import com.revolut.kompot.navigable.TestFlow -import com.revolut.kompot.navigable.TestFlowModel -import com.revolut.kompot.navigable.TestState -import com.revolut.kompot.navigable.TestStep +import com.revolut.kompot.navigable.components.TestFlow +import com.revolut.kompot.navigable.components.TestFlowModel +import com.revolut.kompot.navigable.components.TestState +import com.revolut.kompot.navigable.components.TestStep +import com.revolut.kompot.navigable.cache.DefaultControllersCache import com.revolut.kompot.navigable.flow.RestorationPolicy import com.revolut.kompot.navigable.utils.Preconditions import kotlinx.coroutines.Dispatchers @@ -42,7 +44,7 @@ import org.robolectric.annotation.Config @OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) -@Config(manifest = Config.NONE) +@Config(manifest = Config.NONE, sdk = [Build.VERSION_CODES.N]) internal class FlowSavedStateTest { private val parentControllerManager: ControllerManager = mock { @@ -68,7 +70,8 @@ internal class FlowSavedStateTest { flow.onCreate() flow.onAttach() - flowModel.next(TestStep.Step2, true) + flowModel.changeState(2) + flowModel.next(TestStep(2), true) val bundle = Bundle() @@ -82,7 +85,7 @@ internal class FlowSavedStateTest { restoredFlow.onCreate() restoredFlow.onAttach() - assertEquals(TestStep.Step2, restoredFlowModel.step) + assertEquals(TestStep(2), restoredFlowModel.step) assertEquals(TestState(2), restoredFlowModel.stateWrapper.state) } @@ -97,7 +100,7 @@ internal class FlowSavedStateTest { //mutate current step state flowModel.changeState(-1) //navigate to the next step. state will change accordingly - flowModel.next(TestStep.Step2, true) + flowModel.next(TestStep(2), true) val bundle = Bundle() @@ -114,7 +117,7 @@ internal class FlowSavedStateTest { restoredFlow.handleBack() //First entry from the back stack should be restored - assertEquals(TestStep.Step1, restoredFlowModel.step) + assertEquals(TestStep(1), restoredFlowModel.step) assertEquals(TestState(-1), restoredFlowModel.stateWrapper.state) } @@ -129,15 +132,14 @@ internal class FlowSavedStateTest { //run lifecycle events for the flows flow.onCreate() - nestedFlow.onCreate() - flow.onAttach() - nestedFlow.onAttach() //change internal state of the flow and the nested flow //by moving each of them to the next step - nestedFlowModel.next(TestStep.Step2, true) - flowModel.next(TestStep.Step2, true) + nestedFlowModel.changeState(newValue = 2) + nestedFlowModel.next(TestStep(2), true) + flowModel.changeState(newValue = 2) + flowModel.next(TestStep(2), true) val bundle = Bundle() @@ -166,7 +168,7 @@ internal class FlowSavedStateTest { restoredNestedFlow.onAttach() //check that nested flow restored its step - assertEquals(TestStep.Step2, restoredNestedFlowModel.step) + assertEquals(TestStep(2), restoredNestedFlowModel.step) assertEquals(TestState(2), restoredNestedFlowModel.stateWrapper.state) } @@ -177,7 +179,7 @@ internal class FlowSavedStateTest { flow.onCreate() flow.onAttach() - flowModel.next(TestStep.Step2, true) + flowModel.next(TestStep(2), true) val bundle = Bundle() @@ -192,7 +194,7 @@ internal class FlowSavedStateTest { restoredFlow.onAttach() //check that Step and State are the same as initial - assertEquals(TestStep.Step1, restoredFlowModel.step) + assertEquals(TestStep(1), restoredFlowModel.step) assertEquals(TestState(1), restoredFlowModel.stateWrapper.state) } @@ -206,7 +208,7 @@ internal class FlowSavedStateTest { //mutate state of the flow flowModel.changeState(newValue = 11) - flowModel.next(TestStep.Step2, true) + flowModel.next(TestStep(2), true) val bundle = Bundle() @@ -224,18 +226,19 @@ internal class FlowSavedStateTest { restoredFlow.handleBack() //check that Step and State are the same as initial - assertEquals(TestStep.Step1, restoredFlowModel.step) + assertEquals(TestStep(1), restoredFlowModel.step) assertEquals(TestState(1), restoredFlowModel.stateWrapper.state) } @Test fun `should postpone state restore`() { - val flowModel = TestFlowModel(postponeSavedStateRestore = true) + val flowModel = TestFlowModel() val flow = TestFlow(flowModel) flow.onCreate() flow.onAttach() - flowModel.next(TestStep.Step2, true) + flowModel.changeState(2) + flowModel.next(TestStep(2), true) val bundle = Bundle() @@ -251,8 +254,146 @@ internal class FlowSavedStateTest { assertTrue(restoredFlowModel.startPostponedSavedStateRestore()) - assertEquals(TestStep.Step2, restoredFlowModel.step) + assertEquals(TestStep(2), restoredFlowModel.step) assertEquals(TestState(2), restoredFlowModel.stateWrapper.state) } + @Test + fun `GIVEN canceled parent flow transition WHEN restore from saved state THEN restore nested flow latest state`() { + val nestedFlowModel = TestFlowModel() + val nestedFlow = TestFlow(nestedFlowModel) + + val parentFlowModel = TestFlowModel(firstStepController = nestedFlow) + val parentFlow = TestFlow(parentFlowModel) + + val rootFlowModel = TestFlowModel(firstStepController = parentFlow) + val rootFlow = TestFlow(rootFlowModel) + + rootFlow.onCreate() + rootFlow.onAttach() + + parentFlowModel.next(TestStep(2), true) + //trigger transition cancellation + parentFlow.getOrCreateChildControllerManager( + controllerContainer = parentFlow.mainControllerContainer, + id = parentFlow.mainControllerContainer.containerId, + ).onTransitionCanceled( + from = nestedFlow, + backward = false + ) + + //modify nested flow + nestedFlowModel.next(TestStep(2), true) + + val bundle = Bundle() + + rootFlow.saveState(bundle) + + //instantiate new flows to simulate app state restore + val restoredNestedFlowModel = TestFlowModel() + val restoredNestedFlow = TestFlow(restoredNestedFlowModel) + + val restoredParentFlowModel = TestFlowModel(firstStepController = restoredNestedFlow) + val restoredParentFlow = TestFlow(restoredParentFlowModel) + + val restoredRootFlowModel = TestFlowModel(firstStepController = restoredParentFlow) + val restoredRootFlow = TestFlow(restoredRootFlowModel) + + restoredParentFlow.bind(parentControllerManager, restoredRootFlow) + restoredNestedFlow.bind(parentControllerManager, restoredParentFlow) + + //push bundle to the flow + restoredRootFlow.restoreState(RestorationPolicy.FromBundle(bundle)) + + restoredRootFlow.onCreate() + restoredRootFlow.onAttach() + + assertEquals(TestStep(1), restoredParentFlowModel.step) + assertEquals(TestStep(2), restoredNestedFlowModel.step) //nested flow restored successfully + } + + @Test + fun `GIVEN flow with pending saved state WHEN try save flow state THEN don't save state`() { + //create bundle with flow's saved state + val flowModel = TestFlowModel() + val flow = TestFlow(flowModel) + flow.onCreate() + flow.onAttach() + val bundle = Bundle() + flow.saveState(bundle) + + //simulate flow that postpones saved state restoration, but doesn't trigger startPostponedSavedStateRestore() + val pendingRestoreFlowModel = TestFlowModel(postponeSavedStateRestore = true) + val pendingRestoreFlow = TestFlow(pendingRestoreFlowModel) + + pendingRestoreFlow.restoreState(RestorationPolicy.FromBundle(bundle)) + pendingRestoreFlow.onCreate() + pendingRestoreFlow.onAttach() + pendingRestoreFlowModel.changeState(2) + pendingRestoreFlowModel.next(TestStep(2), true) + + val bundle2 = Bundle() + //try to save state + //Should do nothing, because we can't save state of the flow that has a pending saved state and doesn't trigger startPostponedSavedStateRestore() + pendingRestoreFlow.saveState(bundle2) + + //try to restore flow from from the bundle + val restoredFlowModel = TestFlowModel() + val restoredFlow = TestFlow(restoredFlowModel) + + restoredFlow.restoreState(RestorationPolicy.FromBundle(bundle2)) + restoredFlow.onCreate() + restoredFlow.onAttach() + + //Flow has initial values, there was nothing to restore + assertEquals(TestStep(1), restoredFlowModel.step) + assertEquals(TestState(1), restoredFlowModel.stateWrapper.state) + } + + @Test + fun `GIVEN restored flow with backstack WHEN handleBack, cancel transition and handleBack again THEN controller is reused`() { + // Save flow into bundle + var flowModel = TestFlowModel().apply { + randomiseControllerKey = true + } + var flow = TestFlow(flowModel, DefaultControllersCache(10)) + flow.apply { + onCreate() + onAttach() + } + flowModel.apply { + next(TestStep(2), true) + next(TestStep(3), true) + } + val bundle = Bundle() + flow.saveState(bundle) + + + // Recreate and restore + flowModel = TestFlowModel().apply { + randomiseControllerKey = true + } + flow = TestFlow(flowModel, DefaultControllersCache(10)) + flow.apply { + restoreState(bundle) + onCreate() + onAttach() + } + // Handle back and cancel + flow.handleBack() + assertEquals(TestStep(2), flowModel.step) + val controller2 = flowModel.getController() + flow.getOrCreateChildControllerManager( + controllerContainer = flow.mainControllerContainer, + id = flow.mainControllerContainer.containerId, + ).onTransitionCanceled( + from = controller2, + backward = true + ) + + // Handle back again, assert cached controller is used + flow.handleBack() + assertEquals(flowModel.getController(), controller2) + } + } \ No newline at end of file diff --git a/kompot/src/test/kotlin/com/revolut/kompot/navigable/single_task/SingleTaskTest.kt b/kompot/src/test/kotlin/com/revolut/kompot/navigable/single_task/SingleTaskTest.kt index 0fcbf81..02eb279 100644 --- a/kompot/src/test/kotlin/com/revolut/kompot/navigable/single_task/SingleTaskTest.kt +++ b/kompot/src/test/kotlin/com/revolut/kompot/navigable/single_task/SingleTaskTest.kt @@ -19,7 +19,6 @@ package com.revolut.kompot.navigable.single_task import com.revolut.kompot.advanceTimeImmediatelyBy import com.revolut.kompot.dispatchBlockingTest import com.revolut.kompot.navigable.ControllerModel -import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collect @@ -35,7 +34,6 @@ import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows -@OptIn(ExperimentalCoroutinesApi::class) internal class SingleTaskTest { private val testControllerModel = TestControllerModel() @@ -226,7 +224,6 @@ internal class SingleTaskTest { dispatchBlockingTest { val action = suspend { with(testControllerModel) { - @Suppress("IMPLICIT_NOTHING_TYPE_ARGUMENT_IN_RETURN_POSITION") testSingleTask("") { throw IllegalStateException() } diff --git a/kompot/src/test/kotlin/com/revolut/kompot/navigable/transition/TransitionFactoryTest.kt b/kompot/src/test/kotlin/com/revolut/kompot/navigable/transition/TransitionFactoryTest.kt index 026370d..0c94bbe 100644 --- a/kompot/src/test/kotlin/com/revolut/kompot/navigable/transition/TransitionFactoryTest.kt +++ b/kompot/src/test/kotlin/com/revolut/kompot/navigable/transition/TransitionFactoryTest.kt @@ -16,7 +16,10 @@ package com.revolut.kompot.navigable.transition +import com.revolut.kompot.common.ModalDestination +import com.revolut.kompot.navigable.ModalTransitionAnimation import com.revolut.kompot.navigable.TransitionAnimation +import kotlinx.parcelize.Parcelize import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.MethodSource @@ -43,10 +46,33 @@ internal class TransitionFactoryTest { @JvmStatic fun transitionCreationTestArgs() = arrayOf( arrayOf(TransitionAnimation.NONE, ImmediateTransition::class), - arrayOf(TransitionAnimation.SLIDE_RIGHT_TO_LEFT, SlideTransition::class), arrayOf(TransitionAnimation.SLIDE_LEFT_TO_RIGHT, SlideTransition::class), + arrayOf(TransitionAnimation.SLIDE_RIGHT_TO_LEFT, SlideTransition::class), arrayOf(TransitionAnimation.FADE, FadeTransition::class), + arrayOf(ModalTransitionAnimation.ModalPopup(), ModalShiftTransition::class), + arrayOf(ModalTransitionAnimation.ModalFullscreenSlideFromBottom(), ModalShiftTransition::class), + arrayOf(ModalTransitionAnimation.BottomDialog(), ModalShiftTransition::class), + arrayOf(TestCustomTransitionAnimation, CustomTransition::class), + arrayOf( + ModalTransitionAnimation.ModalFullscreenFade( + showImmediately = false, + style = ModalDestination.Style.FULLSCREEN_IMMEDIATE + ), + ModalShiftTransition::class + ), + arrayOf( + ModalTransitionAnimation.ModalFullscreenFade( + showImmediately = true, + style = ModalDestination.Style.FULLSCREEN_FADE + ), + ModalShiftTransition::class + ), ) } + @Parcelize + private object TestCustomTransitionAnimation : TransitionAnimation.Custom { + override val providerId: Int get() = 0 + } + } \ No newline at end of file diff --git a/kompot/src/test/kotlin/com/revolut/kompot/navigable/vc/ViewControllerTest.kt b/kompot/src/test/kotlin/com/revolut/kompot/navigable/vc/ViewControllerTest.kt new file mode 100644 index 0000000..7f660d9 --- /dev/null +++ b/kompot/src/test/kotlin/com/revolut/kompot/navigable/vc/ViewControllerTest.kt @@ -0,0 +1,245 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.vc + +import android.content.Context +import android.os.Build +import android.os.Bundle +import android.os.Parcelable +import android.view.ContextThemeWrapper +import android.view.LayoutInflater +import androidx.test.core.app.ApplicationProvider +import com.nhaarman.mockitokotlin2.mock +import com.revolut.kompot.common.ModalDestination +import com.revolut.kompot.navigable.ControllerManager +import com.revolut.kompot.navigable.ModalTransitionAnimation +import com.revolut.kompot.navigable.TransitionAnimation +import com.revolut.kompot.navigable.cache.DefaultControllersCache +import com.revolut.kompot.navigable.components.TestControllerView +import com.revolut.kompot.navigable.components.TestFlowViewController +import com.revolut.kompot.navigable.components.TestFlowViewControllerModel +import com.revolut.kompot.navigable.components.TestViewController +import com.revolut.kompot.navigable.hooks.ControllerHook +import com.revolut.kompot.navigable.hooks.ControllerViewContextHook +import com.revolut.kompot.navigable.hooks.HooksProvider +import com.revolut.kompot.navigable.vc.ViewController.Companion.CONTAINER_VIEW_STATE_KEY +import com.revolut.kompot.utils.StubMainThreadRule +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [Build.VERSION_CODES.N]) +internal class ViewControllerTest { + + @[Rule JvmField] + val stubMainThreadRule = StubMainThreadRule() + + @Test + fun `GIVEN view context hook WHEN getViewInflater THEN apply context from hook`() { + val controller = TestViewController("") + val controllerManager = ControllerManager( + defaultControllerContainer = 1, + controllersCache = DefaultControllersCache(20), + controllerViewHolder = mock(), + modal = false, + ).apply { + hooksProvider = TestViewCtxHookProvider() + } + + controller.bind(controllerManager, null) + + val baseCtx = ApplicationProvider.getApplicationContext() + val actualInflater = controller.getViewInflater(LayoutInflater.from(baseCtx)) + val expectedCtx = TestViewCtx(baseCtx) + + assertEquals(expectedCtx, actualInflater.context) + } + + @Test + fun `GIVEN controller theme id WHEN getViewInflater THEN themed context applied to inflater`() { + val baseInflater = LayoutInflater.from(ApplicationProvider.getApplicationContext()) + + val actualInflater = TestViewController("", themeId = 42).getViewInflater(baseInflater) + assertTrue(actualInflater.context is ContextThemeWrapper) + } + + @Test + fun `GIVEN view context hook, theme id WHEN getViewInflater THEN theme id and context hook applied to base context`() { + val controller = TestViewController("", themeId = 42) + val controllerManager = ControllerManager( + defaultControllerContainer = 1, + controllersCache = DefaultControllersCache(20), + controllerViewHolder = mock(), + modal = false, + ).apply { + hooksProvider = TestViewCtxHookProvider() + } + + controller.bind(controllerManager, null) + + val baseCtx = ApplicationProvider.getApplicationContext() + val actualInflater = controller.getViewInflater(LayoutInflater.from(baseCtx)) + val actualContext = actualInflater.context + val actualIntermediateContext = (actualContext as? ContextThemeWrapper)?.baseContext + + assertTrue(actualContext is ContextThemeWrapper) + assertEquals(actualIntermediateContext, TestViewCtx(baseCtx)) + } + + @Test + fun `GIVEN no hooks, no theme id WHEN getViewInflater THEN return base inflater`() { + val baseInflater = LayoutInflater.from(ApplicationProvider.getApplicationContext()) + + val actualInflater = TestViewController("").getViewInflater(baseInflater) + assertEquals(baseInflater, actualInflater) + } + + @Test + fun `GIVEN bound popup enter transition WHEN request modal style THEN return popup style`() { + val controller = TestViewController("").apply { + bind(mock(), mock(), ModalTransitionAnimation.ModalPopup()) + } + + assertEquals(ModalDestination.Style.POPUP, controller.environment.modalStyle) + } + + @Test + fun `GIVEN bound default transition WHEN request modal style THEN return null`() { + val controller = TestViewController("").apply { + bind(mock(), mock(), TransitionAnimation.SLIDE_LEFT_TO_RIGHT) + } + + assertNull(controller.environment.modalStyle) + } + + @Test + fun `GIVEN saved state WHEN restore from saved state THEN restore view state`() { + val viewMarker = 42 + val controller = TestViewController("", viewMarker = viewMarker) + + val bundle = Bundle() + controller.onCreate() + controller.onAttach() + controller.saveState(bundle) + + val restoredController = TestViewController("") + restoredController.restoreState(bundle) + restoredController.onCreate() + restoredController.onAttach() + + val restoredView = restoredController.view as TestControllerView + assertEquals(viewMarker, restoredView.restoredMarker) + } + + @Test + fun `GIVEN view saved state disabled WHEN restore from saved state THEN don't restore view state`() { + val controller = TestViewController("", viewSavedStateEnabled = false, viewMarker = 42) + + val bundle = Bundle() + controller.onCreate() + controller.onAttach() + controller.saveState(bundle) + + val restoredController = TestViewController("") + restoredController.restoreState(bundle) + restoredController.onCreate() + restoredController.onAttach() + + val restoredView = restoredController.view as TestControllerView + assertNull(restoredView.restoredMarker) + } + + @Test + fun `GIVEN flow saved state WHEN save state THEN store flow view state only in flow state`() { + val flowModel = TestFlowViewControllerModel() + val flow = TestFlowViewController(flowModel) + + val bundle = Bundle() + flow.onCreate() + flow.onAttach() + flow.saveState(bundle) + + val flowContainerState = bundle.getSparseParcelableArray(CONTAINER_VIEW_STATE_KEY) + assertEquals(1, flowContainerState!!.size()) + } + + @Test + fun `GIVEN flow saved state WHEN restore from saved state THEN restore child controller view`() { + val flowModel = TestFlowViewControllerModel() + val flow = TestFlowViewController(flowModel) + + val bundle = Bundle() + flow.onCreate() + flow.onAttach() + flow.saveState(bundle) + + val restoredFlowModel = TestFlowViewControllerModel() + val restoredFlow = TestFlowViewController(restoredFlowModel) + + restoredFlow.restoreState(bundle) + restoredFlow.onCreate() + restoredFlow.onAttach() + + val flowChild = restoredFlow.currentController as TestViewController + val flowChildView = flowChild.view as TestControllerView + assertNotNull(flowChildView.restoredMarker) + } + + @Test + fun `GIVEN controller restored from saved state WHEN check model restored THEN model restored`() { + val controller = TestViewController("") + + val bundle = Bundle() + controller.onCreate() + controller.onAttach() + controller.saveState(bundle) + + val restoredController = TestViewController("") + restoredController.restoreState(bundle) + restoredController.onCreate() + restoredController.onAttach() + + assertTrue(restoredController.model._restored) + } + + @Test + fun `GIVEN controller not restored from saved state WHEN check model restored THEN model not restored`() { + val controller = TestViewController("") + + controller.onCreate() + controller.onAttach() + + assertFalse(controller.model._restored) + } + + private class TestViewCtxHookProvider : HooksProvider { + override fun getHook(key: ControllerHook.Key): T = + ControllerViewContextHook { _, ctx -> + TestViewCtx(ctx) + } as T + } + + private data class TestViewCtx(val baseCtx: Context) : ContextThemeWrapper(baseCtx, 42) +} \ No newline at end of file diff --git a/kompot/src/test/kotlin/com/revolut/kompot/navigable/vc/flow/FlowViewControllerNavigationTest.kt b/kompot/src/test/kotlin/com/revolut/kompot/navigable/vc/flow/FlowViewControllerNavigationTest.kt new file mode 100644 index 0000000..b97231f --- /dev/null +++ b/kompot/src/test/kotlin/com/revolut/kompot/navigable/vc/flow/FlowViewControllerNavigationTest.kt @@ -0,0 +1,169 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.vc.flow + +import android.os.Build +import android.os.Bundle +import com.revolut.kompot.navigable.TransitionAnimation +import com.revolut.kompot.navigable.components.TestFlowViewController +import com.revolut.kompot.navigable.components.TestFlowViewControllerModel +import com.revolut.kompot.navigable.components.TestStep +import com.revolut.kompot.utils.StubMainThreadRule +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [Build.VERSION_CODES.N]) +internal class FlowViewControllerNavigationTest { + + @[Rule JvmField] + val stubMainThreadRule = StubMainThreadRule() + + private val flowModel = TestFlowViewControllerModel() + private val flow = TestFlowViewController(flowModel) + + @Test + fun `GIVEN initial step WHEN attached THEN render initial step`() { + flow.onCreate() + flow.onAttach() + + flow.assertStateRendered(stateValue = 1) + } + + @Test + fun `GIVEN initial step WHEN navigate forward THEN go to next step`() { + flow.onCreate() + flow.onAttach() + + flowModel.flowCoordinator.next( + TestStep(2), + addCurrentStepToBackStack = true, + animation = TransitionAnimation.NONE, + executeImmediately = false, + ) + + flow.assertStateRendered(stateValue = 2) + } + + @Test + fun `WHEN navigate backward THEN return to previous step`() { + flow.onCreate() + flow.onAttach() + + flowModel.flowCoordinator.next( + TestStep(2), + addCurrentStepToBackStack = true, + animation = TransitionAnimation.NONE, + executeImmediately = false, + ) + flow.handleBack() + + flow.assertStateRendered(stateValue = 1) + } + + @Test + fun `GIVEN step 2 not added to back stack WHEN navigate backward from step 2 THEN go to step 1`() { + flow.onCreate() + flow.onAttach() + + flowModel.flowCoordinator.next( + TestStep(2), + addCurrentStepToBackStack = true, + animation = TransitionAnimation.NONE, + executeImmediately = false, + ) + flowModel.flowCoordinator.next( + TestStep(3), + addCurrentStepToBackStack = false, + animation = TransitionAnimation.NONE, + executeImmediately = false, + ) + flow.handleBack() + + flow.assertStateRendered(stateValue = 1) + } + + @Test + fun `GIVEN flow with step 2 opened WHEN restore from saved state THEN restore step 2`() { + flow.onCreate() + flow.onAttach() + + flowModel.flowCoordinator.next( + TestStep(2), + addCurrentStepToBackStack = true, + animation = TransitionAnimation.NONE, + executeImmediately = false, + ) + + val bundle = Bundle() + flow.saveState(bundle) + + val restoredFlowModel = TestFlowViewControllerModel() + val restoredFlow = TestFlowViewController(restoredFlowModel) + + restoredFlow.restoreState(bundle) + + restoredFlow.onCreate() + restoredFlow.onAttach() + + restoredFlow.assertStateRendered(stateValue = 2) + } + + @Test + fun `GIVEN initial step WHEN transition to step 2 canceled THEN revert to initial state`() { + flow.onCreate() + flow.onAttach() + + val initialController = flowModel.flowCoordinator.getCurrentController() + + flowModel.flowCoordinator.next( + TestStep(2), + addCurrentStepToBackStack = true, + animation = TransitionAnimation.NONE, + executeImmediately = false, + ) + flow.mainControllerManager.onTransitionCanceled( + from = initialController, + backward = false, + ) + + flow.assertStateRendered(stateValue = 1) + } + + @Test + fun `GIVEN initial backstack WHEN navigate backward THEN return to predefined backstack steps`() { + val flowModel = TestFlowViewControllerModel( + initialBackStack = listOf( + BackStackEntry(TestStep(41), TransitionAnimation.NONE), + BackStackEntry(TestStep(42), TransitionAnimation.NONE), + ) + ) + val flow = TestFlowViewController(flowModel) + + flow.onCreate() + flow.onAttach() + flow.assertStateRendered(stateValue = 1) + + flow.handleBack() + flow.assertStateRendered(stateValue = 42) + flow.handleBack() + flow.assertStateRendered(stateValue = 41) + } +} \ No newline at end of file diff --git a/kompot/src/test/kotlin/com/revolut/kompot/navigable/vc/modal/ModalHostTest.kt b/kompot/src/test/kotlin/com/revolut/kompot/navigable/vc/modal/ModalHostTest.kt new file mode 100644 index 0000000..c8f9ba4 --- /dev/null +++ b/kompot/src/test/kotlin/com/revolut/kompot/navigable/vc/modal/ModalHostTest.kt @@ -0,0 +1,206 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.vc.modal + +import android.os.Build +import android.os.Bundle +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.argumentCaptor +import com.nhaarman.mockitokotlin2.eq +import com.nhaarman.mockitokotlin2.times +import com.nhaarman.mockitokotlin2.verify +import com.revolut.kompot.common.ModalDestination +import com.revolut.kompot.navigable.components.TestFlowViewController +import com.revolut.kompot.navigable.components.TestFlowViewControllerModel +import com.revolut.kompot.navigable.components.TestRootFlow +import com.revolut.kompot.navigable.components.TestStep +import com.revolut.kompot.navigable.components.TestViewController +import com.revolut.kompot.navigable.root.RootNavigator +import com.revolut.kompot.utils.StubMainThreadRule +import org.junit.Rule +import org.junit.Test +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [Build.VERSION_CODES.N]) +class ModalHostTest { + + @[Rule JvmField] + val stubMainThreadRule = StubMainThreadRule() + + private val modalHostModel = TestFlowViewControllerModel() + private val modalHost = TestFlowViewController(modalHostModel) + private val rootFlow = TestRootFlow(modalHost) + + @Test + fun `WHEN modal called THEN root flow opens a modal`() { + rootFlow.onCreate() + modalHost.onAttach() + rootFlow.onAttach() + + modalHostModel.flowCoordinator.openModal(TestStep(2), style = ModalDestination.Style.POPUP) + + rootFlow.model.rootNavigator.assertModalControllersOpened(listOf("2")) + } + + @Test + fun `GIVEN multiple opened modals WHEN state restored THEN restore modals in-order`() { + rootFlow.onCreate() + modalHost.onAttach() + rootFlow.onAttach() + rootFlow.model.onControllersFirstLayout() + + modalHostModel.flowCoordinator.openModal(TestStep(3), style = ModalDestination.Style.POPUP) + modalHostModel.flowCoordinator.openModal(TestStep(2), style = ModalDestination.Style.POPUP) + rootFlow.model.rootNavigator.triggerLatestModalsCreation(count = 2) + + val bundle = Bundle() + rootFlow.saveState(bundle) + + val restoredModalHostModel = TestFlowViewControllerModel() + val restoredModalHost = TestFlowViewController(restoredModalHostModel) + val restoredRootFlow = TestRootFlow(restoredModalHost) + + restoredRootFlow.doOnCreate { restoredRootFlow.restoreState(bundle) } + + restoredRootFlow.onCreate() + restoredModalHost.onAttach() + restoredRootFlow.onAttach() + restoredRootFlow.model.onControllersFirstLayout() + + restoredRootFlow.model.rootNavigator.assertModalControllersOpened(listOf("3", "2"), shownImmediately = true) + } + + @Test + fun `GIVEN modals opened and then destroyed WHEN state restored THEN don't trigger modals`() { + rootFlow.onCreate() + modalHost.onAttach() + rootFlow.onAttach() + rootFlow.model.onControllersFirstLayout() + + modalHostModel.flowCoordinator.openModal(TestStep(3), style = ModalDestination.Style.POPUP) + modalHostModel.flowCoordinator.openModal(TestStep(2), style = ModalDestination.Style.POPUP) + rootFlow.model.rootNavigator.triggerLatestModalsCreation(count = 2) + rootFlow.model.rootNavigator.triggerLatestModalsDestruction(count = 2) + + val bundle = Bundle() + rootFlow.saveState(bundle) + + val restoredModalHostModel = TestFlowViewControllerModel() + val restoredModalHost = TestFlowViewController(restoredModalHostModel) + val restoredRootFlow = TestRootFlow(restoredModalHost) + + restoredRootFlow.doOnCreate { restoredRootFlow.restoreState(bundle) } + + restoredRootFlow.onCreate() + restoredModalHost.onAttach() + restoredRootFlow.onAttach() + restoredRootFlow.model.onControllersFirstLayout() + + restoredRootFlow.model.rootNavigator.assertModalControllersOpened(emptyList(), shownImmediately = true) + } + + @Test + fun `GIVEN opened modals WHEN postponed state restored after root attached THEN restore modals`() { + rootFlow.onCreate() + modalHost.onAttach() + rootFlow.onAttach() + rootFlow.model.onControllersFirstLayout() + + modalHostModel.flowCoordinator.openModal(TestStep(2), style = ModalDestination.Style.POPUP) + modalHostModel.flowCoordinator.openModal(TestStep(3), style = ModalDestination.Style.POPUP) + rootFlow.model.rootNavigator.triggerLatestModalsCreation(count = 2) + + val bundle = Bundle() + rootFlow.saveState(bundle) + + val restoredModalHostModel = TestFlowViewControllerModel() + val restoredModalHost = TestFlowViewController(restoredModalHostModel) + val restoredRootFlow = TestRootFlow(TestViewController(""), postponeStateRestore = true) + + restoredRootFlow.doOnCreate { restoredRootFlow.restoreState(bundle) } + + restoredRootFlow.onCreate() + restoredRootFlow.onAttach() + restoredRootFlow.model.onControllersFirstLayout() + + restoredRootFlow.model.child = restoredModalHost + restoredRootFlow.model.startPostponedSavedStateRestore() + + restoredRootFlow.model.rootNavigator.assertModalControllersOpened(listOf("2", "3"), shownImmediately = true) + } + + @Test + fun `GIVEN opened modals WHEN postponed state restored before root attached THEN restore modals`() { + rootFlow.onCreate() + modalHost.onAttach() + rootFlow.onAttach() + rootFlow.model.onControllersFirstLayout() + + modalHostModel.flowCoordinator.openModal(TestStep(2), style = ModalDestination.Style.POPUP) + modalHostModel.flowCoordinator.openModal(TestStep(3), style = ModalDestination.Style.POPUP) + rootFlow.model.rootNavigator.triggerLatestModalsCreation(count = 2) + + val bundle = Bundle() + rootFlow.saveState(bundle) + + val restoredModalHostModel = TestFlowViewControllerModel() + val restoredModalHost = TestFlowViewController(restoredModalHostModel) + val restoredRootFlow = TestRootFlow(TestViewController(""), postponeStateRestore = true) + + restoredRootFlow.doOnCreate { restoredRootFlow.restoreState(bundle) } + + restoredRootFlow.onCreate() + restoredRootFlow.model.child = restoredModalHost + restoredRootFlow.model.startPostponedSavedStateRestore() + restoredRootFlow.onAttach() + restoredRootFlow.model.onControllersFirstLayout() + + restoredRootFlow.model.rootNavigator.assertModalControllersOpened(listOf("2", "3"), shownImmediately = true) + } + + private fun RootNavigator.assertModalControllersOpened(descriptors: List, shownImmediately: Boolean = false) { + val modalCommandsCaptor = argumentCaptor() + verify(this, times(descriptors.size)).openModal(modalCommandsCaptor.capture(), callerController = any(), showImmediately = eq(shownImmediately)) + val actualDescriptors = modalCommandsCaptor.allValues.map { + (it.controller as TestViewController).input + } + assertEquals(descriptors, actualDescriptors) + } + + private fun RootNavigator.triggerLatestModalsCreation(count: Int) { + val modalCommandCaptor = argumentCaptor() + verify(this, times(count)).openModal(modalCommandCaptor.capture(), callerController = any(), showImmediately = any()) + modalCommandCaptor.allValues.forEach { + val openedController = it.controller as TestViewController + openedController.onCreate() + } + } + + private fun RootNavigator.triggerLatestModalsDestruction(count: Int) { + val modalCommandCaptor = argumentCaptor() + verify(this, times(count)).openModal(modalCommandCaptor.capture(), callerController = any(), showImmediately = any()) + modalCommandCaptor.allValues.forEach { + val openedController = it.controller as TestViewController + openedController.onDestroy() + } + } +} diff --git a/kompot/src/test/kotlin/com/revolut/kompot/navigable/vc/ui/UIStatesControllerTest.kt b/kompot/src/test/kotlin/com/revolut/kompot/navigable/vc/ui/UIStatesControllerTest.kt new file mode 100644 index 0000000..56e28fe --- /dev/null +++ b/kompot/src/test/kotlin/com/revolut/kompot/navigable/vc/ui/UIStatesControllerTest.kt @@ -0,0 +1,162 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.navigable.vc.ui + +import android.os.Bundle +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.mock +import com.revolut.kompot.coroutines.test.TestDispatcherExtension +import com.revolut.kompot.navigable.components.TestDomainState +import com.revolut.kompot.navigable.components.TestPersistentDomainState +import com.revolut.kompot.navigable.components.TestUIPersistentStatesViewControllerModel +import com.revolut.kompot.navigable.components.TestUIState +import com.revolut.kompot.navigable.components.TestUIStatesViewController +import com.revolut.kompot.navigable.components.TestUIStatesViewControllerModel +import com.revolut.kompot.navigable.hooks.HooksProvider +import com.revolut.kompot.navigable.hooks.PersistentModelStateStorageHook +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.jupiter.api.Assertions +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +internal class UIStatesControllerTest { + + private val model = TestUIStatesViewControllerModel() + private val screen = TestUIStatesViewController(model) + + @Test + fun `GIVEN initial state WHEN attached THEN render initial state`() { + screen.onCreate() + screen.onAttach() + + assertEquals(1, model.state.current.value) + screen.assertRenderedStates(listOf(TestUIState(1))) + } + + @Test + fun `GIVEN initial state WHEN state updated THEN render updated states`() { + screen.onCreate() + screen.onAttach() + + model.state.update { TestDomainState(2) } + model.state.update { TestDomainState(3) } + model.state.update { TestDomainState(4) } + + assertEquals(4, model.state.current.value) + screen.assertRenderedStates( + listOf( + TestUIState(1), + TestUIState(2), + TestUIState(3), + TestUIState(4), + ) + ) + } + + @Test + fun `GIVEN screen with updated state WHEN restore from saved state THEN restore latest state`() { + screen.onCreate() + screen.onAttach() + + model.state.update { TestDomainState(2) } + + val bundle = Bundle() + screen.saveState(bundle) + + val restoredModel = TestUIStatesViewControllerModel() + val restoredScreen = TestUIStatesViewController(restoredModel) + + restoredScreen.restoreState(bundle) + restoredScreen.onCreate() + restoredScreen.onAttach() + + assertEquals(2, restoredModel.state.current.value) + restoredScreen.assertRenderedStates( + listOf( + TestUIState(2), + ) + ) + } + + @Test + fun `GIVEN mapStateInBackground is false THEN all emissions on default`() = com.revolut.kompot.coroutines.test.dispatchBlockingTest { + val model = TestUIStatesViewControllerModel(mapStatesInBackground = false) + val screen = TestUIStatesViewController(model) + screen.onCreate() + screen.onAttach() + model.state.update { copy(value = 2) } + val mappingThreads = model.mapper.mappingThreads + Assertions.assertTrue(mappingThreads[0] == mappingThreads[1]) + } + + @Test + fun `GIVEN stateReducer for the persistent state THEN reduced state will be emitted`() = com.revolut.kompot.coroutines.test.dispatchBlockingTest { + + val storage: PersistentModelStateStorage = mock { + on { get(PersistentModelStateKey(TestUIPersistentStatesViewControllerModel.STORAGE_KEY)) } doReturn TestPersistentDomainState(2) + } + val hookProvider: HooksProvider = mock { + on { getHook(PersistentModelStateStorageHook.Key) } doReturn PersistentModelStateStorageHook(storage) + } + + val model = TestUIPersistentStatesViewControllerModel( + initialState = TestPersistentDomainState(1), + stateReducer = { _, restoredState -> + restoredState.copy(value = 3) + } + ) + val screen = TestUIStatesViewController( + model, + hookProvider + ) + screen.onCreate() + screen.onAttach() + screen.assertRenderedStates( + listOf( + TestUIState(3), + ) + ) + } + + @Test + fun `GIVEN mapStateInBackground is true THEN first state emission mapped on default and others on worker thread`() { + val dispatcherExtension = TestDispatcherExtension() + dispatcherExtension.beforeAll(null) + com.revolut.kompot.coroutines.test.dispatchBlockingTest { + val model = TestUIStatesViewControllerModel(mapStatesInBackground = true) + val screen = TestUIStatesViewController(model) + screen.onCreate() + screen.onAttach() + model.state.update { copy(value = 2) } + model.state.update { copy(value = 3) } + val mappingThreads = model.mapper.mappingThreads + //same thread + Assertions.assertTrue(mappingThreads[0] == mappingThreads[1]) + //different thread after onAttach + Assertions.assertTrue(mappingThreads[0] != mappingThreads[2]) + } + dispatcherExtension.afterAll(null) + } + + private fun TestUIStatesViewController<*>.assertRenderedStates(states: List) { + assertEquals(states, renderedStates) + } +} \ No newline at end of file diff --git a/kompot/src/test/kotlin/com/revolut/kompot/utils/StubMainThreadRule.kt b/kompot/src/test/kotlin/com/revolut/kompot/utils/StubMainThreadRule.kt new file mode 100644 index 0000000..632a7fa --- /dev/null +++ b/kompot/src/test/kotlin/com/revolut/kompot/utils/StubMainThreadRule.kt @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.utils + +import com.revolut.kompot.navigable.utils.Preconditions +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement + +@OptIn(ExperimentalCoroutinesApi::class) +class StubMainThreadRule : TestRule { + + override fun apply(base: Statement, d: Description): Statement { + return object : Statement() { + override fun evaluate() { + Preconditions.mainThreadRequirementEnabled = false + Dispatchers.setMain(UnconfinedTestDispatcher()) + try { + base.evaluate() + } finally { + Preconditions.mainThreadRequirementEnabled = true + Dispatchers.resetMain() + } + } + } + } +} \ No newline at end of file diff --git a/kompot/src/test/kotlin/com/revolut/kompot/view/ControllerContainerTest.kt b/kompot/src/test/kotlin/com/revolut/kompot/view/ControllerContainerTest.kt new file mode 100644 index 0000000..ea38d63 --- /dev/null +++ b/kompot/src/test/kotlin/com/revolut/kompot/view/ControllerContainerTest.kt @@ -0,0 +1,95 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.view + +import android.content.Context +import android.os.Build +import android.os.SystemClock +import android.view.MotionEvent +import android.view.ViewGroup +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE, sdk = [Build.VERSION_CODES.N]) +internal class ControllerContainerTest { + + @Test + fun `GIVEN no running transition WHEN dispatch touch event THEN don't intercept event`() { + getControllerContainers(ApplicationProvider.getApplicationContext()).forEach { container -> + assertFalse(container.dispatchTouchEvent(createMotionEvent())) + } + } + + @Test + fun `GIVEN running definite transition WHEN dispatch touch event THEN intercept event`() { + getControllerContainers(ApplicationProvider.getApplicationContext()).forEach { container -> + require(container is ControllerContainer) + container.onControllersTransitionStart(indefinite = false) + assertTrue(container.dispatchTouchEvent(createMotionEvent())) + } + } + + @Test + fun `GIVEN running indefinite transition WHEN dispatch touch event THEN don't intercept event`() { + getControllerContainers(ApplicationProvider.getApplicationContext()).forEach { container -> + require(container is ControllerContainer) + container.onControllersTransitionStart(indefinite = true) + assertFalse(container.dispatchTouchEvent(createMotionEvent())) + } + } + + @Test + fun `GIVEN ended transition WHEN dispatch touch event THEN don't intercept event`() { + getControllerContainers(ApplicationProvider.getApplicationContext()).forEach { container -> + require(container is ControllerContainer) + container.onControllersTransitionStart(indefinite = false) + container.onControllersTransitionEnd(indefinite = false) + assertFalse(container.dispatchTouchEvent(createMotionEvent())) + } + } + + @Test + fun `GIVEN canceled transition WHEN dispatch touch event THEN don't intercept event`() { + getControllerContainers(ApplicationProvider.getApplicationContext()).forEach { container -> + require(container is ControllerContainer) + container.onControllersTransitionStart(indefinite = false) + container.onControllersTransitionCanceled(indefinite = false) + assertFalse(container.dispatchTouchEvent(createMotionEvent())) + } + } + + private fun getControllerContainers(context: Context): List = listOf( + ControllerContainerFrameLayout(context), + ControllerContainerLinearLayout(context), + ControllerContainerConstraintLayout(context), + ) + + private fun createMotionEvent() = MotionEvent.obtain( + SystemClock.uptimeMillis(), + SystemClock.uptimeMillis(), + MotionEvent.ACTION_UP, + 0f, + 0f, + 0 + ) +} \ No newline at end of file diff --git a/kompot_core_test/build.gradle b/kompot_core_test/build.gradle index 6689c60..5d2f874 100644 --- a/kompot_core_test/build.gradle +++ b/kompot_core_test/build.gradle @@ -32,6 +32,7 @@ android { dependencies { implementation project(':kompot') + implementation project(':kompot_coroutines') implementation project(':kompot_coroutines_test') implementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion" diff --git a/kompot_core_test/gradle.properties b/kompot_core_test/gradle.properties index c17060c..4087cdc 100644 --- a/kompot_core_test/gradle.properties +++ b/kompot_core_test/gradle.properties @@ -1,21 +1,5 @@ -# -# Copyright (C) 2022 Revolut -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - POM_ARTIFACT_ID=core-test -VERSION_NAME=0.0.2 +VERSION_NAME=0.0.3 POM_NAME=core-test POM_PACKAGING=aar GROUP=com.revolut.kompot \ No newline at end of file diff --git a/kompot_core_test/src/main/java/com/revolut/kompot/core/test/assertion/BaseFlowModelAssertion.kt b/kompot_core_test/src/main/java/com/revolut/kompot/core/test/assertion/BaseFlowModelAssertion.kt new file mode 100644 index 0000000..73bffd8 --- /dev/null +++ b/kompot_core_test/src/main/java/com/revolut/kompot/core/test/assertion/BaseFlowModelAssertion.kt @@ -0,0 +1,110 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.core.test.assertion + +import android.annotation.SuppressLint +import com.revolut.kompot.common.EventsDispatcher +import com.revolut.kompot.common.IOData +import com.revolut.kompot.coroutines.Direct +import com.revolut.kompot.navigable.Controller +import com.revolut.kompot.navigable.binder.asFlow +import com.revolut.kompot.navigable.flow.Back +import com.revolut.kompot.navigable.flow.BaseFlowModel +import com.revolut.kompot.navigable.flow.FlowState +import com.revolut.kompot.navigable.flow.FlowStep +import com.revolut.kompot.navigable.flow.Next +import com.revolut.kompot.navigable.flow.PostFlowResult +import com.revolut.kompot.navigable.flow.Quit +import com.revolut.kompot.navigable.flow.StartPostponedStateRestore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.parcelize.Parcelize + +@SuppressLint("VisibleForTests") +internal class BaseFlowModelAssertion internal constructor( + private val flowModel: BaseFlowModel<*, STEP, OUTPUT> +) : CommonFlowModelAssertions() { + + override val eventsDispatcher: EventsDispatcher + get() = flowModel.eventsDispatcher + + override val hasBackStack: Boolean + get() = flowModel.hasBackStack + + init { + flowModel.applyTestDependencies(dialogDisplayer = dialogDisplayer) + flowModel.setInitialState() + + val childFlowModel = FakeFlowModel() + + @OptIn(ExperimentalCoroutinesApi::class) + testScope.launch(Dispatchers.Direct) { + flowModel.navigationBinder().asFlow().collect { command -> + when (command) { + is Next -> flowModel.setNextState( + command.step, + command.animation, + command.addCurrentStepToBackStack, + childFlowModel + ) + + is Back -> { + flowModel.handleBackStack(immediate = true) + commandQueue.add(command) + } + + is Quit, + is PostFlowResult, + is StartPostponedStateRestore -> { + commandQueue.add(command) + } + //Commands for internal communication are not supported + else -> {} + } + } + } + + flowModel.onCreated() + } + + override fun getCurrentController(): Controller = + flowModel.getController(flowModel.step) + + override fun getCurrentStep(): STEP = flowModel.step +} + +@SuppressLint("VisibleForTests") +private class FakeFlowModel : BaseFlowModel() { + + override val initialStep: FakeStep = FakeStep + override val initialState: FakeState = FakeState + + init { + setInitialState() + } + + override fun getController(step: FakeStep): Controller { + throw IllegalStateException() + } +} + +@Parcelize +private object FakeStep : FlowStep + +@Parcelize +private object FakeState : FlowState diff --git a/kompot_core_test/src/main/java/com/revolut/kompot/core/test/assertion/ControllerModelAssertions.kt b/kompot_core_test/src/main/java/com/revolut/kompot/core/test/assertion/ControllerModelAssertions.kt new file mode 100644 index 0000000..d335be6 --- /dev/null +++ b/kompot_core_test/src/main/java/com/revolut/kompot/core/test/assertion/ControllerModelAssertions.kt @@ -0,0 +1,233 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.core.test.assertion + +import com.nhaarman.mockitokotlin2.argumentCaptor +import com.nhaarman.mockitokotlin2.clearInvocations +import com.nhaarman.mockitokotlin2.never +import com.nhaarman.mockitokotlin2.times +import com.nhaarman.mockitokotlin2.verify +import com.revolut.kompot.common.EventsDispatcher +import com.revolut.kompot.common.IOData +import com.revolut.kompot.common.ModalDestination +import com.revolut.kompot.common.NavigationDestination +import com.revolut.kompot.common.NavigationEvent +import com.revolut.kompot.dialog.DialogDisplayer +import com.revolut.kompot.dialog.DialogModel +import com.revolut.kompot.dialog.DialogModelResult +import com.revolut.kompot.navigable.Controller +import com.revolut.kompot.navigable.flow.Flow +import com.revolut.kompot.navigable.screen.Screen +import com.revolut.kompot.navigable.vc.ViewController +import org.junit.jupiter.api.Assertions + +internal object ControllerModelAssertions { + + fun assertModalScreen( + eventsDispatcher: EventsDispatcher, + assertion: (Screen<*>) -> Boolean + ) { + argumentCaptor().apply { + verify(eventsDispatcher).handleEvent(capture()) + clearInvocations(eventsDispatcher) + val screen = (firstValue.destination as ModalDestination.ExplicitScreen<*>).screen + Assertions.assertTrue( + assertion(screen), + "\nAssertion failed for screen! Actual value: ${firstValue.destination}\n" + ) + } + } + + fun assertModalFlow( + eventsDispatcher: EventsDispatcher, + assertion: (Flow<*>) -> Boolean + ) { + argumentCaptor().apply { + verify(eventsDispatcher).handleEvent(capture()) + clearInvocations(eventsDispatcher) + val screen = (firstValue.destination as ModalDestination.ExplicitFlow<*>).flow + Assertions.assertTrue( + assertion(screen), + "\nAssertion failed for flow! Actual value: ${firstValue.destination}\n" + ) + } + } + + @Suppress("UNCHECKED_CAST") + fun assertModalScreen( + eventsDispatcher: EventsDispatcher, + outputToReturn: T, + assertion: (Screen) -> Boolean + ) { + argumentCaptor().apply { + verify(eventsDispatcher).handleEvent(capture()) + clearInvocations(eventsDispatcher) + val destination = firstValue.destination + if ((destination as? ModalDestination.ExplicitScreen) != null) { + Assertions.assertTrue( + assertion(destination.screen), + "\nAssertion failed for screen! Actual value: ${firstValue.destination}\n" + ) + + destination.onResult?.invoke(outputToReturn) + return + } + + // when launched using modalCoordinator in ViewControllerModel + if ((destination as? ModalDestination.CallbackController)?.controller != null) { + Assertions.assertTrue( + assertion(destination.controller as Screen), + "\nAssertion failed for screen! Actual value: ${firstValue.destination}\n" + ) + + (destination.controller as Screen).onScreenResult.invoke(outputToReturn) + return + } + + throw IllegalStateException("Can't assert modal screen\n$destination is not supported") + } + } + + @Suppress("UNCHECKED_CAST") + fun assertModalFlow( + eventsDispatcher: EventsDispatcher, + outputToReturn: T, + assertion: (Flow) -> Boolean + ) { + argumentCaptor().apply { + verify(eventsDispatcher).handleEvent(capture()) + clearInvocations(eventsDispatcher) + val destination = firstValue.destination + val flow = (destination as ModalDestination.ExplicitFlow).flow + Assertions.assertTrue( + assertion(flow), + "\nAssertion failed for flow! Actual value: ${firstValue.destination}\n" + ) + + destination.onResult?.invoke(outputToReturn) + } + } + + fun assertModalViewController( + eventsDispatcher: EventsDispatcher, + assertion: (ViewController<*>) -> Boolean + ) { + argumentCaptor().apply { + verify(eventsDispatcher).handleEvent(capture()) + clearInvocations(eventsDispatcher) + val controller = (firstValue.destination as ModalDestination.CallbackController).controller + Assertions.assertTrue( + assertion(controller as ViewController<*>), + "\nAssertion failed for viewController! Actual value: ${firstValue.destination}\n" + ) + } + } + + @Suppress("UNCHECKED_CAST") + fun assertModalViewController( + eventsDispatcher: EventsDispatcher, + outputToReturn: T, + assertion: (ViewController) -> Boolean + ) { + argumentCaptor().apply { + verify(eventsDispatcher).handleEvent(capture()) + clearInvocations(eventsDispatcher) + val controller = (firstValue.destination as ModalDestination.CallbackController).controller + Assertions.assertTrue( + assertion(controller as ViewController), + "\nAssertion failed for viewController! Actual value: ${firstValue.destination}\n" + ) + controller.postResult(outputToReturn) + } + } + + fun assertModalController( + eventsDispatcher: EventsDispatcher, + assertion: (Controller) -> Boolean + ) { + argumentCaptor().apply { + verify(eventsDispatcher).handleEvent(capture()) + clearInvocations(eventsDispatcher) + val controller = (firstValue.destination as ModalDestination.CallbackController).controller + Assertions.assertTrue( + assertion(controller), + "\nAssertion failed for controller! Actual value: ${firstValue.destination}\n" + ) + } + } + + fun assertDestination( + destination: NavigationDestination, + eventsDispatcher: EventsDispatcher, + ) { + argumentCaptor().apply { + verify(eventsDispatcher).handleEvent(capture()) + clearInvocations(eventsDispatcher) + Assertions.assertEquals( + destination, + firstValue.destination, + "\nAssertion failed for destination!" + ) + } + } + + fun assertNoNavigationEvent( + eventsDispatcher: EventsDispatcher, + ) { + argumentCaptor().apply { + verify(eventsDispatcher, never()).handleEvent(capture()) + clearInvocations(eventsDispatcher) + } + } + + fun assertDialog(dialogDisplayer: DialogDisplayer, model: DialogModel<*>) { + argumentCaptor>().apply { + verify(dialogDisplayer).showDialog(capture()) + clearInvocations(dialogDisplayer) + val dialogModel = firstValue + Assertions.assertEquals( + model, + dialogModel, + "\nAssertion failed for dialog!" + ) + } + } + + /** Useful for cases where we show 2+ dialogs one after another. `assertDialog()` implementation above + * doesn't support sequential invocation, in contrast to `FlowModelAssertion.assertDialog()` */ + fun assertDialogs(dialogDisplayer: DialogDisplayer, vararg models: DialogModel<*>) { + argumentCaptor>().apply { + verify(dialogDisplayer, times(models.size)).showDialog(capture()) + clearInvocations(dialogDisplayer) + models.forEachIndexed { index, model -> + val dialogModel = allValues[index] + Assertions.assertEquals( + model, + dialogModel, + "\nAssertion failed for dialog!" + ) + } + } + } + + fun assertNoDialog(dialogDisplayer: DialogDisplayer) { + argumentCaptor>().apply { + verify(dialogDisplayer, never()).showDialog(capture()) + clearInvocations(dialogDisplayer) + } + } +} diff --git a/kompot_core_test/src/main/java/com/revolut/kompot/core/test/assertion/ControllerModelExtensionAssertionExt.kt b/kompot_core_test/src/main/java/com/revolut/kompot/core/test/assertion/ControllerModelExtensionAssertionExt.kt new file mode 100644 index 0000000..32c2345 --- /dev/null +++ b/kompot_core_test/src/main/java/com/revolut/kompot/core/test/assertion/ControllerModelExtensionAssertionExt.kt @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.core.test.assertion + +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.whenever +import com.revolut.kompot.common.IOData +import com.revolut.kompot.common.NavigationDestination +import com.revolut.kompot.dialog.DialogModel +import com.revolut.kompot.dialog.DialogModelResult +import com.revolut.kompot.navigable.Controller +import com.revolut.kompot.navigable.ControllerModelExtension +import com.revolut.kompot.navigable.flow.Flow +import com.revolut.kompot.navigable.screen.Screen +import com.revolut.kompot.navigable.vc.ViewController +import kotlinx.coroutines.flow.flowOf + +fun ControllerModelExtension.assertModalScreen(assertion: (Screen<*>) -> Boolean) { + ControllerModelAssertions.assertModalScreen(parentEventsDispatcher, assertion) +} + +fun ControllerModelExtension.assertModalScreen(outputToReturn: T, assertion: (Screen) -> Boolean) { + ControllerModelAssertions.assertModalScreen(parentEventsDispatcher, outputToReturn, assertion) +} + +fun ControllerModelExtension.assertModalFlow(assertion: (Flow<*>) -> Boolean) { + ControllerModelAssertions.assertModalFlow(parentEventsDispatcher, assertion) +} + +fun ControllerModelExtension.assertModalFlow(outputToReturn: T, assertion: (Flow) -> Boolean) { + ControllerModelAssertions.assertModalFlow(parentEventsDispatcher, outputToReturn, assertion) +} + +fun ControllerModelExtension.assertModalViewController(assertion: (ViewController<*>) -> Boolean) { + ControllerModelAssertions.assertModalViewController(parentEventsDispatcher, assertion) +} + +fun ControllerModelExtension.assertModalController(assertion: (Controller) -> Boolean) { + ControllerModelAssertions.assertModalController(parentEventsDispatcher, assertion) +} + +fun ControllerModelExtension.assertDestination(destination: NavigationDestination) { + ControllerModelAssertions.assertDestination(destination, parentEventsDispatcher) +} + +fun ControllerModelExtension.assertNoNavigationEvent() { + ControllerModelAssertions.assertNoNavigationEvent(parentEventsDispatcher) +} + +fun ControllerModelExtension.assertDialog(model: DialogModel<*>) { + ControllerModelAssertions.assertDialog(parentDialogDisplayer, model) +} + +fun ControllerModelExtension.mockDialogResult(forModel: DialogModel<*>, resultToReturn: DialogModelResult) { + whenever(parentDialogDisplayer.showDialog(forModel)) doReturn flowOf(resultToReturn) +} + +fun ControllerModelExtension.assertDialogs(vararg models: DialogModel<*>) { + ControllerModelAssertions.assertDialogs(parentDialogDisplayer, *models) +} + +fun ControllerModelExtension.assertNoDialog() { + ControllerModelAssertions.assertNoDialog(parentDialogDisplayer) +} diff --git a/kompot_core_test/src/main/java/com/revolut/kompot/core/test/assertion/ControllerModelExtensionTestExt.kt b/kompot_core_test/src/main/java/com/revolut/kompot/core/test/assertion/ControllerModelExtensionTestExt.kt new file mode 100644 index 0000000..16aa8cb --- /dev/null +++ b/kompot_core_test/src/main/java/com/revolut/kompot/core/test/assertion/ControllerModelExtensionTestExt.kt @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.core.test.assertion + +import com.nhaarman.mockitokotlin2.mock +import com.revolut.kompot.common.EventsDispatcher +import com.revolut.kompot.dialog.DialogDisplayer +import com.revolut.kompot.navigable.ControllerModel +import com.revolut.kompot.navigable.ControllerModelExtension + +/** + * ControllerModel object is required for providing dependencies into ControllerModelExtension. + * The providing is done inside ControllerModel#injectDependencies + */ +fun T.applyTestDependencies( + dialogDisplayer: DialogDisplayer = mock(), + eventsDispatcher: EventsDispatcher = mock(), +): T = apply { + object : ControllerModel() {}.apply { + applyTestDependencies( + dialogDisplayer = dialogDisplayer, + eventsDispatcher = eventsDispatcher, + controllerModelExtensions = setOf(this@applyTestDependencies), + ) + } +} \ No newline at end of file diff --git a/kompot_core_test/src/main/java/com/revolut/kompot/core/test/assertion/ControllerModelTestExt.kt b/kompot_core_test/src/main/java/com/revolut/kompot/core/test/assertion/ControllerModelTestExt.kt new file mode 100644 index 0000000..c15a338 --- /dev/null +++ b/kompot_core_test/src/main/java/com/revolut/kompot/core/test/assertion/ControllerModelTestExt.kt @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.core.test.assertion + +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.whenever +import com.revolut.kompot.common.ControllerDescriptor +import com.revolut.kompot.common.ControllerHolder +import com.revolut.kompot.common.ControllerRequest +import com.revolut.kompot.common.EventsDispatcher +import com.revolut.kompot.common.IOData +import com.revolut.kompot.coroutines.test.TestContextProvider +import com.revolut.kompot.dialog.DialogDisplayer +import com.revolut.kompot.dialog.DialogModel +import com.revolut.kompot.dialog.DialogModelResult +import com.revolut.kompot.navigable.ControllerModel +import com.revolut.kompot.navigable.ControllerModelExtension +import com.revolut.kompot.navigable.binder.asFlow +import com.revolut.kompot.navigable.cache.ControllersCache +import com.revolut.kompot.navigable.screen.ScreenModel +import com.revolut.kompot.navigable.screen.ScreenStates +import com.revolut.kompot.navigable.vc.ViewController +import com.revolut.kompot.navigable.vc.ViewControllerModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf + +@OptIn(ExperimentalCoroutinesApi::class) +fun T.applyTestDependencies( + dialogDisplayer: DialogDisplayer = mock(), + eventsDispatcher: EventsDispatcher = mock(), + controllersCache: ControllersCache = FakeControllersCache(), + mainDispatcher: CoroutineDispatcher = TestContextProvider.unconfinedDispatcher(), + controllerModelExtensions: Set = emptySet(), +): T = apply { + injectDependencies( + dialogDisplayer = dialogDisplayer, + eventsDispatcher = eventsDispatcher, + controllersCache = controllersCache, + mainDispatcher = mainDispatcher, + controllerModelExtensions = controllerModelExtensions, + ) +} + +fun ViewControllerModel.resultStream() = resultsBinder().asFlow() + +fun ViewControllerModel.backStream() = backPressBinder().asFlow() + +fun ScreenModel.resultStream() = resultsBinder().asFlow() + +fun ScreenModel.backStream() = backPressBinder().asFlow() + +internal fun EventsDispatcher.bindDescriptor(descriptor: ControllerDescriptor, controller: ViewController) { + val controllerRequestResult = mock { + on { this.controller } doReturn controller + } + whenever(handleEvent(ControllerRequest(descriptor))) + .thenReturn(controllerRequestResult) +} + +fun ControllerModel.bindDescriptor(descriptor: ControllerDescriptor, controller: ViewController) = + eventsDispatcher.bindDescriptor(descriptor, controller) + +fun ControllerModelExtension.bindDescriptor(descriptor: ControllerDescriptor, controller: ViewController) = + parentEventsDispatcher.bindDescriptor(descriptor, controller) + +fun ControllerModel.assertDialog(model: DialogModel<*>) = + ControllerModelAssertions.assertDialog(dialogDisplayer, model) + +fun ControllerModel.mockDialogResult(forModel: DialogModel<*>, resultToReturn: DialogModelResult) { + whenever(dialogDisplayer.showDialog(forModel)) + .doReturn(flowOf(resultToReturn)) +} \ No newline at end of file diff --git a/kompot_core_test/src/main/kotlin/com/revolut/kompot/core/test/assertion/DummyFlow.kt b/kompot_core_test/src/main/java/com/revolut/kompot/core/test/assertion/DummyFlow.kt similarity index 92% rename from kompot_core_test/src/main/kotlin/com/revolut/kompot/core/test/assertion/DummyFlow.kt rename to kompot_core_test/src/main/java/com/revolut/kompot/core/test/assertion/DummyFlow.kt index 17ed32e..754abdb 100644 --- a/kompot_core_test/src/main/kotlin/com/revolut/kompot/core/test/assertion/DummyFlow.kt +++ b/kompot_core_test/src/main/java/com/revolut/kompot/core/test/assertion/DummyFlow.kt @@ -17,14 +17,14 @@ package com.revolut.kompot.core.test.assertion import com.revolut.kompot.common.IOData -import com.revolut.kompot.di.flow.ParentFlowComponent +import com.revolut.kompot.di.flow.BaseFlowComponent import com.revolut.kompot.navigable.flow.BaseFlow import com.revolut.kompot.navigable.flow.FlowModel import com.revolut.kompot.navigable.flow.FlowStep class DummyFlow(input: INPUT_DATA) : BaseFlow(input) { - override val component: ParentFlowComponent + override val component: BaseFlowComponent get() = throw NotImplementedError() override val flowModel: FlowModel get() = throw NotImplementedError() diff --git a/kompot_core_test/src/main/kotlin/com/revolut/kompot/core/test/assertion/DummyScreen.kt b/kompot_core_test/src/main/java/com/revolut/kompot/core/test/assertion/DummyScreen.kt similarity index 100% rename from kompot_core_test/src/main/kotlin/com/revolut/kompot/core/test/assertion/DummyScreen.kt rename to kompot_core_test/src/main/java/com/revolut/kompot/core/test/assertion/DummyScreen.kt diff --git a/kompot_core_test/src/main/kotlin/com/revolut/kompot/core/test/assertion/FakeControllersCache.kt b/kompot_core_test/src/main/java/com/revolut/kompot/core/test/assertion/FakeControllersCache.kt similarity index 96% rename from kompot_core_test/src/main/kotlin/com/revolut/kompot/core/test/assertion/FakeControllersCache.kt rename to kompot_core_test/src/main/java/com/revolut/kompot/core/test/assertion/FakeControllersCache.kt index bd6ab4f..ffd8639 100644 --- a/kompot_core_test/src/main/kotlin/com/revolut/kompot/core/test/assertion/FakeControllersCache.kt +++ b/kompot_core_test/src/main/java/com/revolut/kompot/core/test/assertion/FakeControllersCache.kt @@ -33,4 +33,5 @@ internal class FakeControllersCache : ControllersCache { override fun isControllerCached(controllerKey: ControllerKey): Boolean = false override fun clearCache() = Unit + override fun getCacheLogWithKeys(): String = "" } \ No newline at end of file diff --git a/kompot_core_test/src/main/java/com/revolut/kompot/core/test/assertion/FakeDialogDisplayerDelegate.kt b/kompot_core_test/src/main/java/com/revolut/kompot/core/test/assertion/FakeDialogDisplayerDelegate.kt new file mode 100644 index 0000000..83945a7 --- /dev/null +++ b/kompot_core_test/src/main/java/com/revolut/kompot/core/test/assertion/FakeDialogDisplayerDelegate.kt @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.core.test.assertion + +import com.revolut.kompot.dialog.DialogDisplayerDelegate +import com.revolut.kompot.dialog.DialogModel +import com.revolut.kompot.dialog.DialogModelResult + +internal class FakeDialogDisplayerDelegate( + private val dialogResultStream: kotlinx.coroutines.flow.Flow, + private val onShown: (DialogModel<*>) -> Unit +) : DialogDisplayerDelegate>() { + + override fun canHandle(dialogModel: DialogModel<*>) = true + + override fun showDialogInternal(dialogModel: DialogModel<*>) { + onShown(dialogModel) + } + + override fun hideDialog() { + //do nothing + } + + override fun startObservingResult(): kotlinx.coroutines.flow.Flow = dialogResultStream +} \ No newline at end of file diff --git a/kompot_core_test/src/main/java/com/revolut/kompot/core/test/assertion/FlowModelAssertion.kt b/kompot_core_test/src/main/java/com/revolut/kompot/core/test/assertion/FlowModelAssertion.kt new file mode 100644 index 0000000..212610b --- /dev/null +++ b/kompot_core_test/src/main/java/com/revolut/kompot/core/test/assertion/FlowModelAssertion.kt @@ -0,0 +1,363 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.core.test.assertion + +import com.nhaarman.mockitokotlin2.argumentCaptor +import com.nhaarman.mockitokotlin2.clearInvocations +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.verify +import com.revolut.kompot.common.ErrorEvent +import com.revolut.kompot.common.EventsDispatcher +import com.revolut.kompot.common.IOData +import com.revolut.kompot.common.ModalDestination +import com.revolut.kompot.common.NavigationDestination +import com.revolut.kompot.common.NavigationEvent +import com.revolut.kompot.coroutines.test.TestContextProvider +import com.revolut.kompot.dialog.DialogDisplayer +import com.revolut.kompot.dialog.DialogModel +import com.revolut.kompot.dialog.DialogModelResult +import com.revolut.kompot.navigable.Controller +import com.revolut.kompot.navigable.flow.Back +import com.revolut.kompot.navigable.flow.BaseFlowModel +import com.revolut.kompot.navigable.flow.Flow +import com.revolut.kompot.navigable.flow.FlowNavigationCommand +import com.revolut.kompot.navigable.flow.FlowStep +import com.revolut.kompot.navigable.flow.PostFlowResult +import com.revolut.kompot.navigable.flow.Quit +import com.revolut.kompot.navigable.flow.scroller.ScrollerFlow +import com.revolut.kompot.navigable.screen.Screen +import com.revolut.kompot.navigable.vc.ViewController +import com.revolut.kompot.navigable.vc.ViewControllerModel +import com.revolut.kompot.navigable.vc.flow.FlowViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import org.junit.jupiter.api.Assertions +import java.util.LinkedList +import java.util.Queue + +fun BaseFlowModel<*, STEP, OUTPUT>.test() + : FlowModelAssertion = BaseFlowModelAssertion(this) + +fun T.test() + : FlowModelAssertion where T : FlowViewModel, + T : ViewControllerModel = + ViewControllerFlowModelAssertion(this) + +interface FlowModelAssertion { + + fun assertStep(step: STEP, result: T): FlowModelAssertion + + fun assertStep( + step: STEP, + result: T, + controllerAccessor: (Controller) -> Unit, + ): FlowModelAssertion + + fun assertStep(step: STEP): FlowModelAssertion + fun assertResult(output: OUTPUT) + fun assertBack(): FlowModelAssertion + fun assertQuitFlow() + fun assertDestination(destination: NavigationDestination) + fun assertDestination(assertion: (NavigationDestination) -> Boolean) + fun assertError(assertion: (Throwable) -> Boolean) + fun assertModalScreen(assertion: (Screen<*>) -> Boolean): FlowModelAssertion + fun assertHasBackStack(): FlowModelAssertion + fun assertNoBackStack(): FlowModelAssertion + + fun assertModalViewController( + assertion: (ViewController) -> Boolean, + output: T + ): FlowModelAssertion + + fun assertModalScreenFromFlowCoordinator( + assertion: (Screen<*>) -> Boolean, + ): FlowModelAssertion + + fun assertModalScreen( + output: T, + assertion: (Screen) -> Boolean + ): FlowModelAssertion + + fun assertModalFlow(assertion: (Flow<*>) -> Boolean): FlowModelAssertion + + fun assertModalFlow( + output: T, + assertion: (Flow) -> Boolean + ): FlowModelAssertion + + fun assertDialog( + model: DialogModel<*>, + result: DialogModelResult + ): FlowModelAssertion + + fun , RESULT : DialogModelResult> assertDialog( + assertion: (actual: MODEL) -> RESULT? + ): FlowModelAssertion +} + +internal abstract class CommonFlowModelAssertions : + FlowModelAssertion { + protected val testScope = TestContextProvider.unconfinedTestScope() + + private val dialogResultStream = MutableSharedFlow(extraBufferCapacity = 16) + protected val dialogDisplayer = DialogDisplayer( + loadingDialogDisplayer = mock(), + delegates = listOf( + FakeDialogDisplayerDelegate( + dialogResultStream = dialogResultStream, + onShown = { model -> + dialogQueue.add(model) + } + ) + ) + ) + private val dialogQueue: Queue> = LinkedList() + protected val commandQueue: Queue> = LinkedList() + + abstract val eventsDispatcher: EventsDispatcher + abstract val hasBackStack: Boolean + abstract fun getCurrentController(): Controller + abstract fun getCurrentStep(): STEP + + override fun assertStep(step: STEP, result: T) = apply { + assertStepInternal(step) + finishStepWithResult(getCurrentController(), result) + } + + override fun assertStep( + step: STEP, + result: T, + controllerAccessor: (Controller) -> Unit, + ): FlowModelAssertion { + assertStepInternal(step) + + val controller = getCurrentController() + controllerAccessor.invoke(controller) + + finishStepWithResult(controller, result) + return this + } + + override fun assertStep(step: STEP) = apply { + assertStepInternal(step) + } + + private fun assertStepInternal(step: STEP) { + Assertions.assertEquals( + step, + getCurrentStep(), + "\nCurrent step is different than expected!" + ) + } + + @Suppress("UNCHECKED_CAST") + private fun finishStepWithResult(controller: Controller, result: T) { + when (controller) { + is Flow<*> -> (controller as Flow).onFlowResult(result) + is ScrollerFlow<*> -> (controller as ScrollerFlow).onFlowResult(result) + is Screen<*> -> (controller as Screen).onScreenResult(result) + is ViewController<*> -> (controller as ViewController).postResult(result) + } + } + + override fun assertResult(output: OUTPUT) { + val lastCommand = commandQueue.poll() + assertResult(lastCommand) + val result = (lastCommand as PostFlowResult).data + Assertions.assertEquals( + output, + result, + "\nResult is different than expected!" + ) + } + + private fun assertResult(command: FlowNavigationCommand?) { + Assertions.assertTrue( + command is PostFlowResult, + "\nExpected PostFlowResult but was: $command!" + ) + } + + override fun assertBack() = apply { + val lastCommand = commandQueue.poll() + Assertions.assertTrue( + lastCommand is Back, + "\nExpected Back but was: $lastCommand!" + ) + } + + override fun assertQuitFlow() { + val lastCommand = commandQueue.poll() + Assertions.assertTrue( + lastCommand is Quit, + "\nExpected Quit but was: $lastCommand!" + ) + } + + override fun assertDestination(destination: NavigationDestination) { + argumentCaptor().apply { + verify(eventsDispatcher).handleEvent(capture()) + clearInvocations(eventsDispatcher) + Assertions.assertEquals( + destination, + firstValue.destination, + "\nAssertion failed for destination!" + ) + } + } + + override fun assertDestination(assertion: (NavigationDestination) -> Boolean) { + argumentCaptor().apply { + verify(eventsDispatcher).handleEvent(capture()) + clearInvocations(eventsDispatcher) + Assertions.assertTrue( + assertion(firstValue.destination), + "\nActual: ${firstValue.destination}\n" + ) + } + } + + override fun assertError(assertion: (Throwable) -> Boolean) { + argumentCaptor().apply { + verify(eventsDispatcher).handleEvent(capture()) + clearInvocations(eventsDispatcher) + Assertions.assertTrue( + assertion(firstValue.throwable), + "\nActual: ${firstValue.throwable}\n" + ) + } + } + + override fun assertModalScreen(assertion: (Screen<*>) -> Boolean) = apply { + argumentCaptor().apply { + verify(eventsDispatcher).handleEvent(capture()) + clearInvocations(eventsDispatcher) + val screen = (firstValue.destination as ModalDestination.ExplicitScreen<*>).screen + Assertions.assertTrue( + assertion(screen), + "\nAssertion failed for screen: ${firstValue.destination}!" + ) + } + } + + @Suppress("UNCHECKED_CAST") + override fun assertModalScreen( + output: T, + assertion: (Screen) -> Boolean + ) = apply { + argumentCaptor().apply { + verify(eventsDispatcher).handleEvent(capture()) + clearInvocations(eventsDispatcher) + val destination = firstValue.destination + val screen = (destination as ModalDestination.ExplicitScreen).screen + Assertions.assertTrue( + assertion(screen), + "\nAssertion failed for screen: ${firstValue.destination}!" + ) + + destination.onResult?.invoke(output) + } + } + + override fun assertModalViewController( + assertion: (ViewController) -> Boolean, + output: T + ): FlowModelAssertion { + argumentCaptor().apply { + verify(eventsDispatcher).handleEvent(capture()) + clearInvocations(eventsDispatcher) + val destination = firstValue.destination + val viewController = (destination as ModalDestination.CallbackController).controller + Assertions.assertTrue(viewController is ViewController<*>, "$viewController is not instance of ViewController") + require(viewController is ViewController<*>) + Assertions.assertTrue( + assertion(viewController as ViewController), + "\nAssertion failed for screen: ${firstValue.destination}!" + ) + + viewController.postResult(output) + } + return this + } + + override fun assertModalScreenFromFlowCoordinator(assertion: (Screen<*>) -> Boolean): FlowModelAssertion { + argumentCaptor().apply { + verify(eventsDispatcher).handleEvent(capture()) + clearInvocations(eventsDispatcher) + val destination = firstValue.destination + val viewController = (destination as ModalDestination.CallbackController).controller + Assertions.assertTrue(viewController is Screen<*>, "$viewController is not instance of Screen") + require(viewController is Screen<*>) + Assertions.assertTrue( + assertion(viewController as Screen<*>), + "\nAssertion failed for screen: ${firstValue.destination}!" + ) + } + return this + } + + override fun assertModalFlow(assertion: (Flow<*>) -> Boolean) = apply { + argumentCaptor().apply { + verify(eventsDispatcher).handleEvent(capture()) + clearInvocations(eventsDispatcher) + val flow = (firstValue.destination as ModalDestination.ExplicitFlow<*>).flow + Assertions.assertTrue( + assertion(flow), + "\nAssertion failed for flow: ${firstValue.destination}!" + ) + } + } + + @Suppress("UNCHECKED_CAST") + override fun assertModalFlow(output: T, assertion: (Flow) -> Boolean) = + apply { + argumentCaptor().apply { + verify(eventsDispatcher).handleEvent(capture()) + clearInvocations(eventsDispatcher) + val destination = firstValue.destination + val flow = (destination as ModalDestination.ExplicitFlow).flow + Assertions.assertTrue( + assertion(flow), + "\nAssertion failed for screen: ${firstValue.destination}!" + ) + + destination.onResult?.invoke(output) + } + } + + override fun assertDialog(model: DialogModel<*>, result: DialogModelResult) = apply { + Assertions.assertEquals( + model, + dialogQueue.poll(), + "\nDialog model is different than expected!" + ) + dialogResultStream.tryEmit(result) + } + + override fun , RESULT : DialogModelResult> assertDialog(assertion: (actual: MODEL) -> RESULT?) = apply { + @Suppress("UNCHECKED_CAST") val actualModel = dialogQueue.poll() as MODEL + val result = assertion(actualModel) + result?.let { dialogResultStream.tryEmit(result) } + } + + override fun assertHasBackStack(): FlowModelAssertion = apply { + Assertions.assertTrue(hasBackStack, "Expected hasBackStack true, but got false") + } + + override fun assertNoBackStack(): FlowModelAssertion = apply { + Assertions.assertFalse(hasBackStack, "Expected hasBackStack false, but got true") + } +} \ No newline at end of file diff --git a/kompot_core_test/src/main/kotlin/com/revolut/kompot/core/test/assertion/FlowModelTestExt.kt b/kompot_core_test/src/main/java/com/revolut/kompot/core/test/assertion/FlowModelTestExt.kt similarity index 100% rename from kompot_core_test/src/main/kotlin/com/revolut/kompot/core/test/assertion/FlowModelTestExt.kt rename to kompot_core_test/src/main/java/com/revolut/kompot/core/test/assertion/FlowModelTestExt.kt diff --git a/kompot_core_test/src/main/java/com/revolut/kompot/core/test/assertion/ScreenAssertionExt.kt b/kompot_core_test/src/main/java/com/revolut/kompot/core/test/assertion/ScreenAssertionExt.kt new file mode 100644 index 0000000..03354eb --- /dev/null +++ b/kompot_core_test/src/main/java/com/revolut/kompot/core/test/assertion/ScreenAssertionExt.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.core.test.assertion + +import android.annotation.SuppressLint +import com.revolut.kompot.common.IOData +import com.revolut.kompot.navigable.screen.BaseRecyclerViewScreen +import com.revolut.kompot.navigable.screen.ScreenStates +import com.revolut.recyclerkit.delegates.ListItem +import org.junit.jupiter.api.Assertions + +@SuppressLint("RestrictedApi") +fun + BaseRecyclerViewScreen.assertThatDelegatesRegisteredForItems( + items: List +) { + Assertions.assertTrue(this.delegatesForTesting.any { delegate -> items.any { delegate.suitFor(0, it) } }) +} \ No newline at end of file diff --git a/kompot_core_test/src/main/java/com/revolut/kompot/core/test/assertion/ScreenModelAssertionExt.kt b/kompot_core_test/src/main/java/com/revolut/kompot/core/test/assertion/ScreenModelAssertionExt.kt new file mode 100644 index 0000000..6811877 --- /dev/null +++ b/kompot_core_test/src/main/java/com/revolut/kompot/core/test/assertion/ScreenModelAssertionExt.kt @@ -0,0 +1,166 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.core.test.assertion + +import com.nhaarman.mockitokotlin2.argumentCaptor +import com.nhaarman.mockitokotlin2.clearInvocations +import com.nhaarman.mockitokotlin2.doReturn +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.whenever +import com.revolut.kompot.common.ErrorEvent +import com.revolut.kompot.common.IOData +import com.revolut.kompot.common.NavigationDestination +import com.revolut.kompot.common.NavigationEvent +import com.revolut.kompot.dialog.DialogModel +import com.revolut.kompot.dialog.DialogModelResult +import com.revolut.kompot.navigable.Controller +import com.revolut.kompot.navigable.flow.Flow +import com.revolut.kompot.navigable.screen.BaseScreenModel +import com.revolut.kompot.navigable.screen.Screen +import com.revolut.kompot.navigable.vc.ViewController +import com.revolut.kompot.navigable.vc.ViewControllerModel +import kotlinx.coroutines.flow.flowOf +import org.junit.jupiter.api.Assertions + +fun BaseScreenModel<*, *, *>.assertDestination(destination: NavigationDestination) { + argumentCaptor().apply { + verify(eventsDispatcher).handleEvent(capture()) + clearInvocations(eventsDispatcher) + Assertions.assertEquals( + destination, + firstValue.destination, + "\nAssertion failed for destination!" + ) + } +} + +fun BaseScreenModel<*, *, *>.assertDestination(assertion: (NavigationDestination) -> Boolean) { + argumentCaptor().apply { + verify(eventsDispatcher).handleEvent(capture()) + clearInvocations(eventsDispatcher) + Assertions.assertTrue( + assertion(firstValue.destination), + "\nAssertion failed for destination! Actual value: ${firstValue.destination}\n" + ) + } +} + +fun BaseScreenModel<*, *, *>.assertError(assertion: (Throwable) -> Boolean) { + argumentCaptor().apply { + verify(eventsDispatcher).handleEvent(capture()) + clearInvocations(eventsDispatcher) + Assertions.assertTrue( + assertion(firstValue.throwable), + "\nAssertion failed for error! Actual value: ${firstValue.throwable}\n" + ) + } +} + +fun BaseScreenModel<*, *, *>.assertModalScreen(assertion: (Screen<*>) -> Boolean) { + ControllerModelAssertions.assertModalScreen(eventsDispatcher, assertion) +} + +fun BaseScreenModel<*, *, *>.assertModalScreen(outputToReturn: T, assertion: (Screen) -> Boolean) { + ControllerModelAssertions.assertModalScreen(eventsDispatcher, outputToReturn, assertion) +} + +fun BaseScreenModel<*, *, *>.assertModalFlow(assertion: (Flow<*>) -> Boolean) { + ControllerModelAssertions.assertModalFlow(eventsDispatcher, assertion) +} + +fun BaseScreenModel<*, *, *>.assertModalFlow(outputToReturn: T, assertion: (Flow) -> Boolean) { + ControllerModelAssertions.assertModalFlow(eventsDispatcher, outputToReturn, assertion) +} + +fun BaseScreenModel<*, *, *>.assertModalViewController(assertion: (ViewController<*>) -> Boolean) { + ControllerModelAssertions.assertModalViewController(eventsDispatcher, assertion) +} + +fun BaseScreenModel<*, *, *>.assertModalViewController(outputToReturn: T, assertion: (ViewController) -> Boolean) { + ControllerModelAssertions.assertModalViewController(eventsDispatcher, outputToReturn, assertion) +} + +fun ViewControllerModel<*>.assertModalViewController(assertion: (ViewController<*>) -> Boolean) { + ControllerModelAssertions.assertModalViewController(eventsDispatcher, assertion) +} + +fun ViewControllerModel<*>.assertModalController(assertion: (Controller) -> Boolean) { + ControllerModelAssertions.assertModalController(eventsDispatcher, assertion) +} + +fun ViewControllerModel<*>.assertModalViewController(outputToReturn: T, assertion: (ViewController<*>) -> Boolean) { + ControllerModelAssertions.assertModalViewController(eventsDispatcher, outputToReturn, assertion) +} + +fun ViewControllerModel<*>.assertModalScreen(assertion: (Screen<*>) -> Boolean) { + ControllerModelAssertions.assertModalScreen(eventsDispatcher, assertion) +} + +fun ViewControllerModel<*>.assertModalScreen(outputToReturn: T, assertion: (Screen<*>) -> Boolean) { + ControllerModelAssertions.assertModalScreen(eventsDispatcher, outputToReturn, assertion) +} + +fun ViewControllerModel<*>.assertModalFlow(assertion: (Flow<*>) -> Boolean) { + ControllerModelAssertions.assertModalFlow(eventsDispatcher, assertion) +} + +fun ViewControllerModel<*>.assertModalFlow(outputToReturn: T, assertion: (Flow) -> Boolean) { + ControllerModelAssertions.assertModalFlow(eventsDispatcher, outputToReturn, assertion) +} + +fun ViewControllerModel<*>.assertDestination(destination: NavigationDestination) { + ControllerModelAssertions.assertDestination(destination, eventsDispatcher) +} + +fun BaseScreenModel<*, *, *>.mockDialogResult(forModel: DialogModel<*>, resultToReturn: DialogModelResult) { + whenever(dialogDisplayer.showDialog(forModel)) + .doReturn(flowOf(resultToReturn)) +} + +fun BaseScreenModel<*, *, *>.assertDialog(model: DialogModel<*>) { + ControllerModelAssertions.assertDialog(dialogDisplayer, model) +} + +inline fun , RESULT : DialogModelResult> BaseScreenModel<*, *, *>.assertDialog(assertion: (actual: MODEL) -> Boolean) { + argumentCaptor().apply { + verify(dialogDisplayer).showDialog(capture()) + clearInvocations(dialogDisplayer) + val dialogModel = firstValue + Assertions.assertTrue( + assertion(dialogModel), + "\nAssertion failed for dialog! Actual value: $firstValue\n" + ) + } +} + +inline fun > BaseScreenModel<*, *, *>.assertDialogHidden(assertion: (actual: MODEL) -> Boolean) { + argumentCaptor().apply { + verify(dialogDisplayer).hideDialog(capture()) + clearInvocations(dialogDisplayer) + val dialogModel = firstValue + Assertions.assertTrue( + assertion(dialogModel), + "\nAssertion failed for dialog! Actual value: $firstValue\n" + ) + } +} + +/** Useful for cases where we show 2+ dialogs one after another. `BaseScreenModel.assertDialog()` implementation + * doesn't support sequential invocation, in contrast to `FlowModelAssertion.assertDialog()` */ +fun BaseScreenModel<*, *, *>.assertDialogs(vararg models: DialogModel<*>) { + ControllerModelAssertions.assertDialogs(dialogDisplayer, *models) +} diff --git a/kompot_core_test/src/main/kotlin/com/revolut/kompot/core/test/assertion/ScrolleFlowModelTetsExt.kt b/kompot_core_test/src/main/java/com/revolut/kompot/core/test/assertion/ScrolleFlowModelTetsExt.kt similarity index 80% rename from kompot_core_test/src/main/kotlin/com/revolut/kompot/core/test/assertion/ScrolleFlowModelTetsExt.kt rename to kompot_core_test/src/main/java/com/revolut/kompot/core/test/assertion/ScrolleFlowModelTetsExt.kt index ffd5bce..a981630 100644 --- a/kompot_core_test/src/main/kotlin/com/revolut/kompot/core/test/assertion/ScrolleFlowModelTetsExt.kt +++ b/kompot_core_test/src/main/java/com/revolut/kompot/core/test/assertion/ScrolleFlowModelTetsExt.kt @@ -19,8 +19,8 @@ package com.revolut.kompot.core.test.assertion import com.revolut.kompot.ExperimentalKompotApi import com.revolut.kompot.common.IOData import com.revolut.kompot.navigable.binder.asFlow -import com.revolut.kompot.navigable.flow.FlowStep import com.revolut.kompot.navigable.flow.scroller.ScrollerFlowModel +import com.revolut.kompot.navigable.flow.scroller.ScrollerFlowStep @OptIn(ExperimentalKompotApi::class) -fun ScrollerFlowModel.navigationCommandsStream() = navigationBinder().asFlow() \ No newline at end of file +fun ScrollerFlowModel.navigationCommandsStream() = navigationBinder().asFlow() \ No newline at end of file diff --git a/kompot_core_test/src/main/java/com/revolut/kompot/core/test/assertion/ViewControllerFlowModelAssertion.kt b/kompot_core_test/src/main/java/com/revolut/kompot/core/test/assertion/ViewControllerFlowModelAssertion.kt new file mode 100644 index 0000000..13fb7f3 --- /dev/null +++ b/kompot_core_test/src/main/java/com/revolut/kompot/core/test/assertion/ViewControllerFlowModelAssertion.kt @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.core.test.assertion + +import android.annotation.SuppressLint +import com.revolut.kompot.common.EventsDispatcher +import com.revolut.kompot.common.IOData +import com.revolut.kompot.navigable.Controller +import com.revolut.kompot.navigable.ControllerKey +import com.revolut.kompot.navigable.binder.asFlow +import com.revolut.kompot.navigable.flow.Back +import com.revolut.kompot.navigable.flow.FlowStep +import com.revolut.kompot.navigable.flow.Next +import com.revolut.kompot.navigable.flow.PostFlowResult +import com.revolut.kompot.navigable.flow.Quit +import com.revolut.kompot.navigable.flow.StartPostponedStateRestore +import com.revolut.kompot.navigable.vc.ViewControllerModel +import com.revolut.kompot.navigable.vc.flow.FlowViewModel +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +@SuppressLint("CheckResult", "VisibleForTests") +internal class ViewControllerFlowModelAssertion internal constructor( + private val model: T +) : CommonFlowModelAssertions() where T : FlowViewModel, + T : ViewControllerModel { + + override val eventsDispatcher: EventsDispatcher + get() = model.eventsDispatcher + + override val hasBackStack: Boolean + get() = model.flowCoordinator.hasBackStack + + init { + model.applyTestDependencies(dialogDisplayer = dialogDisplayer) + model.flowCoordinator.onCreate(ControllerKey("")) + model.flowCoordinator + .navigationBinder().asFlow() + .onEach { command -> + when (command) { + is Next -> model.flowCoordinator.setNextState(command, null) + is Quit, + is StartPostponedStateRestore -> { + commandQueue.add(command) + } + //Commands for internal communication are not supported + else -> {} + } + }.launchIn(testScope) + + model.resultStream() + .onEach { + commandQueue.add(PostFlowResult(it)) + }.launchIn(testScope) + + model.backStream() + .onEach { + model.flowCoordinator.handleBackStack(immediate = true) + commandQueue.add(Back()) + }.launchIn(testScope) + } + + override fun getCurrentController(): Controller = + model.flowCoordinator.getCurrentController() + + override fun getCurrentStep(): STEP = model.flowCoordinator.step +} diff --git a/kompot_core_test/src/main/kotlin/com/revolut/kompot/core/test/assertion/ControllerModelTestExt.kt b/kompot_core_test/src/main/kotlin/com/revolut/kompot/core/test/assertion/ControllerModelTestExt.kt deleted file mode 100644 index 560624c..0000000 --- a/kompot_core_test/src/main/kotlin/com/revolut/kompot/core/test/assertion/ControllerModelTestExt.kt +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright (C) 2022 Revolut - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.revolut.kompot.core.test.assertion - -import com.nhaarman.mockitokotlin2.mock -import com.revolut.kompot.common.EventsDispatcher -import com.revolut.kompot.common.IOData -import com.revolut.kompot.coroutines.test.TestContextProvider -import com.revolut.kompot.dialog.DialogDisplayer -import com.revolut.kompot.navigable.ControllerModel -import com.revolut.kompot.navigable.binder.asFlow -import com.revolut.kompot.navigable.cache.ControllersCache -import com.revolut.kompot.navigable.screen.ScreenModel -import com.revolut.kompot.navigable.screen.ScreenStates -import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.ExperimentalCoroutinesApi - -@OptIn(ExperimentalCoroutinesApi::class) -fun T.applyTestDependencies( - dialogDisplayer: DialogDisplayer = mock(), - eventsDispatcher: EventsDispatcher = mock(), - controllersCache: ControllersCache = FakeControllersCache(), - mainDispatcher: CoroutineDispatcher = TestContextProvider.unconfinedDispatcher() -): T = apply { - injectDependencies( - dialogDisplayer = dialogDisplayer, - eventsDispatcher = eventsDispatcher, - controllersCache = controllersCache, - mainDispatcher = mainDispatcher - ) -} - -fun ScreenModel.resultStream() = resultsBinder().asFlow() - -fun ScreenModel.backStream() = backPressBinder().asFlow() \ No newline at end of file diff --git a/kompot_core_test/src/main/kotlin/com/revolut/kompot/core/test/assertion/FlowModelAssertion.kt b/kompot_core_test/src/main/kotlin/com/revolut/kompot/core/test/assertion/FlowModelAssertion.kt deleted file mode 100644 index 4a7a94c..0000000 --- a/kompot_core_test/src/main/kotlin/com/revolut/kompot/core/test/assertion/FlowModelAssertion.kt +++ /dev/null @@ -1,311 +0,0 @@ -/* - * Copyright (C) 2022 Revolut - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -@file:OptIn(ExperimentalCoroutinesApi::class) - -package com.revolut.kompot.core.test.assertion - -import android.annotation.SuppressLint -import com.nhaarman.mockitokotlin2.argumentCaptor -import com.nhaarman.mockitokotlin2.clearInvocations -import com.nhaarman.mockitokotlin2.mock -import com.nhaarman.mockitokotlin2.verify -import com.revolut.kompot.ExperimentalKompotApi -import com.revolut.kompot.common.ErrorEvent -import com.revolut.kompot.common.IOData -import com.revolut.kompot.common.ModalDestination -import com.revolut.kompot.common.NavigationDestination -import com.revolut.kompot.common.NavigationEvent -import com.revolut.kompot.coroutines.test.TestContextProvider -import com.revolut.kompot.dialog.DialogDisplayer -import com.revolut.kompot.dialog.DialogDisplayerDelegate -import com.revolut.kompot.dialog.DialogModel -import com.revolut.kompot.dialog.DialogModelResult -import com.revolut.kompot.navigable.Controller -import com.revolut.kompot.navigable.binder.asFlow -import com.revolut.kompot.navigable.flow.Back -import com.revolut.kompot.navigable.flow.BaseFlowModel -import com.revolut.kompot.navigable.flow.Flow -import com.revolut.kompot.navigable.flow.FlowNavigationCommand -import com.revolut.kompot.navigable.flow.FlowState -import com.revolut.kompot.navigable.flow.FlowStep -import com.revolut.kompot.navigable.flow.Next -import com.revolut.kompot.navigable.flow.PostFlowResult -import com.revolut.kompot.navigable.flow.Quit -import com.revolut.kompot.navigable.flow.scroller.ScrollerFlow -import com.revolut.kompot.navigable.screen.Screen -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.parcelize.Parcelize -import org.junit.jupiter.api.Assertions -import java.util.* - -fun BaseFlowModel<*, STEP, OUTPUT>.test() = FlowModelAssertion(this) - -@SuppressLint("CheckResult", "VisibleForTests") -class FlowModelAssertion internal constructor( - private val flowModel: BaseFlowModel<*, STEP, OUTPUT> -) { - - private val testScope = TestContextProvider.unconfinedTestScope() - - private val dialogResultStream = MutableSharedFlow(extraBufferCapacity = 16) - private val dialogDisplayer = DialogDisplayer( - loadingDialogDisplayer = mock(), - delegates = listOf( - FakeDialogDisplayerDelegate( - dialogResultStream = dialogResultStream, - onShown = { model -> - dialogQueue.add(model) - } - ) - ) - ) - - private val dialogQueue: Queue> = LinkedList() - private val commandQueue: Queue> = LinkedList() - - init { - flowModel.applyTestDependencies(dialogDisplayer = dialogDisplayer) - flowModel.setInitialState() - val childFlowModel = FakeFlowModel() - flowModel - .navigationBinder().asFlow() - .onEach { command -> - when (command) { - is Next -> flowModel.setNextState( - command.step, command.animation, - command.addCurrentStepToBackStack, - childFlowModel - ) - is Back -> { - flowModel.restorePreviousState() - commandQueue.add(command) - } - else -> commandQueue.add(command) - } - }.launchIn(testScope) - - flowModel.onCreated() - } - - fun assertStep(step: STEP, result: T) = apply { - assertStepInternal(step) - finishStepWithResult(result) - } - - fun assertStep(step: STEP) = apply { - assertStepInternal(step) - } - - private fun assertStepInternal(step: STEP) { - Assertions.assertEquals( - step, - flowModel.step, - "\nCurrent step is different than expected!" - ) - } - - @OptIn(ExperimentalKompotApi::class) - @Suppress("UNCHECKED_CAST") - private fun finishStepWithResult(result: T) { - when (val controller = flowModel.getController(flowModel.step)) { - is Flow<*> -> (controller as Flow).onFlowResult(result) - is ScrollerFlow<*> -> (controller as ScrollerFlow).onFlowResult(result) - is Screen<*> -> (controller as Screen).onScreenResult(result) - } - } - - fun assertResult(output: OUTPUT) { - val lastCommand = commandQueue.poll() - assertResult(lastCommand) - val result = (lastCommand as PostFlowResult).data - Assertions.assertEquals( - output, - result, - "\nResult is different than expected!" - ) - } - - private fun assertResult(command: FlowNavigationCommand?) { - Assertions.assertTrue( - command is PostFlowResult, - "\nExpected PostFlowResult but was: $command!" - ) - } - - fun assertBack() = apply { - val lastCommand = commandQueue.poll() - Assertions.assertTrue( - lastCommand is Back, - "\nExpected Back but was: $lastCommand!" - ) - } - - fun assertQuitFlow() { - val lastCommand = commandQueue.poll() - Assertions.assertTrue( - lastCommand is Quit, - "\nExpected Quit but was: $lastCommand!" - ) - } - - fun assertDestination(destination: NavigationDestination) { - argumentCaptor().apply { - verify(flowModel.eventsDispatcher).handleEvent(capture()) - clearInvocations(flowModel.eventsDispatcher) - Assertions.assertEquals( - destination, - firstValue.destination, - "\nAssertion failed for destination!" - ) - } - } - - fun assertDestination(assertion: (NavigationDestination) -> Boolean) { - argumentCaptor().apply { - verify(flowModel.eventsDispatcher).handleEvent(capture()) - clearInvocations(flowModel.eventsDispatcher) - Assertions.assertTrue( - assertion(firstValue.destination), - "\nActual: ${firstValue.destination}\n" - ) - } - } - - fun assertError(assertion: (Throwable) -> Boolean) { - argumentCaptor().apply { - verify(flowModel.eventsDispatcher).handleEvent(capture()) - clearInvocations(flowModel.eventsDispatcher) - Assertions.assertTrue( - assertion(firstValue.throwable), - "\nActual: ${firstValue.throwable}\n" - ) - } - } - - fun assertModalScreen(assertion: (Screen<*>) -> Boolean) = apply { - argumentCaptor().apply { - verify(flowModel.eventsDispatcher).handleEvent(capture()) - clearInvocations(flowModel.eventsDispatcher) - val screen = (firstValue.destination as ModalDestination.ExplicitScreen<*>).screen - Assertions.assertTrue( - assertion(screen), - "\nAssertion failed for screen: ${firstValue.destination}!" - ) - } - } - - @Suppress("UNCHECKED_CAST") - fun assertModalScreen(output: T, assertion: (Screen) -> Boolean) = apply { - argumentCaptor().apply { - verify(flowModel.eventsDispatcher).handleEvent(capture()) - clearInvocations(flowModel.eventsDispatcher) - val destination = firstValue.destination - val screen = (destination as ModalDestination.ExplicitScreen).screen - Assertions.assertTrue( - assertion(screen), - "\nAssertion failed for screen: ${firstValue.destination}!" - ) - - destination.onResult?.invoke(output) - } - } - - fun assertModalFlow(assertion: (Flow<*>) -> Boolean) = apply { - argumentCaptor().apply { - verify(flowModel.eventsDispatcher).handleEvent(capture()) - clearInvocations(flowModel.eventsDispatcher) - val flow = (firstValue.destination as ModalDestination.ExplicitFlow<*>).flow - Assertions.assertTrue( - assertion(flow), - "\nAssertion failed for flow: ${firstValue.destination}!" - ) - } - } - - @Suppress("UNCHECKED_CAST") - fun assertModalFlow(output: T, assertion: (Flow) -> Boolean) = apply { - argumentCaptor().apply { - verify(flowModel.eventsDispatcher).handleEvent(capture()) - clearInvocations(flowModel.eventsDispatcher) - val destination = firstValue.destination - val flow = (destination as ModalDestination.ExplicitFlow).flow - Assertions.assertTrue( - assertion(flow), - "\nAssertion failed for screen: ${firstValue.destination}!" - ) - - destination.onResult?.invoke(output) - } - } - - fun assertDialog(model: DialogModel<*>, result: DialogModelResult) = apply { - Assertions.assertEquals( - model, - dialogQueue.poll(), - "\nDialog model is different than expected!" - ) - dialogResultStream.tryEmit(result) - } - - fun , RESULT : DialogModelResult> assertDialog(assertion: (actual: MODEL) -> RESULT?) { - @Suppress("UNCHECKED_CAST") val actualModel = dialogQueue.poll() as MODEL - val result = assertion(actualModel) - result?.let { dialogResultStream.tryEmit(result) } - } -} - -@SuppressLint("VisibleForTests") -private class FakeFlowModel : BaseFlowModel() { - - override val initialStep: FakeStep = FakeStep - override val initialState: FakeState = FakeState - - init { - setInitialState() - } - - override fun getController(step: FakeStep): Controller { - throw IllegalStateException() - } -} - -@Parcelize -private object FakeStep : FlowStep - -@Parcelize -private object FakeState : FlowState - -private class FakeDialogDisplayerDelegate( - private val dialogResultStream: kotlinx.coroutines.flow.Flow, - private val onShown: (DialogModel<*>) -> Unit -) : DialogDisplayerDelegate>() { - - override fun canHandle(dialogModel: DialogModel<*>) = true - - override fun showDialogInternal(dialogModel: DialogModel<*>) { - onShown(dialogModel) - } - - override fun hideDialog() { - //do nothing - } - - override fun startObservingResult(): kotlinx.coroutines.flow.Flow = dialogResultStream -} \ No newline at end of file diff --git a/kompot_core_test/src/test/java/com/revolut/kompot/core/test/assertion/DummyDialogModel.kt b/kompot_core_test/src/test/java/com/revolut/kompot/core/test/assertion/DummyDialogModel.kt new file mode 100644 index 0000000..96b6632 --- /dev/null +++ b/kompot_core_test/src/test/java/com/revolut/kompot/core/test/assertion/DummyDialogModel.kt @@ -0,0 +1,22 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.core.test.assertion + +import com.revolut.kompot.dialog.DialogModel +import com.revolut.kompot.dialog.EmptyDialogModelResult + +internal class DummyDialogModel : DialogModel \ No newline at end of file diff --git a/kompot_core_test/src/test/java/com/revolut/kompot/core/test/assertion/DummyNavigationDestination.kt b/kompot_core_test/src/test/java/com/revolut/kompot/core/test/assertion/DummyNavigationDestination.kt new file mode 100644 index 0000000..f013cf1 --- /dev/null +++ b/kompot_core_test/src/test/java/com/revolut/kompot/core/test/assertion/DummyNavigationDestination.kt @@ -0,0 +1,21 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.core.test.assertion + +import com.revolut.kompot.common.NavigationDestination + +internal class DummyNavigationDestination : NavigationDestination \ No newline at end of file diff --git a/kompot_core_test/src/test/java/com/revolut/kompot/core/test/assertion/FakeScreenModel.kt b/kompot_core_test/src/test/java/com/revolut/kompot/core/test/assertion/FakeScreenModel.kt new file mode 100644 index 0000000..ab1fc4c --- /dev/null +++ b/kompot_core_test/src/test/java/com/revolut/kompot/core/test/assertion/FakeScreenModel.kt @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.core.test.assertion + +import com.nhaarman.mockitokotlin2.mock +import com.revolut.kompot.common.ErrorEvent +import com.revolut.kompot.common.IOData +import com.revolut.kompot.dialog.EmptyDialogModelResult +import com.revolut.kompot.navigable.flow.FlowStep +import com.revolut.kompot.navigable.screen.BaseScreenModel +import com.revolut.kompot.navigable.screen.ScreenStates +import kotlinx.coroutines.flow.Flow + +internal class FakeScreenModel : BaseScreenModel( + mock() +) { + override val initialState = ScreenStates.EmptyDomain + + var modalScreenResultHandled = false + var modalFlowResultHandled = false + + init { + injectDependencies(mock(), mock(), mock()) + } + + + fun navigateToDestination(destination: DummyNavigationDestination) { + destination.navigate() + } + + fun postError(throwable: Throwable) { + eventsDispatcher.handleEvent(ErrorEvent(throwable)) + } + + fun startModalScreen() { + DummyScreen(IOData.EmptyInput).showModal { + modalScreenResultHandled = true + } + } + + fun startModalFlow() { + DummyFlow(IOData.EmptyInput).showModal { + modalFlowResultHandled = true + } + } + + fun startDialog(fakeDialogModel: DummyDialogModel): Flow = + showDialog(fakeDialogModel) +} \ No newline at end of file diff --git a/kompot_core_test/src/test/java/com/revolut/kompot/core/test/assertion/ScreenModelAssertionTest.kt b/kompot_core_test/src/test/java/com/revolut/kompot/core/test/assertion/ScreenModelAssertionTest.kt new file mode 100644 index 0000000..4bf0572 --- /dev/null +++ b/kompot_core_test/src/test/java/com/revolut/kompot/core/test/assertion/ScreenModelAssertionTest.kt @@ -0,0 +1,436 @@ +/* + * Copyright (C) 2022 Revolut + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.revolut.kompot.core.test.assertion + +import com.revolut.kompot.common.IOData +import com.revolut.kompot.coroutines.test.KompotTestScope +import com.revolut.kompot.coroutines.test.dispatchBlockingTest +import com.revolut.kompot.coroutines.test.flow.testIn +import com.revolut.kompot.dialog.DialogModel +import com.revolut.kompot.dialog.EmptyDialogModelResult +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.mockito.exceptions.verification.TooFewActualInvocations +import org.mockito.exceptions.verification.TooManyActualInvocations +import org.mockito.exceptions.verification.WantedButNotInvoked +import org.opentest4j.AssertionFailedError + + +internal class ScreenModelAssertionTest { + + private val screenModel = FakeScreenModel() + + @Test + fun `GIVEN no navigation to destination WHEN destination asserted THEN catch assert exception`() { + assertThrows { + screenModel.assertDestination(DummyNavigationDestination()) + } + } + + @Test + fun `GIVEN navigation to destination WHEN destination asserted THEN pass the test`() { + testAssertDestination() + } + + @Test + fun `GIVEN navigation to destination twice WHEN destination asserted twice THEN pass the test`() { + repeat(2) { + testAssertDestination() + } + } + + private fun testAssertDestination() { + val destination = DummyNavigationDestination() + + screenModel.navigateToDestination(destination) + + screenModel.assertDestination(destination) + } + + @Test + fun `GIVEN navigation to destination WHEN destination asserted twice THEN catch proper exception`() { + val destination = DummyNavigationDestination() + + screenModel.navigateToDestination(destination) + + screenModel.assertDestination(destination) + assertThrows { + screenModel.assertDestination(destination) + } + } + + @Test + fun `GIVEN navigation to destination WHEN destination asserted with custom assertion THEN pass the test`() { + testAssertDestinationWithCustomAssertion() + } + + @Test + fun `GIVEN navigation to destination twice WHEN destination asserted with custom assertion twice THEN pass the test`() { + repeat(2) { + testAssertDestinationWithCustomAssertion() + } + } + + private fun testAssertDestinationWithCustomAssertion() { + val destination = DummyNavigationDestination() + + screenModel.navigateToDestination(destination) + + screenModel.assertDestination { actualDestination -> actualDestination == destination } + } + + @Test + fun `GIVEN navigation to destination WHEN destination asserted with custom assertion twice THEN catch proper exception`() { + val destination = DummyNavigationDestination() + + screenModel.navigateToDestination(destination) + + screenModel.assertDestination { actualDestination -> actualDestination == destination } + assertThrows { + screenModel.assertDestination { actualDestination -> actualDestination == destination } + } + } + + @Test + fun `GIVEN no navigation to destination WHEN destination asserted with custom assertion THEN catch proper exception`() { + val destination = DummyNavigationDestination() + + assertThrows { + screenModel.assertDestination { actualDestination -> actualDestination == destination } + } + } + + @Test + fun `GIVEN error event WHEN error asserted with custom assertion THEN pass the test`() { + testAssertError() + } + + @Test + fun `GIVEN error event twice WHEN error asserted with custom assertion twice THEN pass the test`() { + repeat(2) { + testAssertError() + } + } + + private fun testAssertError() { + val error = Throwable() + + screenModel.postError(error) + + screenModel.assertError { actualError -> actualError == error } + } + + @Test + fun `GIVEN error event WHEN error asserted with custom assertion twice THEN catch proper exception`() { + val error = Throwable() + + screenModel.postError(error) + + screenModel.assertError { actualError -> actualError == error } + assertThrows { + screenModel.assertError { actualError -> actualError == error } + } + } + + @Test + fun `GIVEN no error event WHEN error asserted with custom assertion THEN catch proper exception`() { + val error = Throwable() + + assertThrows { + screenModel.assertError { actualError -> actualError == error } + } + } + + @Test + fun `GIVEN modal screen started WHEN modal screen asserted THEN pass the test`() { + testAssertModalScreen() + } + + @Test + fun `GIVEN modal screen started twice WHEN modal screen asserted twice THEN pass the test`() { + repeat(2) { + testAssertModalScreen() + } + } + + @Test + fun `GIVEN modal screen started WHEN modal screen asserted twice THEN catch proper exception`() { + testAssertModalScreen() + assertThrows { + screenModel.assertModalScreen { screen -> screen is DummyScreen<*, *, *> } + } + } + + private fun testAssertModalScreen() { + screenModel.startModalScreen() + + screenModel.assertModalScreen { screen -> screen is DummyScreen<*, *, *> } + Assertions.assertFalse(screenModel.modalScreenResultHandled) + } + + @Test + fun `GIVEN no modal screen started WHEN modal screen asserted THEN catch proper exception`() { + assertThrows { + screenModel.assertModalScreen { screen -> screen is DummyScreen<*, *, *> } + } + Assertions.assertFalse(screenModel.modalScreenResultHandled) + } + + @Test + fun `GIVEN modal screen with result started WHEN modal screen asserted THEN pass the test`() { + testAssertModalScreenWithResult() + } + + @Test + fun `GIVEN modal screen with result started WHEN modal screen asserted twice THEN throw proper exception`() { + testAssertModalScreenWithResult() + assertThrows { + screenModel.assertModalScreen(IOData.EmptyOutput) { screen -> screen is DummyScreen<*, *, *> } + } + } + + @Test + fun `GIVEN modal screen with result started twice WHEN modal screen asserted twice THEN pass the test`() { + repeat(2) { + testAssertModalScreenWithResult() + } + } + + private fun testAssertModalScreenWithResult() { + screenModel.startModalScreen() + + screenModel.assertModalScreen(IOData.EmptyOutput) { screen -> screen is DummyScreen<*, *, *> } + Assertions.assertTrue(screenModel.modalScreenResultHandled) + } + + @Test + fun `GIVEN modal flow started WHEN modal flow asserted THEN pass the test`() { + testAssertModalFlow() + } + + @Test + fun `GIVEN modal flow started twice WHEN modal flow asserted twice THEN pass the test`() { + repeat(2) { + testAssertModalFlow() + } + } + + @Test + fun `GIVEN modal flow started WHEN modal flow asserted twice THEN catch proper exception`() { + testAssertModalFlow() + assertThrows { + screenModel.assertModalFlow { flow -> flow is DummyFlow<*, *, *> } + } + } + + private fun testAssertModalFlow() { + screenModel.startModalFlow() + + screenModel.assertModalFlow { flow -> flow is DummyFlow<*, *, *> } + Assertions.assertFalse(screenModel.modalFlowResultHandled) + } + + @Test + fun `GIVEN no modal flow started WHEN modal flow asserted THEN catch proper exception`() { + assertThrows { + screenModel.assertModalFlow { flow -> flow is DummyFlow<*, *, *> } + } + Assertions.assertFalse(screenModel.modalFlowResultHandled) + } + + @Test + fun `GIVEN modal flow with result started WHEN modal flow asserted THEN pass the test`() { + testAssertModalFlowWithResult() + } + + @Test + fun `GIVEN 2 modal flows with result started WHEN modal flows asserted THEN pass the test`() { + repeat(2) { + testAssertModalFlowWithResult() + } + } + + private fun testAssertModalFlowWithResult() { + screenModel.startModalFlow() + + screenModel.assertModalFlow(IOData.EmptyOutput) { flow -> flow is DummyFlow<*, *, *> } + Assertions.assertTrue(screenModel.modalFlowResultHandled) + } + + @Test + fun `GIVEN 1 modal flow with result started WHEN modal flow asserted twice THEN throw proper exception`() { + testAssertModalFlowWithResult() + assertThrows { + screenModel.assertModalFlow(IOData.EmptyOutput) { flow -> flow is DummyFlow<*, *, *> } + } + } + + @Test + fun `GIVEN dialog shown WHEN dialog asserted THEN pass the test`() = dispatchBlockingTest { + testDialogAssertion() + } + + @Test + fun `GIVEN 2 dialogs shown WHEN dialogs asserted THEN pass the test`() = dispatchBlockingTest { + repeat(2) { + testDialogAssertion() + } + } + + @Test + fun `GIVEN dialog shown WHEN dialog asserted twice THEN throw proper exception`() = dispatchBlockingTest { + val dialogModel = DummyDialogModel() + + screenModel.mockDialogResult(dialogModel, EmptyDialogModelResult) + + screenModel.startDialog(dialogModel) + .testIn(this) + + screenModel.assertDialog(dialogModel) + assertThrows { + screenModel.assertDialog(dialogModel) + } + } + + private fun KompotTestScope.testDialogAssertion() { + val dialogModel = DummyDialogModel() + + screenModel.mockDialogResult(dialogModel, EmptyDialogModelResult) + + screenModel.startDialog(dialogModel) + .testIn(this) + + screenModel.assertDialog(dialogModel) + } + + @Test + fun `GIVEN dialog not shown WHEN dialog asserted THEN catch proper exception`() = dispatchBlockingTest { + val dialogModel = DummyDialogModel() + + assertThrows { + screenModel.assertDialog(dialogModel) + } + } + + @Test + fun `GIVEN dialog shown WHEN dialog asserted with custom assertion THEN pass the test`() = dispatchBlockingTest { + testDialogWithCustomAssertion() + } + + @Test + fun `GIVEN dialog shown twice WHEN dialog asserted with custom assertion twice THEN pass the test`() = dispatchBlockingTest { + repeat(2) { + testDialogWithCustomAssertion() + } + } + + private fun KompotTestScope.testDialogWithCustomAssertion() { + val dialogModel = DummyDialogModel() + screenModel.mockDialogResult(dialogModel, EmptyDialogModelResult) + + val testObserver = screenModel.startDialog(dialogModel) + .testIn(this) + + screenModel.assertDialog { model -> + dialogModel == model + } + testObserver.assertValues(listOf(EmptyDialogModelResult)) + } + + @Test + fun `GIVEN dialog shown WHEN dialog asserted with custom assertion twice THEN throw proper exception`() = dispatchBlockingTest { + val dialogModel = DummyDialogModel() + screenModel.mockDialogResult(dialogModel, EmptyDialogModelResult) + + val testObserver = screenModel.startDialog(dialogModel) + .testIn(this) + + screenModel.assertDialog { model -> + dialogModel == model + } + testObserver.assertValues(listOf(EmptyDialogModelResult)) + assertThrows { + screenModel.assertDialog { model -> + dialogModel == model + } + } + } + + @Test + fun `GIVEN multiple dialogs shown WHEN dialogs asserted THEN pass the test`() = dispatchBlockingTest { + val dialogModel = DummyDialogModel() + val anotherDialogModel = AnotherDialogModel() + val thirdDialogModel = object : DialogModel {} + + screenModel.showDialog(dialogModel) + screenModel.showDialog(anotherDialogModel) + screenModel.showDialog(thirdDialogModel) + + screenModel.assertDialogs(dialogModel, anotherDialogModel, thirdDialogModel) + } + + @Test + fun `GIVEN two dialogs shown WHEN one dialog asserted THEN throw proper exception`() = dispatchBlockingTest { + val dialogModel = DummyDialogModel() + val anotherDialogModel = AnotherDialogModel() + + screenModel.showDialog(dialogModel) + screenModel.showDialog(anotherDialogModel) + + assertThrows { + screenModel.assertDialogs(dialogModel) + } + } + + @Test + fun `GIVEN one dialog shown WHEN two dialogs asserted THEN throw proper exception`() = dispatchBlockingTest { + val dialogModel = DummyDialogModel() + val anotherDialogModel = AnotherDialogModel() + + screenModel.showDialog(dialogModel) + + assertThrows { + screenModel.assertDialogs(dialogModel, anotherDialogModel) + } + } + + @Test + fun `GIVEN two dialogs shown WHEN dialogs asserted in wrong order THEN throw proper exception`() = dispatchBlockingTest { + val dialogModel = DummyDialogModel() + val anotherDialogModel = AnotherDialogModel() + + screenModel.showDialog(dialogModel) + screenModel.showDialog(anotherDialogModel) + + assertThrows { + screenModel.assertDialogs(anotherDialogModel, dialogModel) + } + } + + @Test + fun `GIVEN dialog model WHEN dialog asserted THEN pass the test`() { + val model = DummyDialogModel() + + screenModel.hideDialog(model) + screenModel.assertDialogHidden { + model == it + } + } + + internal class AnotherDialogModel : DialogModel +} diff --git a/kompot_coroutines/gradle.properties b/kompot_coroutines/gradle.properties index 8d419e4..e022699 100644 --- a/kompot_coroutines/gradle.properties +++ b/kompot_coroutines/gradle.properties @@ -15,7 +15,7 @@ # POM_ARTIFACT_ID=coroutines -VERSION_NAME=0.0.2 +VERSION_NAME=0.0.3 POM_NAME=coroutines POM_PACKAGING=aar GROUP=com.revolut.kompot \ No newline at end of file diff --git a/kompot_coroutines/src/main/kotlin/com/revolut/kompot/coroutines/AppDispatchers.kt b/kompot_coroutines/src/main/kotlin/com/revolut/kompot/coroutines/AppDispatchers.kt index e340fe0..8162d51 100644 --- a/kompot_coroutines/src/main/kotlin/com/revolut/kompot/coroutines/AppDispatchers.kt +++ b/kompot_coroutines/src/main/kotlin/com/revolut/kompot/coroutines/AppDispatchers.kt @@ -17,31 +17,41 @@ package com.revolut.kompot.coroutines import androidx.annotation.VisibleForTesting +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlin.coroutines.CoroutineContext object AppDispatchers { - val Default - get() = dispatcherOverride { - Dispatchers.Default - } + val Default: CoroutineDispatcher + get() = dispatcherOverride() ?: Dispatchers.Default - val IO - get() = dispatcherOverride { - Dispatchers.IO - } + val IO: CoroutineDispatcher + get() = dispatcherOverride() ?: Dispatchers.IO - val Unconfined - get() = dispatcherOverride { - Dispatchers.Unconfined - } + val Unconfined: CoroutineDispatcher + get() = dispatcherOverride() ?: Dispatchers.Unconfined /** * Main is testable by default. We can override it with Dispatchers.setMain(context) call */ - internal val Main get() = Dispatchers.Main + internal val Main: CoroutineDispatcher get() = Dispatchers.Main @VisibleForTesting - var dispatcherOverride: (() -> CoroutineContext) -> CoroutineContext = { it() } + var dispatcherOverride: () -> CoroutineDispatcher? = { null } +} + +/** + * A dispatcher that executes the computations immediately in the thread that requests it. + * + * This dispatcher is similar to [Dispatchers.Unconfined] but does not attempt to avoid stack overflows. + */ +@ExperimentalCoroutinesApi +val Dispatchers.Direct: CoroutineDispatcher get() = DirectDispatcher + +private object DirectDispatcher : CoroutineDispatcher() { + override fun dispatch(context: CoroutineContext, block: Runnable) { + block.run() + } } \ No newline at end of file diff --git a/kompot_coroutines/src/main/kotlin/com/revolut/kompot/coroutines/CustomisableContext.kt b/kompot_coroutines/src/main/kotlin/com/revolut/kompot/coroutines/CustomisableContext.kt index b886a86..58c40de 100644 --- a/kompot_coroutines/src/main/kotlin/com/revolut/kompot/coroutines/CustomisableContext.kt +++ b/kompot_coroutines/src/main/kotlin/com/revolut/kompot/coroutines/CustomisableContext.kt @@ -14,6 +14,7 @@ * limitations under the License. */ + package com.revolut.kompot.coroutines import kotlinx.coroutines.CoroutineScope @@ -30,14 +31,9 @@ class CustomContextWrapper(context: CoroutineContext) : CoroutineContext { override fun plus(context: CoroutineContext): CoroutineContext = CustomContextWrapper(wrappedContext.plus(context.withTestableContinuationInterceptor())) - override fun fold(initial: R, operation: (R, CoroutineContext.Element) -> R): R = - wrappedContext.fold(initial, operation) - - override fun get(key: CoroutineContext.Key): E? = - wrappedContext[key] - - override fun minusKey(key: CoroutineContext.Key<*>): CoroutineContext = - wrappedContext.minusKey(key) + override fun fold(initial: R, operation: (R, CoroutineContext.Element) -> R): R = wrappedContext.fold(initial, operation) + override fun get(key: CoroutineContext.Key): E? = wrappedContext[key] + override fun minusKey(key: CoroutineContext.Key<*>): CoroutineContext = wrappedContext.minusKey(key) } private fun CoroutineContext.withTestableContinuationInterceptor(): CoroutineContext { @@ -51,5 +47,4 @@ private fun CoroutineContext.withTestableContinuationInterceptor(): CoroutineCon } @Suppress("FunctionName") -fun AppCoroutineScope(context: CoroutineContext = Dispatchers.Default) = - CoroutineScope(CustomContextWrapper(SupervisorJob() + context)) \ No newline at end of file +fun AppCoroutineScope(context: CoroutineContext = Dispatchers.Default) = CoroutineScope(CustomContextWrapper(SupervisorJob() + context)) \ No newline at end of file diff --git a/kompot_coroutines/src/test/kotlin/com/revolut/kompot/coroutines/CustomContextText.kt b/kompot_coroutines/src/test/kotlin/com/revolut/kompot/coroutines/CustomContextText.kt index af0e8d0..bce9502 100644 --- a/kompot_coroutines/src/test/kotlin/com/revolut/kompot/coroutines/CustomContextText.kt +++ b/kompot_coroutines/src/test/kotlin/com/revolut/kompot/coroutines/CustomContextText.kt @@ -125,7 +125,7 @@ internal class CustomContextText { private fun withReplacedDispatchers(block: () -> Unit) { AppDispatchers.dispatcherOverride = { testDispatcher } block() - AppDispatchers.dispatcherOverride = { it() } + AppDispatchers.dispatcherOverride = { null } } companion object { diff --git a/kompot_coroutines_test/gradle.properties b/kompot_coroutines_test/gradle.properties index a98b106..a7ef271 100644 --- a/kompot_coroutines_test/gradle.properties +++ b/kompot_coroutines_test/gradle.properties @@ -1,21 +1,5 @@ -# -# Copyright (C) 2022 Revolut -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - POM_ARTIFACT_ID=coroutines-test -VERSION_NAME=0.0.2 +VERSION_NAME=0.0.3 POM_NAME=coroutines-test POM_PACKAGING=aar GROUP=com.revolut.kompot \ No newline at end of file diff --git a/kompot_coroutines_test/src/main/kotlin/com/revolut/kompot/coroutines/test/TestContextProvider.kt b/kompot_coroutines_test/src/main/kotlin/com/revolut/kompot/coroutines/test/TestContextProvider.kt index b92403a..544e5ef 100644 --- a/kompot_coroutines_test/src/main/kotlin/com/revolut/kompot/coroutines/test/TestContextProvider.kt +++ b/kompot_coroutines_test/src/main/kotlin/com/revolut/kompot/coroutines/test/TestContextProvider.kt @@ -20,11 +20,13 @@ import com.revolut.kompot.coroutines.AppCoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestCoroutineScheduler import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext @OptIn(ExperimentalCoroutinesApi::class) object TestContextProvider { val testScheduler = TestCoroutineScheduler() fun unconfinedDispatcher() = UnconfinedTestDispatcher(testScheduler) - fun unconfinedTestScope() = AppCoroutineScope(unconfinedDispatcher()) + fun unconfinedTestScope(context: CoroutineContext = EmptyCoroutineContext) = AppCoroutineScope(unconfinedDispatcher() + context) } \ No newline at end of file diff --git a/kompot_coroutines_test/src/main/kotlin/com/revolut/kompot/coroutines/test/TestDispatcherExtension.kt b/kompot_coroutines_test/src/main/kotlin/com/revolut/kompot/coroutines/test/TestDispatcherExtension.kt index 55a5f0a..5f10ff8 100644 --- a/kompot_coroutines_test/src/main/kotlin/com/revolut/kompot/coroutines/test/TestDispatcherExtension.kt +++ b/kompot_coroutines_test/src/main/kotlin/com/revolut/kompot/coroutines/test/TestDispatcherExtension.kt @@ -39,6 +39,6 @@ class TestDispatcherExtension : BeforeAllCallback, AfterAllCallback { @SuppressLint("VisibleForTests") override fun afterAll(context: ExtensionContext?) { Dispatchers.resetMain() - AppDispatchers.dispatcherOverride = { it() } + AppDispatchers.dispatcherOverride = { null } } } \ No newline at end of file diff --git a/kompot_coroutines_test/src/main/kotlin/com/revolut/kompot/coroutines/test/flow/FlowAssertion.kt b/kompot_coroutines_test/src/main/kotlin/com/revolut/kompot/coroutines/test/flow/FlowAssertion.kt index 3b3cf33..64d166e 100644 --- a/kompot_coroutines_test/src/main/kotlin/com/revolut/kompot/coroutines/test/flow/FlowAssertion.kt +++ b/kompot_coroutines_test/src/main/kotlin/com/revolut/kompot/coroutines/test/flow/FlowAssertion.kt @@ -47,10 +47,14 @@ interface FlowAssertion { fun assertComplete(): FlowAssertion + fun assertNotCompleted(): FlowAssertion + fun assertError( throwable: Throwable ): FlowAssertion + fun assertNoErrors(): FlowAssertion + fun assertError( throwableClass: Class ): FlowAssertion diff --git a/kompot_coroutines_test/src/main/kotlin/com/revolut/kompot/coroutines/test/flow/FlowAssertionImpl.kt b/kompot_coroutines_test/src/main/kotlin/com/revolut/kompot/coroutines/test/flow/FlowAssertionImpl.kt index d0e9c29..6eaed71 100644 --- a/kompot_coroutines_test/src/main/kotlin/com/revolut/kompot/coroutines/test/flow/FlowAssertionImpl.kt +++ b/kompot_coroutines_test/src/main/kotlin/com/revolut/kompot/coroutines/test/flow/FlowAssertionImpl.kt @@ -17,6 +17,7 @@ package com.revolut.kompot.coroutines.test.flow import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue internal class FlowAssertionImpl( @@ -69,6 +70,11 @@ internal class FlowAssertionImpl( return this } + override fun assertNotCompleted(): FlowAssertion { + assertFalse({ testState is TestState.Completed }, "Flow completed") + return this + } + override fun assertError(throwable: Throwable): FlowAssertion { val actualThrowable = (testState as? TestState.Error)?.throwable assertEquals(throwable, actualThrowable) @@ -80,6 +86,11 @@ internal class FlowAssertionImpl( assertTrue({ throwableClass.isInstance(actualThrowable) }, "expected $throwableClass but was ${actualThrowable?.javaClass}") return this } + + override fun assertNoErrors(): FlowAssertion { + assertTrue({ testState !is TestState.Error }, "Expected no errors but got ${(testState as? TestState.Error)?.throwable}") + return this + } } fun FlowAssertion.assertLatestValue(predicate: (actualValue: T) -> Boolean) = diff --git a/samples/build_first_flow/.idea/misc.xml b/samples/build_first_flow/.idea/misc.xml index fba146a..786c115 100644 --- a/samples/build_first_flow/.idea/misc.xml +++ b/samples/build_first_flow/.idea/misc.xml @@ -11,7 +11,7 @@ - + diff --git a/samples/build_first_flow/app/build.gradle b/samples/build_first_flow/app/build.gradle index 9ebf271..36e59a5 100644 --- a/samples/build_first_flow/app/build.gradle +++ b/samples/build_first_flow/app/build.gradle @@ -6,12 +6,12 @@ plugins { } android { - compileSdk 31 + compileSdk 33 defaultConfig { applicationId "com.revolut.business.build_first_flow" minSdk 23 - targetSdk 31 + targetSdk 33 versionCode 1 versionName "1.0" diff --git a/samples/build_first_flow/app/src/main/java/com/revolut/kompot/build_first_flow/flow/AddContactFlow.kt b/samples/build_first_flow/app/src/main/java/com/revolut/kompot/build_first_flow/flow/AddContactFlow.kt index 77aa98b..14ce0d0 100644 --- a/samples/build_first_flow/app/src/main/java/com/revolut/kompot/build_first_flow/flow/AddContactFlow.kt +++ b/samples/build_first_flow/app/src/main/java/com/revolut/kompot/build_first_flow/flow/AddContactFlow.kt @@ -4,8 +4,6 @@ import com.revolut.kompot.build_first_flow.App import com.revolut.kompot.build_first_flow.R import com.revolut.kompot.build_first_flow.flow.di.AddContactFlowComponent import com.revolut.kompot.common.IOData -import com.revolut.kompot.dialog.DefaultLoadingDialogDisplayer -import com.revolut.kompot.dialog.DialogDisplayer import com.revolut.kompot.navigable.root.RootFlow import com.revolut.kompot.view.ControllerContainerFrameLayout diff --git a/samples/build_first_flow/gradle/wrapper/gradle-wrapper.properties b/samples/build_first_flow/gradle/wrapper/gradle-wrapper.properties index 8001ae7..2c65175 100644 --- a/samples/build_first_flow/gradle/wrapper/gradle-wrapper.properties +++ b/samples/build_first_flow/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Tue Aug 10 17:46:48 BST 2021 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-all.zip distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME diff --git a/samples/build_first_screen/.idea/gradle.xml b/samples/build_first_screen/.idea/gradle.xml index bddcd41..cc2240a 100644 --- a/samples/build_first_screen/.idea/gradle.xml +++ b/samples/build_first_screen/.idea/gradle.xml @@ -14,7 +14,6 @@ - diff --git a/samples/build_first_screen/.idea/misc.xml b/samples/build_first_screen/.idea/misc.xml index 5a13181..e51d8f9 100644 --- a/samples/build_first_screen/.idea/misc.xml +++ b/samples/build_first_screen/.idea/misc.xml @@ -10,7 +10,7 @@ - + diff --git a/samples/build_first_screen/app/build.gradle b/samples/build_first_screen/app/build.gradle index 6c6fd6e..5e11bab 100644 --- a/samples/build_first_screen/app/build.gradle +++ b/samples/build_first_screen/app/build.gradle @@ -6,12 +6,12 @@ plugins { } android { - compileSdk 31 + compileSdk 33 defaultConfig { applicationId "com.revolut.business.build_first_screen" minSdk 23 - targetSdk 31 + targetSdk 33 versionCode 1 versionName "1.0" diff --git a/samples/build_first_screen/gradle/wrapper/gradle-wrapper.properties b/samples/build_first_screen/gradle/wrapper/gradle-wrapper.properties index 8001ae7..2c65175 100644 --- a/samples/build_first_screen/gradle/wrapper/gradle-wrapper.properties +++ b/samples/build_first_screen/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Tue Aug 10 17:46:48 BST 2021 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-all.zip distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME diff --git a/samples/messenger/app/build.gradle b/samples/messenger/app/build.gradle index 8c832af..a10c2ae 100644 --- a/samples/messenger/app/build.gradle +++ b/samples/messenger/app/build.gradle @@ -5,10 +5,12 @@ apply plugin: 'kotlin-kapt' apply from: '../kompot_sample_dependencies.gradle' android { + namespace "com.revolut.kompot.sample" + defaultConfig { applicationId "com.revolut.kompot.sample" compileSdkVersion kompot_sample.compileSdkVersion - buildToolsVersion kompot_sample.androidBuildToolsVersion + buildToolsVersion kompot_sample.buildToolsVersion targetSdkVersion kompot_sample.targetSdkVersion minSdkVersion kompot_sample.minSdkVersion versionCode 1 @@ -32,7 +34,11 @@ android { exclude 'META-INF/LICENSE.md' exclude 'META-INF/LICENSE-notice.md' } - + testOptions { + unitTests.all { + useJUnitPlatform() + } + } } dependencies { diff --git a/samples/messenger/app/src/main/AndroidManifest.xml b/samples/messenger/app/src/main/AndroidManifest.xml index 55cd9b9..0cbc165 100644 --- a/samples/messenger/app/src/main/AndroidManifest.xml +++ b/samples/messenger/app/src/main/AndroidManifest.xml @@ -1,7 +1,6 @@ + xmlns:tools="http://schemas.android.com/tools"> + android:exported="false" /> + android:exported="true" + android:screenOrientation="portrait"> diff --git a/samples/messenger/app/src/main/java/com/revolut/kompot/sample/Features.kt b/samples/messenger/app/src/main/java/com/revolut/kompot/sample/Features.kt index c263930..e58da10 100644 --- a/samples/messenger/app/src/main/java/com/revolut/kompot/sample/Features.kt +++ b/samples/messenger/app/src/main/java/com/revolut/kompot/sample/Features.kt @@ -1,5 +1,6 @@ package com.revolut.kompot.sample +import com.revolut.kompot.FeatureGateway import com.revolut.kompot.sample.data.di.DataApiProvider import com.revolut.kompot.sample.feature.chat.ChatFeatureGateway import com.revolut.kompot.sample.feature.chat.di.ChatArguments @@ -12,7 +13,7 @@ import com.revolut.kompot.sample.utils.di.CoreUtilsApiProvider object Features { - fun createFeatures() = listOf( + fun createFeaturesList(): List = listOf( ChatFeatureGateway { ChatArguments( dataApi = DataApiProvider.instance, diff --git a/samples/messenger/app/src/main/java/com/revolut/kompot/sample/di/AppModule.kt b/samples/messenger/app/src/main/java/com/revolut/kompot/sample/di/AppModule.kt index 69c539f..263f482 100644 --- a/samples/messenger/app/src/main/java/com/revolut/kompot/sample/di/AppModule.kt +++ b/samples/messenger/app/src/main/java/com/revolut/kompot/sample/di/AppModule.kt @@ -5,9 +5,9 @@ import androidx.appcompat.view.ContextThemeWrapper import com.revolut.kompot.DefaultFeaturesRegistry import com.revolut.kompot.FeaturesRegistry import com.revolut.kompot.di.ThemedApplicationContext +import com.revolut.kompot.sample.Features import com.revolut.kompot.sample.R import com.revolut.kompot.sample.SampleApplication -import dagger.Binds import dagger.Module import dagger.Provides import javax.inject.Singleton @@ -15,10 +15,6 @@ import javax.inject.Singleton @Module abstract class AppModule { - @Binds - @Singleton - abstract fun provideFeaturesRegistry(registry: DefaultFeaturesRegistry): FeaturesRegistry - @Module companion object { @@ -28,5 +24,12 @@ abstract class AppModule { @ThemedApplicationContext fun provideThemedApplicationContext(application: SampleApplication): Context = ContextThemeWrapper(application, R.style.AppTheme) + + @[Provides Singleton] + fun provideFeaturesRegistry(): FeaturesRegistry { + val featuresRegistry = DefaultFeaturesRegistry() + featuresRegistry.registerFeatures(Features.createFeaturesList()) + return featuresRegistry + } } } \ No newline at end of file diff --git a/samples/messenger/app/src/main/java/com/revolut/kompot/sample/ui/flows/main/MainFlow.kt b/samples/messenger/app/src/main/java/com/revolut/kompot/sample/ui/flows/main/MainFlow.kt index f144dde..646bf57 100644 --- a/samples/messenger/app/src/main/java/com/revolut/kompot/sample/ui/flows/main/MainFlow.kt +++ b/samples/messenger/app/src/main/java/com/revolut/kompot/sample/ui/flows/main/MainFlow.kt @@ -1,49 +1,47 @@ package com.revolut.kompot.sample.ui.flows.main +import android.view.View import com.revolut.kompot.common.IOData -import com.revolut.kompot.navigable.cache.ControllerCacheStrategy -import com.revolut.kompot.navigable.flow.BaseFlow import com.revolut.kompot.navigable.utils.viewBinding +import com.revolut.kompot.navigable.vc.ViewController +import com.revolut.kompot.navigable.vc.composite.ui_states_flow.ModelBinding +import com.revolut.kompot.navigable.vc.composite.ui_states_flow.UIStatesFlowController import com.revolut.kompot.sample.R import com.revolut.kompot.sample.databinding.FlowMainBinding import com.revolut.kompot.sample.sampleApplication -import com.revolut.kompot.sample.ui.flows.main.MainFlowContract.Step +import com.revolut.kompot.sample.ui.flows.main.MainFlowContract.UIState import com.revolut.kompot.sample.ui.flows.main.di.MainFlowComponent -class MainFlow : BaseFlow(IOData.EmptyInput) { +class MainFlow : ViewController(), UIStatesFlowController { override val layoutId = R.layout.flow_main private val binding by viewBinding(FlowMainBinding::bind) - override var cacheStrategy: ControllerCacheStrategy = ControllerCacheStrategy.Prioritized - override var keyInitialization = { MainFlowContract.mainFlowKey } override val component: MainFlowComponent by lazy(LazyThreadSafetyMode.NONE) { activity.sampleApplication .appComponent .mainFlowComponent - .inputData(inputData) - .flow(this) + .controller(this) .build() } - - override val flowModel by lazy(LazyThreadSafetyMode.NONE) { + override val controllerModel by lazy(LazyThreadSafetyMode.NONE) { component.flowModel } + override val modelBinding by lazy(LazyThreadSafetyMode.NONE) { + ModelBinding( + model = controllerModel, + ) + } - override fun onAttach() { - super.onAttach() - - flowModel.tabsStateFlow() - .collectTillDetachView { tabsState -> - binding.bottomBar.setItems(tabsState.tabs) - binding.bottomBar.setSelected(tabsState.selectedTabId) - } + override fun onShown(view: View) { binding.bottomBar.selectedItemFlow() .collectTillDetachView { tabId -> - flowModel.onTabSelected(tabId) + controllerModel.onTabSelected(tabId) } } - override fun updateUi(step: Step) = Unit - + override fun render(uiState: UIState, payload: Any?) { + binding.bottomBar.setItems(uiState.tabs) + binding.bottomBar.setSelected(uiState.selectedTabId) + } } \ No newline at end of file diff --git a/samples/messenger/app/src/main/java/com/revolut/kompot/sample/ui/flows/main/MainFlowContract.kt b/samples/messenger/app/src/main/java/com/revolut/kompot/sample/ui/flows/main/MainFlowContract.kt index bcdbffb..6a30800 100644 --- a/samples/messenger/app/src/main/java/com/revolut/kompot/sample/ui/flows/main/MainFlowContract.kt +++ b/samples/messenger/app/src/main/java/com/revolut/kompot/sample/ui/flows/main/MainFlowContract.kt @@ -1,41 +1,40 @@ package com.revolut.kompot.sample.ui.flows.main import com.revolut.kompot.common.IOData -import com.revolut.kompot.navigable.ControllerKey -import com.revolut.kompot.navigable.flow.FlowModel import com.revolut.kompot.navigable.flow.FlowState -import com.revolut.kompot.navigable.flow.FlowStep +import com.revolut.kompot.navigable.flow.ReusableFlowStep +import com.revolut.kompot.navigable.vc.composite.ui_states_flow.UIStatesFlowModel +import com.revolut.kompot.navigable.vc.ui.States import com.revolut.kompot.sample.ui.views.BottomBar -import kotlinx.coroutines.flow.Flow import kotlinx.parcelize.Parcelize interface MainFlowContract { - interface FlowModelApi : FlowModel { + interface FlowModelApi : UIStatesFlowModel { fun onTabSelected(tabId: String) - fun tabsStateFlow(): Flow } - data class TabsState( + data class DomainState(val selectedTabId: String) : States.Domain + + data class UIState( val selectedTabId: String, val tabs: List - ) + ) : States.UI @Parcelize data class State( val selectedTabId: String ) : FlowState - sealed class Step : FlowStep { + sealed class Step : ReusableFlowStep { @Parcelize - object ChatList : Step() + object ChatList : Step() { + override val key: String get() = "chatList" + } @Parcelize - object ContactList : Step() - } - - companion object { - val mainFlowKey = ControllerKey("MainFlow") + object ContactList : Step() { + override val key: String get() = "contactList" + } } - } \ No newline at end of file diff --git a/samples/messenger/app/src/main/java/com/revolut/kompot/sample/ui/flows/main/MainFlowModel.kt b/samples/messenger/app/src/main/java/com/revolut/kompot/sample/ui/flows/main/MainFlowModel.kt index 8f8665c..df72fd8 100644 --- a/samples/messenger/app/src/main/java/com/revolut/kompot/sample/ui/flows/main/MainFlowModel.kt +++ b/samples/messenger/app/src/main/java/com/revolut/kompot/sample/ui/flows/main/MainFlowModel.kt @@ -1,55 +1,49 @@ package com.revolut.kompot.sample.ui.flows.main import com.revolut.kompot.common.IOData -import com.revolut.kompot.navigable.Controller import com.revolut.kompot.navigable.TransitionAnimation -import com.revolut.kompot.navigable.flow.BaseFlowModel +import com.revolut.kompot.navigable.vc.ViewControllerModel +import com.revolut.kompot.navigable.vc.flow.FlowCoordinator +import com.revolut.kompot.navigable.vc.ui.ModelState +import com.revolut.kompot.navigable.vc.ui.States import com.revolut.kompot.sample.R -import com.revolut.kompot.sample.feature.chat.ui.screens.chat_list.ChatListScreen -import com.revolut.kompot.sample.feature.contacts.ui.screens.contacts.ContactListScreen -import com.revolut.kompot.sample.ui.flows.main.MainFlowContract.* +import com.revolut.kompot.sample.feature.chat.ui.screens.chat_list.ChatListViewController +import com.revolut.kompot.sample.feature.contacts.ui.screens.contacts.ContactListViewController +import com.revolut.kompot.sample.ui.flows.main.MainFlowContract.DomainState +import com.revolut.kompot.sample.ui.flows.main.MainFlowContract.FlowModelApi +import com.revolut.kompot.sample.ui.flows.main.MainFlowContract.Step +import com.revolut.kompot.sample.ui.flows.main.MainFlowContract.UIState import com.revolut.kompot.sample.ui.views.BottomBar -import kotlinx.coroutines.flow.flowOf import javax.inject.Inject private const val CHATS_TAB_ID = "CHAT_TAB_ID" private const val CONTACTS_TAB_ID = "CONTACTS_TAB_ID" -class MainFlowModel @Inject constructor() : BaseFlowModel(), FlowModelApi { +class MainFlowModel @Inject constructor() : ViewControllerModel(), + FlowModelApi { - override val initialStep: Step = Step.ChatList - override val initialState: State = State(CHATS_TAB_ID) - - override fun tabsStateFlow() = flowOf( - TabsState( - selectedTabId = currentState.selectedTabId, - tabs = bottomBarTabs - ) + override val state = ModelState( + stateMapper = StateMapper(), + initialState = DomainState(CHATS_TAB_ID) ) - - override fun getController(step: Step): Controller { - val flowKey = MainFlowContract.mainFlowKey - return when (step) { - Step.ChatList -> dependentController(flowKey, step) { - ChatListScreen() - } - is Step.ContactList -> dependentController(flowKey, step) { - ContactListScreen() - } + override val flowCoordinator = FlowCoordinator(Step.ChatList) { step -> + when (step) { + Step.ChatList -> ChatListViewController() + Step.ContactList -> ContactListViewController() } } override fun onTabSelected(tabId: String) { val tabStep = tabId.toStep() - if (step == tabStep) { + if (flowCoordinator.step == tabStep) { return } - next( + flowCoordinator.next( step = tabStep, addCurrentStepToBackStack = false, - animation = TransitionAnimation.NONE + animation = TransitionAnimation.NONE, ) - currentState = currentState.copy(selectedTabId = tabId) + state.update { copy(selectedTabId = tabId) } } private fun String.toStep(): Step = when (this) { @@ -57,17 +51,20 @@ class MainFlowModel @Inject constructor() : BaseFlowModel Step.ContactList else -> throw IllegalStateException("No such tab") } - } -private val bottomBarTabs: List - get() = listOf( - BottomBar.Item( - id = CONTACTS_TAB_ID, - icon = R.drawable.ic_contacts - ), - BottomBar.Item( - id = CHATS_TAB_ID, - icon = R.drawable.ic_chats +class StateMapper : States.Mapper { + override fun mapState(domainState: DomainState) = UIState( + selectedTabId = domainState.selectedTabId, + tabs = listOf( + BottomBar.Item( + id = CONTACTS_TAB_ID, + icon = R.drawable.ic_contacts + ), + BottomBar.Item( + id = CHATS_TAB_ID, + icon = R.drawable.ic_chats + ) ) - ) \ No newline at end of file + ) +} \ No newline at end of file diff --git a/samples/messenger/app/src/main/java/com/revolut/kompot/sample/ui/flows/main/di/MainFlowComponent.kt b/samples/messenger/app/src/main/java/com/revolut/kompot/sample/ui/flows/main/di/MainFlowComponent.kt index b828676..872a290 100644 --- a/samples/messenger/app/src/main/java/com/revolut/kompot/sample/ui/flows/main/di/MainFlowComponent.kt +++ b/samples/messenger/app/src/main/java/com/revolut/kompot/sample/ui/flows/main/di/MainFlowComponent.kt @@ -1,23 +1,17 @@ package com.revolut.kompot.sample.ui.flows.main.di -import com.revolut.kompot.common.IOData -import com.revolut.kompot.di.flow.BaseFlowComponent -import com.revolut.kompot.di.scope.FlowScope +import com.revolut.kompot.navigable.vc.di.ViewControllerComponent +import com.revolut.kompot.navigable.vc.di.ViewControllerScope import com.revolut.kompot.sample.ui.flows.main.MainFlowContract -import dagger.BindsInstance import dagger.Subcomponent -@FlowScope +@ViewControllerScope @Subcomponent( modules = [MainFlowModule::class] ) -interface MainFlowComponent : BaseFlowComponent { +interface MainFlowComponent : ViewControllerComponent { val flowModel: MainFlowContract.FlowModelApi @Subcomponent.Builder - interface Builder : BaseFlowComponent.Builder { - @BindsInstance - fun inputData(inputData: IOData.EmptyInput): Builder - } - + interface Builder : ViewControllerComponent.Builder } \ No newline at end of file diff --git a/samples/messenger/app/src/main/java/com/revolut/kompot/sample/ui/flows/main/di/MainFlowModule.kt b/samples/messenger/app/src/main/java/com/revolut/kompot/sample/ui/flows/main/di/MainFlowModule.kt index 20aeffb..71d6d87 100644 --- a/samples/messenger/app/src/main/java/com/revolut/kompot/sample/ui/flows/main/di/MainFlowModule.kt +++ b/samples/messenger/app/src/main/java/com/revolut/kompot/sample/ui/flows/main/di/MainFlowModule.kt @@ -1,19 +1,15 @@ package com.revolut.kompot.sample.ui.flows.main.di -import com.revolut.kompot.di.flow.BaseFlowModule -import com.revolut.kompot.di.scope.FlowScope +import com.revolut.kompot.navigable.vc.di.ViewControllerModule +import com.revolut.kompot.navigable.vc.di.ViewControllerScope import com.revolut.kompot.sample.ui.flows.main.MainFlowContract import com.revolut.kompot.sample.ui.flows.main.MainFlowModel import dagger.Binds import dagger.Module @Module -abstract class MainFlowModule : BaseFlowModule { - - @Binds - @FlowScope - abstract fun bindsMainFlowModel( - mainFlowModel: MainFlowModel - ) : MainFlowContract.FlowModelApi +abstract class MainFlowModule : ViewControllerModule { + @[Binds ViewControllerScope] + abstract fun bindsMainFlowModel(mainFlowModel: MainFlowModel): MainFlowContract.FlowModelApi } \ No newline at end of file diff --git a/samples/messenger/app/src/main/java/com/revolut/kompot/sample/ui/flows/root/RootFlowImpl.kt b/samples/messenger/app/src/main/java/com/revolut/kompot/sample/ui/flows/root/RootFlowImpl.kt index dace4cf..3bcdc43 100644 --- a/samples/messenger/app/src/main/java/com/revolut/kompot/sample/ui/flows/root/RootFlowImpl.kt +++ b/samples/messenger/app/src/main/java/com/revolut/kompot/sample/ui/flows/root/RootFlowImpl.kt @@ -2,20 +2,31 @@ package com.revolut.kompot.sample.ui.flows.root import android.view.View import com.revolut.kompot.common.IOData +import com.revolut.kompot.dialog.DefaultLoadingDialogDisplayer +import com.revolut.kompot.dialog.DialogDisplayer import com.revolut.kompot.navigable.root.RootFlow -import com.revolut.kompot.navigable.utils.viewBinding +import com.revolut.kompot.view.ControllerContainerFrameLayout import com.revolut.kompot.sample.Features import com.revolut.kompot.sample.R import com.revolut.kompot.sample.databinding.FlowRootBinding import com.revolut.kompot.sample.sampleApplication import com.revolut.kompot.sample.ui.flows.root.di.RootFlowComponent -import com.revolut.kompot.view.ControllerContainerFrameLayout +import com.revolut.kompot.navigable.utils.viewBinding class RootFlowImpl : RootFlow(IOData.EmptyInput) { + override val rootDialogDisplayer by lazy(LazyThreadSafetyMode.NONE) { + DialogDisplayer( + loadingDialogDisplayer = DefaultLoadingDialogDisplayer(activity), + delegates = emptyList() + ) + } + override val layoutId = R.layout.flow_root private val binding by viewBinding(FlowRootBinding::bind) + override val controllerName = "Root" + override val component: RootFlowComponent by lazy(LazyThreadSafetyMode.NONE) { activity.sampleApplication .appComponent @@ -34,6 +45,6 @@ class RootFlowImpl : RootFlow(IOData.E override fun onCreateFlowView(view: View) { super.onCreateFlowView(view) - component.featureRegistry.registerFeatures(Features.createFeatures()) + component.featuresRegistry.registerFeatures(Features.createFeaturesList()) } } \ No newline at end of file diff --git a/samples/messenger/app/src/main/java/com/revolut/kompot/sample/ui/flows/root/RootFlowModel.kt b/samples/messenger/app/src/main/java/com/revolut/kompot/sample/ui/flows/root/RootFlowModel.kt index 4c582ea..b9b07a1 100644 --- a/samples/messenger/app/src/main/java/com/revolut/kompot/sample/ui/flows/root/RootFlowModel.kt +++ b/samples/messenger/app/src/main/java/com/revolut/kompot/sample/ui/flows/root/RootFlowModel.kt @@ -6,11 +6,10 @@ import com.revolut.kompot.common.NavigationDestination import com.revolut.kompot.navigable.Controller import com.revolut.kompot.navigable.root.BaseRootFlowModel import com.revolut.kompot.sample.ui.flows.main.MainFlow -import timber.log.Timber import javax.inject.Inject class RootFlowModel @Inject constructor( - private val featureRegistry: FeaturesRegistry + private val featuresRegistry: FeaturesRegistry ) : BaseRootFlowModel(), RootFlowContract.FlowModelApi { @@ -19,14 +18,13 @@ class RootFlowModel @Inject constructor( override fun getController(step: RootFlowContract.Step): Controller = when (step) { is RootFlowContract.Step.MainFlow -> MainFlow() - is RootFlowContract.Step.FeatureRegistryStep -> featureRegistry.getControllerOrThrow( + is RootFlowContract.Step.FeatureRegistryStep -> featuresRegistry.getControllerOrThrow( destination = step.destination, - flowModel = this + flowModel = this, ) } override fun handleErrorEvent(throwable: Throwable): Boolean { - Timber.tag("RootErrorHandler").e(throwable) return true } @@ -34,9 +32,10 @@ class RootFlowModel @Inject constructor( when (navigationDestination) { is InternalDestination<*> -> { next( - step = RootFlowContract.Step.FeatureRegistryStep(navigationDestination), + RootFlowContract.Step.FeatureRegistryStep(navigationDestination), addCurrentStepToBackStack = navigationDestination.addCurrentStepToBackStack, animation = navigationDestination.animation + ) true } diff --git a/samples/messenger/app/src/main/java/com/revolut/kompot/sample/ui/flows/root/di/RootFlowComponent.kt b/samples/messenger/app/src/main/java/com/revolut/kompot/sample/ui/flows/root/di/RootFlowComponent.kt index 86e787a..f46d97c 100644 --- a/samples/messenger/app/src/main/java/com/revolut/kompot/sample/ui/flows/root/di/RootFlowComponent.kt +++ b/samples/messenger/app/src/main/java/com/revolut/kompot/sample/ui/flows/root/di/RootFlowComponent.kt @@ -13,7 +13,7 @@ import dagger.Subcomponent interface RootFlowComponent : BaseFlowComponent { val flowModel: RootFlowContract.FlowModelApi - val featureRegistry: FeaturesRegistry + val featuresRegistry: FeaturesRegistry @Subcomponent.Builder interface Builder : BaseFlowComponent.Builder diff --git a/samples/messenger/app/src/main/res/layout/flow_root.xml b/samples/messenger/app/src/main/res/layout/flow_root.xml index 411134d..3981d8f 100644 --- a/samples/messenger/app/src/main/res/layout/flow_root.xml +++ b/samples/messenger/app/src/main/res/layout/flow_root.xml @@ -1,21 +1,17 @@ + android:background="@android:color/darker_gray"> + android:layout_height="match_parent" /> + android:visibility="invisible" /> \ No newline at end of file diff --git a/samples/messenger/build.gradle b/samples/messenger/build.gradle index d5cc55d..ac1dd7c 100644 --- a/samples/messenger/build.gradle +++ b/samples/messenger/build.gradle @@ -6,8 +6,9 @@ buildscript { dependencies { apply from: "dependencies.gradle" - classpath 'com.android.tools.build:gradle:7.0.4' - classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.10' + classpath 'com.android.tools.build:gradle:7.4.2' + classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.7.21' + classpath("com.vanniktech:gradle-maven-publish-plugin:0.20.0") } } diff --git a/samples/messenger/data/build.gradle b/samples/messenger/data/build.gradle index 9288d9b..a26b200 100644 --- a/samples/messenger/data/build.gradle +++ b/samples/messenger/data/build.gradle @@ -10,4 +10,8 @@ addDependencies( [configuration: "implementation", dependency: project(':utils')], [configuration: "kapt", dependency: "com.google.dagger:dagger-compiler:$dagger_version"] ] -) \ No newline at end of file +) + +android { + namespace "com.revolut.kompot.sample.data" +} \ No newline at end of file diff --git a/samples/messenger/dependencies.gradle b/samples/messenger/dependencies.gradle index cb1ae6f..cfae995 100644 --- a/samples/messenger/dependencies.gradle +++ b/samples/messenger/dependencies.gradle @@ -1,8 +1,8 @@ ext { kompot_sample = [ targetSdkVersion : 31, - compileSdkVersion : 31, - minSdkVersion : 23, - androidBuildToolsVersion: "30.0.3" + compileSdkVersion : 33, + minSdkVersion : 24, + buildToolsVersion : '33.0.2', ] } \ No newline at end of file diff --git a/samples/messenger/feature_chat_api/build.gradle b/samples/messenger/feature_chat_api/build.gradle index 55fef2c..ec05c85 100644 --- a/samples/messenger/feature_chat_api/build.gradle +++ b/samples/messenger/feature_chat_api/build.gradle @@ -8,4 +8,8 @@ addDependencies( as_general_dependencies_api + [ [configuration: "implementation", dependency: project(':feature_contacts_api')] ] -) \ No newline at end of file +) + +android { + namespace "com.revolut.kompot.sample.feature.api.chat" +} \ No newline at end of file diff --git a/samples/messenger/feature_chat_api/src/main/java/com/revolut/kompot/sample/feature/chat/navigation/ChatNavigationDestination.kt b/samples/messenger/feature_chat_api/src/main/java/com/revolut/kompot/sample/feature/chat/navigation/ChatNavigationDestination.kt index f6517a2..c972f01 100644 --- a/samples/messenger/feature_chat_api/src/main/java/com/revolut/kompot/sample/feature/chat/navigation/ChatNavigationDestination.kt +++ b/samples/messenger/feature_chat_api/src/main/java/com/revolut/kompot/sample/feature/chat/navigation/ChatNavigationDestination.kt @@ -13,6 +13,6 @@ data class ChatNavigationDestination( @Parcelize data class InputData( val contact: Contact - ) : IOData.Input + ): IOData.Input } \ No newline at end of file diff --git a/samples/messenger/feature_chat_impl/build.gradle b/samples/messenger/feature_chat_impl/build.gradle index a74e535..724690c 100644 --- a/samples/messenger/feature_chat_impl/build.gradle +++ b/samples/messenger/feature_chat_impl/build.gradle @@ -6,6 +6,7 @@ apply from: '../default_impl_lib_config.gradle' apply from: '../kompot_sample_dependencies.gradle' android { + namespace "com.revolut.kompot.sample.feature.chat" testOptions { unitTests.returnDefaultValues = true diff --git a/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ChatFeatureGateway.kt b/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ChatFeatureGateway.kt index 95a272f..a9a2458 100644 --- a/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ChatFeatureGateway.kt +++ b/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ChatFeatureGateway.kt @@ -7,7 +7,7 @@ import com.revolut.kompot.navigable.flow.BaseFlowModel import com.revolut.kompot.sample.feature.chat.di.ChatArguments import com.revolut.kompot.sample.feature.chat.di.ChatsApiProvider import com.revolut.kompot.sample.feature.chat.navigation.ChatNavigationDestination -import com.revolut.kompot.sample.feature.chat.ui.screens.chat.ChatScreen +import com.revolut.kompot.sample.feature.chat.ui.screens.chat.ChatViewController class ChatFeatureGateway(argsProvider: () -> ChatArguments) : FeatureGateway { @@ -19,7 +19,7 @@ class ChatFeatureGateway(argsProvider: () -> ChatArguments) : FeatureGateway { destination: NavigationDestination, flowModel: BaseFlowModel<*, *, *> ): Controller? = when (destination) { - is ChatNavigationDestination -> ChatScreen(destination.inputData) + is ChatNavigationDestination -> ChatViewController(destination.inputData) else -> null } diff --git a/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/di/ChatFeatureComponent.kt b/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/di/ChatFeatureComponent.kt index dc42024..34d2a73 100644 --- a/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/di/ChatFeatureComponent.kt +++ b/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/di/ChatFeatureComponent.kt @@ -2,11 +2,11 @@ package com.revolut.kompot.sample.feature.chat.di import com.revolut.kompot.sample.data.api.DataApi import com.revolut.kompot.sample.feature.chat.api.ChatApi -import com.revolut.kompot.sample.feature.chat.ui.screens.chat.di.ChatScreenInjector -import com.revolut.kompot.sample.feature.chat.ui.screens.chat_list.di.ChatListScreenInjector +import com.revolut.kompot.sample.feature.chat.ui.screens.chat.di.ChatControllerInjector +import com.revolut.kompot.sample.feature.chat.ui.screens.chat_list.di.ChatListControllerInjector import com.revolut.kompot.sample.feature.contacts.api.ContactsApi -import com.revolut.kompot.sample.utils.LazySingletonHolder import com.revolut.kompot.sample.utils.api.UtilsApi +import com.revolut.kompot.sample.utils.LazySingletonHolder import com.revolut.kompot.sample.utils.di.FeatureScope import dagger.Component @@ -15,7 +15,7 @@ import dagger.Component modules = [ChatFeatureModule::class], dependencies = [DataApi::class, UtilsApi::class, ContactsApi::class] ) -interface ChatFeatureComponent : ChatApi, ChatScreenInjector, ChatListScreenInjector { +interface ChatFeatureComponent : ChatApi, ChatControllerInjector, ChatListControllerInjector { @Component.Factory interface Builder { fun create( diff --git a/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/delegates/MessageRowDelegate.kt b/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/delegates/MessageRowDelegate.kt index 95f982b..a59eded 100644 --- a/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/delegates/MessageRowDelegate.kt +++ b/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/delegates/MessageRowDelegate.kt @@ -12,18 +12,26 @@ import com.revolut.kompot.sample.feature.chat.R import com.revolut.recyclerkit.delegates.BaseRecyclerViewDelegate import com.revolut.recyclerkit.delegates.BaseRecyclerViewHolder import com.revolut.recyclerkit.delegates.ListItem +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow class MessageRowDelegate : BaseRecyclerViewDelegate( R.layout.delegate_message, { _, data -> data is Model } ) { + private val onItemClicksSharedFlow by lazy(LazyThreadSafetyMode.NONE) { + MutableSharedFlow(extraBufferCapacity = 1) + } + override fun onCreateViewHolder(parent: ViewGroup): ViewHolder { return ViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.delegate_message, parent, false)) } - override fun onBindViewHolder(holder: ViewHolder, data: Model, pos: Int, payloads: List?) { + override fun onBindViewHolder(holder: ViewHolder, data: Model, pos: Int, payloads: List) { super.onBindViewHolder(holder, data, pos, payloads) + holder.itemView.setOnClickListener { onItemClicksSharedFlow.tryEmit(data.listId) } if (payloads.isNullOrEmpty()) { holder.text.text = data.text @@ -48,6 +56,8 @@ class MessageRowDelegate : BaseRecyclerViewDelegate = onItemClicksSharedFlow.asSharedFlow() + class ViewHolder(itemView: View) : BaseRecyclerViewHolder(itemView) { private val layoutMessage: ConstraintLayout = itemView.findViewById(R.id.layoutMessage) val text: TextView = itemView.findViewById(R.id.tvText) diff --git a/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/ChatScreenContract.kt b/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/ChatContract.kt similarity index 71% rename from samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/ChatScreenContract.kt rename to samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/ChatContract.kt index a542f21..b7343e0 100644 --- a/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/ChatScreenContract.kt +++ b/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/ChatContract.kt @@ -1,30 +1,31 @@ package com.revolut.kompot.sample.feature.chat.ui.screens.chat import com.revolut.kompot.common.IOData -import com.revolut.kompot.navigable.screen.ScreenModel -import com.revolut.kompot.navigable.screen.ScreenStates +import com.revolut.kompot.navigable.vc.ui.States +import com.revolut.kompot.navigable.vc.ui.list.UIListStatesModel import com.revolut.kompot.sample.feature.chat.domain.Message import com.revolut.kompot.sample.feature.contacts.domain.Contact import com.revolut.recyclerkit.delegates.ListItem import kotlinx.parcelize.Parcelize -interface ChatScreenContract { +interface ChatContract { - interface ScreenModelApi : ScreenModel { + interface ModelApi : UIListStatesModel { fun onInputChanged(text: String) fun onActionButtonClick() + fun onMessageClicked(listId: String) } data class DomainState( val contact: Contact, val messages: List, val messageInputText: String - ) : ScreenStates.Domain + ) : States.Domain @Parcelize data class RetainedState( val messageInputText: String - ): ScreenStates.RetainedDomain + ) : States.PersistentDomain data class UIState( val contactName: String, @@ -33,6 +34,6 @@ interface ChatScreenContract { val messageInputText: String, val actionButtonEnabled: Boolean, override val items: List - ) : ScreenStates.UIList + ) : States.UIList } \ No newline at end of file diff --git a/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/ChatStateMapper.kt b/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/ChatStateMapper.kt index 500f560..07cdd86 100644 --- a/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/ChatStateMapper.kt +++ b/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/ChatStateMapper.kt @@ -1,17 +1,17 @@ package com.revolut.kompot.sample.feature.chat.ui.screens.chat -import com.revolut.kompot.navigable.screen.StateMapper -import com.revolut.kompot.sample.utils.date.printer.DatePrinter +import com.revolut.kompot.navigable.vc.ui.States import com.revolut.kompot.sample.feature.chat.data.USER_ID import com.revolut.kompot.sample.feature.chat.domain.Message import com.revolut.kompot.sample.feature.chat.ui.delegates.MessageRowDelegate -import com.revolut.kompot.sample.feature.chat.ui.screens.chat.ChatScreenContract.DomainState -import com.revolut.kompot.sample.feature.chat.ui.screens.chat.ChatScreenContract.UIState +import com.revolut.kompot.sample.feature.chat.ui.screens.chat.ChatContract.DomainState +import com.revolut.kompot.sample.feature.chat.ui.screens.chat.ChatContract.UIState +import com.revolut.kompot.sample.utils.date.printer.DatePrinter import javax.inject.Inject class ChatStateMapper @Inject constructor( private val datePrinter: DatePrinter -) : StateMapper { +) : States.Mapper { override fun mapState(domainState: DomainState): UIState { val inputText = domainState.messageInputText diff --git a/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/ChatScreen.kt b/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/ChatViewController.kt similarity index 51% rename from samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/ChatScreen.kt rename to samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/ChatViewController.kt index d31c1f1..58a2d3d 100644 --- a/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/ChatScreen.kt +++ b/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/ChatViewController.kt @@ -1,82 +1,71 @@ package com.revolut.kompot.sample.feature.chat.ui.screens.chat -import android.content.Context import android.view.View import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView import com.revolut.kompot.common.IOData -import com.revolut.kompot.navigable.screen.BaseRecyclerViewScreen -import com.revolut.kompot.navigable.screen.ScreenStates import com.revolut.kompot.navigable.utils.viewBinding +import com.revolut.kompot.navigable.vc.ViewController +import com.revolut.kompot.navigable.vc.ui.list.ModelBinding +import com.revolut.kompot.navigable.vc.ui.list.UIListStatesController import com.revolut.kompot.sample.feature.chat.R import com.revolut.kompot.sample.feature.chat.databinding.ScreenChatBinding import com.revolut.kompot.sample.feature.chat.di.ChatsApiProvider import com.revolut.kompot.sample.feature.chat.navigation.ChatNavigationDestination.InputData import com.revolut.kompot.sample.feature.chat.ui.delegates.MessageRowDelegate -import com.revolut.kompot.sample.feature.chat.ui.screens.chat.ChatScreenContract.UIState -import com.revolut.kompot.sample.feature.chat.ui.screens.chat.di.ChatScreenComponent -import com.revolut.kompot.sample.feature.chat.ui.screens.chat.di.ChatScreenInjector -import com.revolut.recyclerkit.delegates.RecyclerViewDelegate -import kotlinx.coroutines.flow.Flow +import com.revolut.kompot.sample.feature.chat.ui.screens.chat.ChatContract.UIState +import com.revolut.kompot.sample.feature.chat.ui.screens.chat.di.ChatControllerInjector -class ChatScreen(inputData: InputData) : BaseRecyclerViewScreen(inputData) { +class ChatViewController(inputData: InputData) : ViewController(), + UIListStatesController { override val layoutId: Int = R.layout.screen_chat private val binding by viewBinding(ScreenChatBinding::bind) + private val messageRowDelegate = MessageRowDelegate() override val fitStatusBar: Boolean = true - override val screenComponent: ChatScreenComponent by lazy(LazyThreadSafetyMode.NONE) { - (ChatsApiProvider.component as ChatScreenInjector) - .getChatScreenComponentBuilder() - .inputData(inputData) - .screen(this) - .build() - } - - override val screenModel by lazy(LazyThreadSafetyMode.NONE) { - screenComponent.screenModel - } - - override val delegates: List> - get() = listOf( - MessageRowDelegate() - ) - - override fun debounceStream(): Flow = - binding.vMessageInput.textStream() - - override fun createLayoutManager(context: Context): RecyclerView.LayoutManager { - return LinearLayoutManager(context).apply { - stackFromEnd = true + override val component = (ChatsApiProvider.component as ChatControllerInjector) + .getChatControllerComponentBuilder() + .inputData(inputData) + .controller(this) + .build() + + override val controllerModel = component.model + override val modelBinding = ModelBinding( + model = controllerModel, + delegates = listOf(messageRowDelegate), + debounceStreamProvider = { binding.vMessageInput.textStream() }, + layoutManagerProvider = { context -> + LinearLayoutManager(context).apply { + stackFromEnd = true + } } - } - - override fun onScreenViewAttached(view: View) { - super.onScreenViewAttached(view) + ) + override fun onShown(view: View) { binding.vMessageInput.textStream() .collectTillDetachView { text -> - screenModel.onInputChanged(text) + controllerModel.onInputChanged(text) } - binding.vMessageInput.actionClicksStream() .collectTillDetachView { - screenModel.onActionButtonClick() + controllerModel.onActionButtonClick() } binding.vNavBar.navigationButtonClicksStream() .collectTillDetachView { activity.onBackPressed() } + messageRowDelegate.observeItemClicksStream() + .collectTillDetachView { + controllerModel.onMessageClicked(it) + } } - override fun bindScreen(uiState: UIState, payload: ScreenStates.UIPayload?) { - super.bindScreen(uiState, payload) - + override fun render(uiState: UIState, payload: Any?) { bindChatNavBar(uiState) bindMessageInput(uiState) if (uiState.items.isNotEmpty()) { - recyclerView.scrollToPosition(uiState.items.size - 1) + modelBinding.recyclerView.scrollToPosition(uiState.items.size - 1) } } diff --git a/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/ChatScreenModel.kt b/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/ChatViewModel.kt similarity index 55% rename from samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/ChatScreenModel.kt rename to samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/ChatViewModel.kt index 4d3db3f..059502b 100644 --- a/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/ChatScreenModel.kt +++ b/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/ChatViewModel.kt @@ -1,41 +1,49 @@ package com.revolut.kompot.sample.feature.chat.ui.screens.chat import com.revolut.kompot.common.IOData -import com.revolut.kompot.navigable.screen.BaseScreenModel -import com.revolut.kompot.navigable.screen.StateMapper -import com.revolut.kompot.navigable.screen.state.SaveStateDelegate +import com.revolut.kompot.navigable.vc.ViewControllerModel +import com.revolut.kompot.navigable.vc.ui.ModelState +import com.revolut.kompot.navigable.vc.ui.SaveStateDelegate +import com.revolut.kompot.navigable.vc.ui.States import com.revolut.kompot.sample.feature.chat.data.ChatRepository import com.revolut.kompot.sample.feature.chat.domain.Message import com.revolut.kompot.sample.feature.chat.navigation.ChatNavigationDestination -import com.revolut.kompot.sample.feature.chat.ui.screens.chat.ChatScreenContract.* +import com.revolut.kompot.sample.feature.chat.ui.screens.chat.ChatContract.DomainState +import com.revolut.kompot.sample.feature.chat.ui.screens.chat.ChatContract.ModelApi +import com.revolut.kompot.sample.feature.chat.ui.screens.chat.ChatContract.RetainedState +import com.revolut.kompot.sample.feature.chat.ui.screens.chat.ChatContract.UIState +import com.revolut.kompot.sample.feature.chat.ui.screens.chat.extension.ChatMessageActionExtension import com.revolut.kompot.sample.feature.chat.utils.message_generator.MessageGenerator import com.revolut.kompot.sample.utils.date.provider.DateProvider import kotlinx.coroutines.flow.distinctUntilChanged import javax.inject.Inject -class ChatScreenModel @Inject constructor( +class ChatViewModel @Inject constructor( private val inputData: ChatNavigationDestination.InputData, private val chatRepository: ChatRepository, private val dateProvider: DateProvider, private val messageGenerator: MessageGenerator, - stateMapper: StateMapper -) : BaseScreenModel(stateMapper), ScreenModelApi { + private val chatMessageActionExtension: ChatMessageActionExtension, + stateMapper: States.Mapper, +) : ViewControllerModel(), ModelApi { - override val initialState = DomainState( - contact = inputData.contact, - messages = emptyList(), - messageInputText = "" + override val state = ModelState( + initialState = DomainState( + contact = inputData.contact, + messages = emptyList(), + messageInputText = "" + ), + stateMapper = stateMapper, + saveStateDelegate = ChatSaveStateDelegate(), ) - override val saveStateDelegate = ChatSaveStateDelegate() - override fun onCreated() { super.onCreated() chatRepository.messagesStream(inputData.contact.id) .distinctUntilChanged() .collectTillFinish { messages -> - updateState { + state.update { copy(messages = messages) } chatRepository.markMessagesAsRead(inputData.contact.id) @@ -48,10 +56,18 @@ class ChatScreenModel @Inject constructor( tillHide { chatRepository.markMessagesAsRead(inputData.contact.id) } + chatMessageActionExtension.stateStream() + .collectTillHide { delegateState -> + if (delegateState.easterEggDiscovered) { + state.update { + copy(messageInputText = "Hehehe, thank you!") + } + } + } } override fun onInputChanged(text: String) { - updateState { + state.update { copy(messageInputText = text) } } @@ -60,23 +76,28 @@ class ChatScreenModel @Inject constructor( val contactId = inputData.contact.id val message = Message.createFromUser( contactId = contactId, - text = state.messageInputText, + text = state.current.messageInputText, date = dateProvider.provideDate() ) tillFinish { val receiverId = chatRepository.saveMessage(inputData.contact, message) messageGenerator.generateMessage(receiverId) } - updateState { + state.update { copy(messageInputText = "") } } + override fun onMessageClicked(listId: String) { + chatMessageActionExtension.onMessageClicked(listId) + } + } class ChatSaveStateDelegate : SaveStateDelegate() { - override fun getRetainedState(currentState: DomainState) = RetainedState(currentState.messageInputText) + override fun getRetainedState(currentState: DomainState) = + RetainedState(currentState.messageInputText) override fun restoreDomainState( initialState: DomainState, diff --git a/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/di/ChatScreenComponent.kt b/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/di/ChatControllerComponent.kt similarity index 50% rename from samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/di/ChatScreenComponent.kt rename to samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/di/ChatControllerComponent.kt index 1927280..375f947 100644 --- a/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/di/ChatScreenComponent.kt +++ b/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/di/ChatControllerComponent.kt @@ -1,22 +1,21 @@ package com.revolut.kompot.sample.feature.chat.ui.screens.chat.di -import com.revolut.kompot.di.scope.ScreenScope -import com.revolut.kompot.di.screen.BaseScreenComponent +import com.revolut.kompot.navigable.vc.di.ViewControllerComponent +import com.revolut.kompot.navigable.vc.di.ViewControllerScope import com.revolut.kompot.sample.feature.chat.navigation.ChatNavigationDestination -import com.revolut.kompot.sample.feature.chat.ui.screens.chat.ChatScreenContract +import com.revolut.kompot.sample.feature.chat.ui.screens.chat.ChatContract import dagger.BindsInstance import dagger.Subcomponent -@ScreenScope -@Subcomponent(modules = [ChatScreenModule::class]) -interface ChatScreenComponent : BaseScreenComponent { +@ViewControllerScope +@Subcomponent(modules = [ChatControllerModule::class]) +interface ChatControllerComponent : ViewControllerComponent { - val screenModel: ChatScreenContract.ScreenModelApi + val model: ChatContract.ModelApi @Subcomponent.Builder - interface Builder : BaseScreenComponent.Builder { + interface Builder : ViewControllerComponent.Builder { @BindsInstance fun inputData(inputData: ChatNavigationDestination.InputData): Builder } - } \ No newline at end of file diff --git a/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/di/ChatControllerInjector.kt b/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/di/ChatControllerInjector.kt new file mode 100644 index 0000000..ffeaf97 --- /dev/null +++ b/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/di/ChatControllerInjector.kt @@ -0,0 +1,6 @@ +package com.revolut.kompot.sample.feature.chat.ui.screens.chat.di + +interface ChatControllerInjector { + + fun getChatControllerComponentBuilder(): ChatControllerComponent.Builder +} \ No newline at end of file diff --git a/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/di/ChatControllerModule.kt b/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/di/ChatControllerModule.kt new file mode 100644 index 0000000..cfd3c6a --- /dev/null +++ b/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/di/ChatControllerModule.kt @@ -0,0 +1,49 @@ +package com.revolut.kompot.sample.feature.chat.ui.screens.chat.di + +import com.revolut.kompot.navigable.ControllerModelExtension +import com.revolut.kompot.navigable.vc.ViewController +import com.revolut.kompot.navigable.vc.di.ViewControllerModule +import com.revolut.kompot.navigable.vc.di.ViewControllerQualifier +import com.revolut.kompot.navigable.vc.di.ViewControllerScope +import com.revolut.kompot.navigable.vc.ui.States +import com.revolut.kompot.sample.feature.chat.ui.screens.chat.ChatContract.DomainState +import com.revolut.kompot.sample.feature.chat.ui.screens.chat.ChatContract.ModelApi +import com.revolut.kompot.sample.feature.chat.ui.screens.chat.ChatContract.UIState +import com.revolut.kompot.sample.feature.chat.ui.screens.chat.ChatStateMapper +import com.revolut.kompot.sample.feature.chat.ui.screens.chat.ChatViewModel +import com.revolut.kompot.sample.feature.chat.ui.screens.chat.extension.ChatMessageActionExtension +import com.revolut.kompot.sample.feature.chat.ui.screens.chat.extension.ChatMessageActionExtensionImpl +import com.revolut.kompot.sample.feature.chat.utils.message_generator.MessageGenerator +import com.revolut.kompot.sample.feature.chat.utils.message_generator.MessageGeneratorImpl +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.multibindings.IntoSet + +@Module +abstract class ChatControllerModule : ViewControllerModule { + + @[Binds ViewControllerScope] + abstract fun bindsChatViewModel(chatViewModel: ChatViewModel): ModelApi + + @[Binds ViewControllerScope] + abstract fun bindsChatStateMapper(chatStateMapper: ChatStateMapper): States.Mapper + + @[Binds ViewControllerScope] + abstract fun bindsChatMessageActionExtension(delegate: ChatMessageActionExtensionImpl): ChatMessageActionExtension + + @Module + companion object { + @[Provides JvmStatic ViewControllerScope] + fun provideMessageGenerator(@ViewControllerQualifier controller : ViewController<*>): MessageGenerator = + MessageGeneratorImpl( + contextProvider = { controller.activity } + ) + + @[Provides IntoSet ViewControllerScope] + fun bindChatMessageActionDelegateAsControllerModelExtension(extension: ChatMessageActionExtension): ControllerModelExtension { + return extension as ControllerModelExtension + } + } + +} \ No newline at end of file diff --git a/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/di/ChatScreenInjector.kt b/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/di/ChatScreenInjector.kt deleted file mode 100644 index 0dbb4e5..0000000 --- a/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/di/ChatScreenInjector.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.revolut.kompot.sample.feature.chat.ui.screens.chat.di - -interface ChatScreenInjector { - - fun getChatScreenComponentBuilder(): ChatScreenComponent.Builder - -} \ No newline at end of file diff --git a/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/di/ChatScreenModule.kt b/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/di/ChatScreenModule.kt deleted file mode 100644 index 79ba349..0000000 --- a/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/di/ChatScreenModule.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.revolut.kompot.sample.feature.chat.ui.screens.chat.di - -import com.revolut.kompot.di.scope.ScreenScope -import com.revolut.kompot.di.screen.BaseScreenModule -import com.revolut.kompot.navigable.screen.BaseScreen -import com.revolut.kompot.navigable.screen.StateMapper -import com.revolut.kompot.sample.feature.chat.ui.screens.chat.ChatScreenModel -import com.revolut.kompot.sample.feature.chat.ui.screens.chat.ChatScreenContract.* -import com.revolut.kompot.sample.feature.chat.ui.screens.chat.ChatStateMapper -import com.revolut.kompot.sample.feature.chat.utils.message_generator.MessageGenerator -import com.revolut.kompot.sample.feature.chat.utils.message_generator.MessageGeneratorImpl -import dagger.Binds -import dagger.Module -import dagger.Provides - -@Module -abstract class ChatScreenModule : BaseScreenModule { - - @Binds - @ScreenScope - abstract fun bindsChatScreenModel(chatScreenModel: ChatScreenModel): ScreenModelApi - - @Binds - @ScreenScope - abstract fun bindsChatStateMapper(chatStateMapper: ChatStateMapper): StateMapper - - @Module - companion object { - @Provides - @JvmStatic - @ScreenScope - fun provideMessageGenerator(screen: BaseScreen<*, *, *>): MessageGenerator = - MessageGeneratorImpl(screen.activity) - } - -} \ No newline at end of file diff --git a/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/extension/ChatMessageActionExtension.kt b/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/extension/ChatMessageActionExtension.kt new file mode 100644 index 0000000..e620d8e --- /dev/null +++ b/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/extension/ChatMessageActionExtension.kt @@ -0,0 +1,11 @@ +package com.revolut.kompot.sample.feature.chat.ui.screens.chat.extension + +import com.revolut.kompot.navigable.extension.StatefulControllerModelExtension + +interface ChatMessageActionExtension : StatefulControllerModelExtension { + + fun onMessageClicked(listId: String) + + data class DomainState(val easterEggDiscovered: Boolean) + +} \ No newline at end of file diff --git a/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/extension/ChatMessageActionExtensionImpl.kt b/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/extension/ChatMessageActionExtensionImpl.kt new file mode 100644 index 0000000..323b7f4 --- /dev/null +++ b/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/extension/ChatMessageActionExtensionImpl.kt @@ -0,0 +1,40 @@ +package com.revolut.kompot.sample.feature.chat.ui.screens.chat.extension + +import com.revolut.kompot.navigable.extension.BaseStatefulControllerModelExtension +import com.revolut.kompot.sample.feature.chat.data.ChatRepository +import com.revolut.kompot.sample.feature.chat.domain.Message +import com.revolut.kompot.sample.feature.chat.navigation.ChatNavigationDestination +import com.revolut.kompot.sample.utils.date.provider.DateProvider +import javax.inject.Inject +import kotlin.random.Random + +class ChatMessageActionExtensionImpl @Inject constructor( + private val inputData: ChatNavigationDestination.InputData, + private val chatRepository: ChatRepository, + private val dateProvider: DateProvider, +) : BaseStatefulControllerModelExtension(), + ChatMessageActionExtension { + + override val initialState = ChatMessageActionExtension.DomainState(easterEggDiscovered = false) + + override fun onMessageClicked(listId: String) { + if (state.easterEggDiscovered) return + + val easterEggDiscovered = Random.nextBoolean() + if (easterEggDiscovered) { + val message = Message.createToUser( + contactId = inputData.contact.id, + text = "\uD83C\uDF8A You've discovered the Easter Egg \uD83C\uDF8A", + date = dateProvider.provideDate() + ) + tillFinish { + chatRepository.saveMessage(inputData.contact, message) + } + + updateState { + copy(easterEggDiscovered = easterEggDiscovered) + } + } + } + +} \ No newline at end of file diff --git a/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat_list/ChatListScreenContract.kt b/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat_list/ChatListContract.kt similarity index 64% rename from samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat_list/ChatListScreenContract.kt rename to samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat_list/ChatListContract.kt index ac5ccc1..d34e4bf 100644 --- a/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat_list/ChatListScreenContract.kt +++ b/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat_list/ChatListContract.kt @@ -1,28 +1,28 @@ package com.revolut.kompot.sample.feature.chat.ui.screens.chat_list import com.revolut.kompot.common.IOData -import com.revolut.kompot.navigable.screen.ScreenModel -import com.revolut.kompot.navigable.screen.ScreenStates +import com.revolut.kompot.navigable.vc.ui.States +import com.revolut.kompot.navigable.vc.ui.list.UIListStatesModel import com.revolut.kompot.sample.feature.chat.domain.Chat import com.revolut.kompot.sample.feature.contacts.domain.Contact import com.revolut.recyclerkit.delegates.ListItem -interface ChatListScreenContract { +interface ChatListContract { - interface ScreenModelApi : ScreenModel { + interface ModelApi : UIListStatesModel { fun onRowClicked(contact: Contact) } data class DomainState( val chats: List - ) : ScreenStates.Domain + ) : States.Domain data class UIState( override val items: List - ) : ScreenStates.UIList + ) : States.UIList data class OutputData( val selectedContact: Contact - ): IOData.Output + ) : IOData.Output } \ No newline at end of file diff --git a/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat_list/ChatListScreen.kt b/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat_list/ChatListScreen.kt deleted file mode 100644 index 43965e7..0000000 --- a/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat_list/ChatListScreen.kt +++ /dev/null @@ -1,46 +0,0 @@ -package com.revolut.kompot.sample.feature.chat.ui.screens.chat_list - -import android.view.View -import com.revolut.kompot.common.IOData -import com.revolut.kompot.navigable.screen.BaseRecyclerViewScreen -import com.revolut.kompot.sample.feature.chat.R -import com.revolut.kompot.sample.feature.chat.di.ChatsApiProvider -import com.revolut.kompot.sample.feature.chat.ui.screens.chat_list.ChatListScreenContract.OutputData -import com.revolut.kompot.sample.feature.chat.ui.screens.chat_list.ChatListScreenContract.UIState -import com.revolut.kompot.sample.feature.chat.ui.screens.chat_list.di.ChatListScreenComponent -import com.revolut.kompot.sample.feature.chat.ui.screens.chat_list.di.ChatListScreenInjector -import com.revolut.kompot.sample.feature.contacts.domain.Contact -import com.revolut.kompot.sample.ui_common.RowDelegate - -class ChatListScreen : BaseRecyclerViewScreen(IOData.EmptyInput) { - - override val layoutId: Int = R.layout.screen_chat_list - - override val fitStatusBar: Boolean = true - - private val chatRowDelegate = RowDelegate() - - override val delegates = listOf(chatRowDelegate) - - override val screenComponent: ChatListScreenComponent by lazy(LazyThreadSafetyMode.NONE) { - (ChatsApiProvider.component as ChatListScreenInjector) - .getChatListScreenComponentBuilder() - .inputData(inputData) - .screen(this) - .build() - } - - override val screenModel by lazy { - screenComponent.screenModel - } - - override fun onScreenViewAttached(view: View) { - super.onScreenViewAttached(view) - - chatRowDelegate.clicksFlow() - .collectTillDetachView { model -> - screenModel.onRowClicked(model.parcel as Contact) - } - } - -} \ No newline at end of file diff --git a/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat_list/ChatListStateMapper.kt b/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat_list/ChatListStateMapper.kt index ff871e7..a4d4027 100644 --- a/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat_list/ChatListStateMapper.kt +++ b/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat_list/ChatListStateMapper.kt @@ -1,18 +1,18 @@ package com.revolut.kompot.sample.feature.chat.ui.screens.chat_list -import com.revolut.kompot.navigable.screen.StateMapper -import com.revolut.kompot.sample.utils.date.printer.DatePrinter +import com.revolut.kompot.navigable.vc.ui.States import com.revolut.kompot.sample.feature.chat.domain.Chat +import com.revolut.kompot.sample.feature.chat.ui.screens.chat_list.ChatListContract.DomainState +import com.revolut.kompot.sample.feature.chat.ui.screens.chat_list.ChatListContract.UIState import com.revolut.kompot.sample.ui_common.RowDelegate -import com.revolut.kompot.sample.feature.chat.ui.screens.chat_list.ChatListScreenContract.DomainState -import com.revolut.kompot.sample.feature.chat.ui.screens.chat_list.ChatListScreenContract.UIState import com.revolut.kompot.sample.ui_common.TextModel +import com.revolut.kompot.sample.utils.date.printer.DatePrinter import com.revolut.recyclerkit.delegates.ListItem import javax.inject.Inject class ChatListStateMapper @Inject constructor( private val datePrinter: DatePrinter -) : StateMapper { +) : States.Mapper { override fun mapState(domainState: DomainState): UIState { return UIState(items = createChatList(domainState.chats)) @@ -20,7 +20,7 @@ class ChatListStateMapper @Inject constructor( private fun createChatList( chats: List - ) : List = chats.map { chat -> + ): List = chats.map { chat -> RowDelegate.Model( listId = chat.contact.id.toString(), image = chat.contact.avatar, diff --git a/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat_list/ChatListViewController.kt b/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat_list/ChatListViewController.kt new file mode 100644 index 0000000..9c4a074 --- /dev/null +++ b/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat_list/ChatListViewController.kt @@ -0,0 +1,44 @@ +package com.revolut.kompot.sample.feature.chat.ui.screens.chat_list + +import android.view.View +import com.revolut.kompot.navigable.vc.ViewController +import com.revolut.kompot.navigable.vc.ui.list.ModelBinding +import com.revolut.kompot.navigable.vc.ui.list.UIListStatesController +import com.revolut.kompot.sample.feature.chat.R +import com.revolut.kompot.sample.feature.chat.di.ChatsApiProvider +import com.revolut.kompot.sample.feature.chat.ui.screens.chat_list.ChatListContract.OutputData +import com.revolut.kompot.sample.feature.chat.ui.screens.chat_list.ChatListContract.UIState +import com.revolut.kompot.sample.feature.chat.ui.screens.chat_list.di.ChatListControllerInjector +import com.revolut.kompot.sample.feature.contacts.domain.Contact +import com.revolut.kompot.sample.ui_common.RowDelegate + +class ChatListViewController : ViewController(), UIListStatesController { + + override val layoutId: Int = R.layout.screen_chat_list + override val fitStatusBar: Boolean = true + private val chatRowDelegate = RowDelegate() + + override val component by lazy(LazyThreadSafetyMode.NONE) { + (ChatsApiProvider.component as ChatListControllerInjector) + .getChatListComponentBuilder() + .controller(this) + .build() + } + + override val controllerModel by lazy(LazyThreadSafetyMode.NONE) { + component.model + } + override val modelBinding by lazy(LazyThreadSafetyMode.NONE) { + ModelBinding( + model = controllerModel, + delegates = listOf(chatRowDelegate) + ) + } + + override fun onShown(view: View) { + chatRowDelegate.clicksFlow() + .collectTillDetachView { model -> + controllerModel.onRowClicked(model.parcel as Contact) + } + } +} \ No newline at end of file diff --git a/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat_list/ChatListScreenModel.kt b/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat_list/ChatListViewModel.kt similarity index 57% rename from samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat_list/ChatListScreenModel.kt rename to samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat_list/ChatListViewModel.kt index 86facb2..cbe98d0 100644 --- a/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat_list/ChatListScreenModel.kt +++ b/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat_list/ChatListViewModel.kt @@ -1,21 +1,28 @@ package com.revolut.kompot.sample.feature.chat.ui.screens.chat_list -import com.revolut.kompot.navigable.screen.BaseScreenModel -import com.revolut.kompot.navigable.screen.StateMapper +import com.revolut.kompot.navigable.vc.ViewControllerModel +import com.revolut.kompot.navigable.vc.ui.ModelState +import com.revolut.kompot.navigable.vc.ui.States import com.revolut.kompot.sample.feature.chat.data.ChatRepository -import com.revolut.kompot.sample.feature.contacts.domain.Contact import com.revolut.kompot.sample.feature.chat.navigation.ChatNavigationDestination -import com.revolut.kompot.sample.feature.chat.ui.screens.chat_list.ChatListScreenContract.* +import com.revolut.kompot.sample.feature.chat.ui.screens.chat_list.ChatListContract.DomainState +import com.revolut.kompot.sample.feature.chat.ui.screens.chat_list.ChatListContract.ModelApi +import com.revolut.kompot.sample.feature.chat.ui.screens.chat_list.ChatListContract.OutputData +import com.revolut.kompot.sample.feature.chat.ui.screens.chat_list.ChatListContract.UIState import com.revolut.kompot.sample.feature.chat.utils.message_generator.MessageGenerator +import com.revolut.kompot.sample.feature.contacts.domain.Contact import javax.inject.Inject -class ChatListScreenModel @Inject constructor( +class ChatListViewModel @Inject constructor( private val chatRepository: ChatRepository, private val messageGenerator: MessageGenerator, - stateMapper: StateMapper -) : BaseScreenModel(stateMapper), ScreenModelApi { + stateMapper: States.Mapper +) : ViewControllerModel(), ModelApi { - override val initialState = DomainState(emptyList()) + override val state = ModelState( + initialState = DomainState(emptyList()), + stateMapper = stateMapper, + ) override fun onCreated() { super.onCreated() @@ -25,7 +32,7 @@ class ChatListScreenModel @Inject constructor( chatRepository.chatListStream() .collectTillFinish( onEach = { chatList -> - updateState { + state.update { copy(chats = chatList) } } @@ -37,5 +44,4 @@ class ChatListScreenModel @Inject constructor( inputData = ChatNavigationDestination.InputData(contact) ).navigate() } - } \ No newline at end of file diff --git a/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat_list/di/ChatListControllerComponent.kt b/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat_list/di/ChatListControllerComponent.kt new file mode 100644 index 0000000..efdf42d --- /dev/null +++ b/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat_list/di/ChatListControllerComponent.kt @@ -0,0 +1,17 @@ +package com.revolut.kompot.sample.feature.chat.ui.screens.chat_list.di + +import com.revolut.kompot.navigable.vc.di.ViewControllerComponent +import com.revolut.kompot.navigable.vc.di.ViewControllerScope +import com.revolut.kompot.sample.feature.chat.ui.screens.chat_list.ChatListContract +import dagger.Subcomponent + +@ViewControllerScope +@Subcomponent( + modules = [ChatListControllerModule::class] +) +interface ChatListControllerComponent : ViewControllerComponent { + val model: ChatListContract.ModelApi + + @Subcomponent.Builder + interface Builder : ViewControllerComponent.Builder +} \ No newline at end of file diff --git a/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat_list/di/ChatListControllerInjector.kt b/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat_list/di/ChatListControllerInjector.kt new file mode 100644 index 0000000..f976c37 --- /dev/null +++ b/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat_list/di/ChatListControllerInjector.kt @@ -0,0 +1,5 @@ +package com.revolut.kompot.sample.feature.chat.ui.screens.chat_list.di + +interface ChatListControllerInjector { + fun getChatListComponentBuilder(): ChatListControllerComponent.Builder +} \ No newline at end of file diff --git a/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat_list/di/ChatListControllerModule.kt b/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat_list/di/ChatListControllerModule.kt new file mode 100644 index 0000000..ce79196 --- /dev/null +++ b/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat_list/di/ChatListControllerModule.kt @@ -0,0 +1,36 @@ +package com.revolut.kompot.sample.feature.chat.ui.screens.chat_list.di + +import com.revolut.kompot.navigable.vc.ViewController +import com.revolut.kompot.navigable.vc.di.ViewControllerModule +import com.revolut.kompot.navigable.vc.di.ViewControllerQualifier +import com.revolut.kompot.navigable.vc.di.ViewControllerScope +import com.revolut.kompot.navigable.vc.ui.States +import com.revolut.kompot.sample.feature.chat.ui.screens.chat_list.ChatListContract.DomainState +import com.revolut.kompot.sample.feature.chat.ui.screens.chat_list.ChatListContract.ModelApi +import com.revolut.kompot.sample.feature.chat.ui.screens.chat_list.ChatListContract.UIState +import com.revolut.kompot.sample.feature.chat.ui.screens.chat_list.ChatListStateMapper +import com.revolut.kompot.sample.feature.chat.ui.screens.chat_list.ChatListViewModel +import com.revolut.kompot.sample.feature.chat.utils.message_generator.MessageGenerator +import com.revolut.kompot.sample.feature.chat.utils.message_generator.MessageGeneratorImpl +import dagger.Binds +import dagger.Module +import dagger.Provides + +@Module +abstract class ChatListControllerModule : ViewControllerModule { + + @[Binds ViewControllerScope] + abstract fun bindMapper(mapper: ChatListStateMapper): States.Mapper + + @[Binds ViewControllerScope] + abstract fun bindModel(model: ChatListViewModel): ModelApi + + @Module + companion object { + @[Provides JvmStatic ViewControllerScope] + fun provideMessageGenerator(@ViewControllerQualifier controller: ViewController<*>): MessageGenerator = + MessageGeneratorImpl( + contextProvider = { controller.activity } + ) + } +} \ No newline at end of file diff --git a/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat_list/di/ChatListScreenComponent.kt b/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat_list/di/ChatListScreenComponent.kt deleted file mode 100644 index fcf1061..0000000 --- a/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat_list/di/ChatListScreenComponent.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.revolut.kompot.sample.feature.chat.ui.screens.chat_list.di - -import com.revolut.kompot.common.IOData -import com.revolut.kompot.di.scope.ScreenScope -import com.revolut.kompot.di.screen.BaseScreenComponent -import com.revolut.kompot.sample.feature.chat.ui.screens.chat_list.ChatListScreenContract -import dagger.BindsInstance -import dagger.Subcomponent - -@ScreenScope -@Subcomponent( - modules = [ChatListScreenModule::class] -) -interface ChatListScreenComponent : BaseScreenComponent { - val screenModel: ChatListScreenContract.ScreenModelApi - - @Subcomponent.Builder - interface Builder : BaseScreenComponent.Builder { - @BindsInstance - fun inputData(ioData: IOData.EmptyInput): Builder - } -} \ No newline at end of file diff --git a/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat_list/di/ChatListScreenInjector.kt b/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat_list/di/ChatListScreenInjector.kt deleted file mode 100644 index 72828c3..0000000 --- a/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat_list/di/ChatListScreenInjector.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.revolut.kompot.sample.feature.chat.ui.screens.chat_list.di - -interface ChatListScreenInjector { - fun getChatListScreenComponentBuilder(): ChatListScreenComponent.Builder -} \ No newline at end of file diff --git a/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat_list/di/ChatListScreenModule.kt b/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat_list/di/ChatListScreenModule.kt deleted file mode 100644 index 0c25a48..0000000 --- a/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat_list/di/ChatListScreenModule.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.revolut.kompot.sample.feature.chat.ui.screens.chat_list.di - -import com.revolut.kompot.di.scope.ScreenScope -import com.revolut.kompot.di.screen.BaseScreenModule -import com.revolut.kompot.navigable.screen.BaseScreen -import com.revolut.kompot.navigable.screen.StateMapper -import com.revolut.kompot.sample.feature.chat.ui.screens.chat_list.ChatListScreenContract.* -import com.revolut.kompot.sample.feature.chat.ui.screens.chat_list.ChatListScreenModel -import com.revolut.kompot.sample.feature.chat.ui.screens.chat_list.ChatListStateMapper -import com.revolut.kompot.sample.feature.chat.utils.message_generator.MessageGenerator -import com.revolut.kompot.sample.feature.chat.utils.message_generator.MessageGeneratorImpl -import dagger.Binds -import dagger.Module -import dagger.Provides - -@Module -abstract class ChatListScreenModule : BaseScreenModule { - - @Binds - @ScreenScope - abstract fun bindMapper(mapper: ChatListStateMapper): StateMapper - - @Binds - @ScreenScope - abstract fun bindScreenModel(model: ChatListScreenModel): ScreenModelApi - - @Module - companion object { - @Provides - @JvmStatic - @ScreenScope - fun provideMessageGenerator(screen: BaseScreen<*, *, *>): MessageGenerator = - MessageGeneratorImpl(screen.activity) - } - -} \ No newline at end of file diff --git a/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/utils/message_generator/MessageGeneratorImpl.kt b/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/utils/message_generator/MessageGeneratorImpl.kt index c9c8347..ccf368f 100644 --- a/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/utils/message_generator/MessageGeneratorImpl.kt +++ b/samples/messenger/feature_chat_impl/src/main/java/com/revolut/kompot/sample/feature/chat/utils/message_generator/MessageGeneratorImpl.kt @@ -4,10 +4,11 @@ import android.content.Context import com.revolut.kompot.sample.feature.chat.data.messenger.MessengerService class MessageGeneratorImpl( - private val context: Context + private val contextProvider: () -> Context ) : MessageGenerator { override fun generateMessage(senderId: Long?) { + val context = contextProvider() context.startService(MessengerService.getGenerateMessagesIntent(context, senderId)) } diff --git a/samples/messenger/feature_chat_impl/src/test/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/ChatSaveStateDelegateTest.kt b/samples/messenger/feature_chat_impl/src/test/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/ChatSaveStateDelegateTest.kt index 8da0f8e..4341467 100644 --- a/samples/messenger/feature_chat_impl/src/test/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/ChatSaveStateDelegateTest.kt +++ b/samples/messenger/feature_chat_impl/src/test/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/ChatSaveStateDelegateTest.kt @@ -11,26 +11,26 @@ class ChatSaveStateDelegateTest { @Test fun `get retained state from domain state`() { - val domainState = ChatScreenContract.DomainState( + val domainState = ChatContract.DomainState( contact = createSampleContact(), messages = listOf(createSampleMessage()), messageInputText = "message text" ) - val expected = ChatScreenContract.RetainedState("message text") + val expected = ChatContract.RetainedState("message text") assertEquals(expected, savedStateDelegate.getRetainedState(domainState)) } @Test fun `get domain state from initial state and retained state`() { - val initialState = ChatScreenContract.DomainState( + val initialState = ChatContract.DomainState( contact = createSampleContact(), messages = listOf(createSampleMessage()), messageInputText = "" ) - val retainedState = ChatScreenContract.RetainedState("message text") + val retainedState = ChatContract.RetainedState("message text") val expected = initialState.copy( messageInputText = "message text" diff --git a/samples/messenger/feature_chat_impl/src/test/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/ChatStateMapperTest.kt b/samples/messenger/feature_chat_impl/src/test/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/ChatStateMapperTest.kt index b352daf..a556019 100644 --- a/samples/messenger/feature_chat_impl/src/test/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/ChatStateMapperTest.kt +++ b/samples/messenger/feature_chat_impl/src/test/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/ChatStateMapperTest.kt @@ -8,8 +8,8 @@ import com.revolut.kompot.sample.feature.chat.data.USER_ID import com.revolut.kompot.sample.feature.contacts.domain.Contact import com.revolut.kompot.sample.feature.chat.domain.Message import com.revolut.kompot.sample.feature.chat.ui.delegates.MessageRowDelegate -import com.revolut.kompot.sample.feature.chat.ui.screens.chat.ChatScreenContract.DomainState -import com.revolut.kompot.sample.feature.chat.ui.screens.chat.ChatScreenContract.UIState +import com.revolut.kompot.sample.feature.chat.ui.screens.chat.ChatContract.DomainState +import com.revolut.kompot.sample.feature.chat.ui.screens.chat.ChatContract.UIState import com.revolut.kompot.sample.utils.date.printer.DatePrinter import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse diff --git a/samples/messenger/feature_chat_impl/src/test/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/ChatScreenModelTest.kt b/samples/messenger/feature_chat_impl/src/test/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/ChatViewModelTest.kt similarity index 82% rename from samples/messenger/feature_chat_impl/src/test/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/ChatScreenModelTest.kt rename to samples/messenger/feature_chat_impl/src/test/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/ChatViewModelTest.kt index 9d08de5..b6b32bc 100644 --- a/samples/messenger/feature_chat_impl/src/test/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/ChatScreenModelTest.kt +++ b/samples/messenger/feature_chat_impl/src/test/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat/ChatViewModelTest.kt @@ -6,8 +6,10 @@ import com.nhaarman.mockitokotlin2.doReturn import com.nhaarman.mockitokotlin2.mock import com.nhaarman.mockitokotlin2.verify import com.nhaarman.mockitokotlin2.whenever +import com.revolut.kompot.core.test.assertion.applyTestDependencies import com.revolut.kompot.coroutines.test.dispatchBlockingTest import com.revolut.kompot.coroutines.test.flow.testIn +import com.revolut.kompot.navigable.vc.test.testDomainStateStream import com.revolut.kompot.sample.feature.chat.R import com.revolut.kompot.sample.feature.chat.createSampleMessage import com.revolut.kompot.sample.feature.chat.data.ChatRepository @@ -19,7 +21,7 @@ import com.revolut.kompot.sample.utils.date.provider.MockDateProvider import kotlinx.coroutines.flow.flowOf import org.junit.jupiter.api.Test -class ChatScreenModelTest { +class ChatViewModelTest { private val contact = Contact( id = 1, @@ -28,7 +30,7 @@ class ChatScreenModelTest { avatar = R.drawable.avatar_mc_fly ) - private val initialDomainState = ChatScreenContract.DomainState( + private val initialDomainState = ChatContract.DomainState( contact = contact, messageInputText = "", messages = listOf() @@ -44,7 +46,7 @@ class ChatScreenModelTest { onBlocking { markMessagesAsRead(contact.id) } doReturn Unit } - private var screenModel = createScreenModel() + private var viewModel = createModel() @Test fun `should observe chat messages after created`() = dispatchBlockingTest { @@ -53,9 +55,9 @@ class ChatScreenModelTest { whenever(chatRepository.messagesStream(contact.id)) .thenReturn(flowOf(testMessages)) - val streamTest = screenModel.domainStateStream().testIn(this) + val streamTest = viewModel.testDomainStateStream().testIn(this) - screenModel.onCreated() + viewModel.onCreated() val expectedStates = listOf( initialDomainState, @@ -80,7 +82,7 @@ class ChatScreenModelTest { ) ) - screenModel.onCreated() + viewModel.onCreated() verify(chatRepository, atLeast(2)).markMessagesAsRead(contact.id) @@ -91,7 +93,7 @@ class ChatScreenModelTest { fun `should update state with new input`() = dispatchBlockingTest { val input = "hello" - val streamTest = screenModel.domainStateStream().testIn(this) + val streamTest = viewModel.testDomainStateStream().testIn(this) val expectedStates = listOf( initialDomainState, @@ -101,7 +103,7 @@ class ChatScreenModelTest { ) - screenModel.onInputChanged(input) + viewModel.onInputChanged(input) streamTest.assertValues(expectedStates) } @@ -110,11 +112,11 @@ class ChatScreenModelTest { fun `should clear input when action button clicked`() = dispatchBlockingTest { val initialInput = "hello" - val streamTest = screenModel.domainStateStream().testIn(this) + val streamTest = viewModel.testDomainStateStream().testIn(this) - screenModel.onCreated() - screenModel.onInputChanged(initialInput) - screenModel.onActionButtonClick() + viewModel.onCreated() + viewModel.onInputChanged(initialInput) + viewModel.onActionButtonClick() val expectedStates = listOf( initialDomainState, @@ -139,10 +141,10 @@ class ChatScreenModelTest { fun `should send message after button clicked`() = dispatchBlockingTest { val messageText = "hello" - screenModel.onCreated() + viewModel.onCreated() - screenModel.onInputChanged(messageText) - screenModel.onActionButtonClick() + viewModel.onInputChanged(messageText) + viewModel.onActionButtonClick() val expectedMessage = Message.createFromUser( contactId = contact.id, @@ -155,21 +157,22 @@ class ChatScreenModelTest { @Test fun `should launch response generator after button clicked`() { - screenModel.onCreated() - screenModel.onActionButtonClick() + viewModel.onCreated() + viewModel.onActionButtonClick() verify(messageGenerator).generateMessage(contact.id) } - private fun createScreenModel( + private fun createModel( contact: Contact = this.contact - ) = ChatScreenModel( + ) = ChatViewModel( inputData = ChatNavigationDestination.InputData( contact = contact ), chatRepository = chatRepository, dateProvider = mockDateProvider, messageGenerator = messageGenerator, + chatMessageActionExtension = mock(), stateMapper = mock() - ) + ).applyTestDependencies() } \ No newline at end of file diff --git a/samples/messenger/feature_chat_impl/src/test/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat_list/ChatListStateMapperTest.kt b/samples/messenger/feature_chat_impl/src/test/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat_list/ChatListStateMapperTest.kt index 8ce83a0..d09c5fd 100644 --- a/samples/messenger/feature_chat_impl/src/test/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat_list/ChatListStateMapperTest.kt +++ b/samples/messenger/feature_chat_impl/src/test/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat_list/ChatListStateMapperTest.kt @@ -8,8 +8,8 @@ import com.revolut.kompot.sample.feature.chat.domain.Chat import com.revolut.kompot.sample.feature.contacts.domain.Contact import com.revolut.kompot.sample.feature.chat.domain.MessagePreview import com.revolut.kompot.sample.ui_common.RowDelegate -import com.revolut.kompot.sample.feature.chat.ui.screens.chat_list.ChatListScreenContract.DomainState -import com.revolut.kompot.sample.feature.chat.ui.screens.chat_list.ChatListScreenContract.UIState +import com.revolut.kompot.sample.feature.chat.ui.screens.chat_list.ChatListContract.DomainState +import com.revolut.kompot.sample.feature.chat.ui.screens.chat_list.ChatListContract.UIState import com.revolut.kompot.sample.ui_common.TextModel import com.revolut.kompot.sample.utils.date.printer.DatePrinter import org.junit.jupiter.api.Assertions.assertEquals diff --git a/samples/messenger/feature_chat_impl/src/test/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat_list/ChatListScreenModelTest.kt b/samples/messenger/feature_chat_impl/src/test/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat_list/ChatListViewModelTest.kt similarity index 82% rename from samples/messenger/feature_chat_impl/src/test/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat_list/ChatListScreenModelTest.kt rename to samples/messenger/feature_chat_impl/src/test/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat_list/ChatListViewModelTest.kt index c806f66..42bf060 100644 --- a/samples/messenger/feature_chat_impl/src/test/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat_list/ChatListScreenModelTest.kt +++ b/samples/messenger/feature_chat_impl/src/test/java/com/revolut/kompot/sample/feature/chat/ui/screens/chat_list/ChatListViewModelTest.kt @@ -7,16 +7,17 @@ import com.nhaarman.mockitokotlin2.verify import com.revolut.kompot.common.NavigationEvent import com.revolut.kompot.coroutines.test.dispatchBlockingTest import com.revolut.kompot.coroutines.test.flow.testIn +import com.revolut.kompot.navigable.vc.test.testDomainStateStream import com.revolut.kompot.sample.feature.chat.createSampleChat import com.revolut.kompot.sample.feature.chat.data.ChatRepository import com.revolut.kompot.sample.feature.chat.navigation.ChatNavigationDestination -import com.revolut.kompot.sample.feature.chat.ui.screens.chat_list.ChatListScreenContract.DomainState +import com.revolut.kompot.sample.feature.chat.ui.screens.chat_list.ChatListContract.DomainState import com.revolut.kompot.sample.feature.chat.utils.message_generator.MessageGenerator import kotlinx.coroutines.flow.flowOf import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test -class ChatListScreenModelTest { +class ChatListViewModelTest { private val chatList = listOf(createSampleChat()) @@ -25,14 +26,14 @@ class ChatListScreenModelTest { } private val messageGenerator: MessageGenerator = mock() - private val screenModel = createScreenModel() + private val viewModel = createModel() @Test fun `should load chat list after created`() = dispatchBlockingTest { - val streamTest = screenModel.domainStateStream().testIn(this) + val streamTest = viewModel.testDomainStateStream().testIn(this) - screenModel.onCreated() + viewModel.onCreated() val expectedStates = listOf( DomainState( @@ -50,7 +51,7 @@ class ChatListScreenModelTest { @Test fun `should generate message after created`() { - screenModel.onCreated() + viewModel.onCreated() verify(messageGenerator).generateMessage() } @@ -59,10 +60,10 @@ class ChatListScreenModelTest { fun `return result with selected contact after contact selected`() { val contact = createSampleChat().contact - screenModel.onRowClicked(contact) + viewModel.onRowClicked(contact) argumentCaptor { - verify(screenModel.eventsDispatcher).handleEvent(capture()) + verify(viewModel.eventsDispatcher).handleEvent(capture()) val expected = ChatNavigationDestination( inputData = ChatNavigationDestination.InputData(contact) @@ -72,7 +73,7 @@ class ChatListScreenModelTest { } } - private fun createScreenModel() = ChatListScreenModel( + private fun createModel() = ChatListViewModel( chatRepository = chatRepository, messageGenerator = messageGenerator, stateMapper = mock() diff --git a/samples/messenger/feature_contacts_api/build.gradle b/samples/messenger/feature_contacts_api/build.gradle index 1d37cad..cd63cb3 100644 --- a/samples/messenger/feature_contacts_api/build.gradle +++ b/samples/messenger/feature_contacts_api/build.gradle @@ -6,4 +6,8 @@ apply from: '../kompot_sample_dependencies.gradle' addDependencies( as_general_dependencies_api -) \ No newline at end of file +) + +android { + namespace "com.revolut.kompot.sample.feature.api.contacts" +} \ No newline at end of file diff --git a/samples/messenger/feature_contacts_impl/build.gradle b/samples/messenger/feature_contacts_impl/build.gradle index 87fbc4e..6fb8d0b 100644 --- a/samples/messenger/feature_contacts_impl/build.gradle +++ b/samples/messenger/feature_contacts_impl/build.gradle @@ -6,6 +6,7 @@ apply from: '../default_impl_lib_config.gradle' apply from: '../kompot_sample_dependencies.gradle' android { + namespace "com.revolut.kompot.sample.feature.contacts" testOptions { unitTests.returnDefaultValues = true diff --git a/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ContactsFeatureGateway.kt b/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ContactsFeatureGateway.kt index 11b6754..4312647 100644 --- a/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ContactsFeatureGateway.kt +++ b/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ContactsFeatureGateway.kt @@ -7,7 +7,7 @@ import com.revolut.kompot.navigable.flow.BaseFlowModel import com.revolut.kompot.sample.feature.contacts.di.ContactsApiProvider import com.revolut.kompot.sample.feature.contacts.di.ContactsArguments import com.revolut.kompot.sample.feature.contacts.navigation.ContactListNavigationDestination -import com.revolut.kompot.sample.feature.contacts.ui.screens.contacts.ContactListScreen +import com.revolut.kompot.sample.feature.contacts.ui.screens.contacts.ContactListViewController class ContactsFeatureGateway(argsProvider: () -> ContactsArguments) : FeatureGateway { @@ -19,12 +19,11 @@ class ContactsFeatureGateway(argsProvider: () -> ContactsArguments) : FeatureGat destination: NavigationDestination, flowModel: BaseFlowModel<*, *, *> ): Controller? = when (destination) { - ContactListNavigationDestination -> ContactListScreen() + ContactListNavigationDestination -> ContactListViewController() else -> null } override fun clearReference() { ContactsApiProvider.clear() } - } \ No newline at end of file diff --git a/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/di/ContactsFeatureComponent.kt b/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/di/ContactsFeatureComponent.kt index 6b2ac54..de06345 100644 --- a/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/di/ContactsFeatureComponent.kt +++ b/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/di/ContactsFeatureComponent.kt @@ -3,7 +3,7 @@ package com.revolut.kompot.sample.feature.contacts.di import com.revolut.kompot.sample.data.api.DataApi import com.revolut.kompot.sample.feature.contacts.api.ContactsApi import com.revolut.kompot.sample.feature.contacts.ui.flows.add_contact.di.AddContactFlowInjector -import com.revolut.kompot.sample.feature.contacts.ui.screens.contacts.di.ContactListScreenInjector +import com.revolut.kompot.sample.feature.contacts.ui.screens.contacts.di.ContactListControllerInjector import com.revolut.kompot.sample.utils.LazySingletonHolder import com.revolut.kompot.sample.utils.di.FeatureScope import dagger.Component @@ -13,7 +13,7 @@ import dagger.Component dependencies = [DataApi::class], modules = [ContactsFeatureModule::class] ) -interface ContactsFeatureComponent : ContactsApi, ContactListScreenInjector, +interface ContactsFeatureComponent : ContactsApi, ContactListControllerInjector, AddContactFlowInjector { @Component.Factory interface Factory { diff --git a/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/flows/add_contact/AddContactFlow.kt b/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/flows/add_contact/AddContactFlow.kt index 7d65d7a..dbacde1 100644 --- a/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/flows/add_contact/AddContactFlow.kt +++ b/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/flows/add_contact/AddContactFlow.kt @@ -1,21 +1,23 @@ package com.revolut.kompot.sample.feature.contacts.ui.flows.add_contact import com.revolut.kompot.common.IOData -import com.revolut.kompot.navigable.flow.BaseFlow +import com.revolut.kompot.navigable.vc.ViewController +import com.revolut.kompot.navigable.vc.composite.stateful_flow.ModelBinding +import com.revolut.kompot.navigable.vc.composite.stateful_flow.StatefulFlowViewController import com.revolut.kompot.sample.feature.contacts.di.ContactsApiProvider -import com.revolut.kompot.sample.feature.contacts.ui.flows.add_contact.AddContactFlowContract.Step -import com.revolut.kompot.sample.feature.contacts.ui.flows.add_contact.di.AddContactFlowComponent -class AddContactFlow : BaseFlow(IOData.EmptyInput) { +class AddContactFlow : ViewController(), StatefulFlowViewController { - override val component: AddContactFlowComponent by lazy(LazyThreadSafetyMode.NONE) { + override val component by lazy(LazyThreadSafetyMode.NONE) { ContactsApiProvider.component .getAddContactFlowComponentBuilder() - .flow(this) + .controller(this) .build() } - - override val flowModel by lazy(LazyThreadSafetyMode.NONE) { component.flowModel } - - override fun updateUi(step: Step) = Unit + override val controllerModel by lazy(LazyThreadSafetyMode.NONE) { + component.model + } + override val modelBinding by lazy(LazyThreadSafetyMode.NONE) { + ModelBinding(controllerModel) + } } \ No newline at end of file diff --git a/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/flows/add_contact/AddContactFlowContract.kt b/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/flows/add_contact/AddContactFlowContract.kt index 9cb63fc..eb57f19 100644 --- a/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/flows/add_contact/AddContactFlowContract.kt +++ b/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/flows/add_contact/AddContactFlowContract.kt @@ -1,14 +1,15 @@ package com.revolut.kompot.sample.feature.contacts.ui.flows.add_contact import com.revolut.kompot.common.IOData -import com.revolut.kompot.navigable.flow.FlowModel + import com.revolut.kompot.navigable.flow.FlowState import com.revolut.kompot.navigable.flow.FlowStep +import com.revolut.kompot.navigable.vc.composite.stateful_flow.StatefulFlowModel import kotlinx.parcelize.Parcelize interface AddContactFlowContract { - interface FlowModelApi : FlowModel + interface FlowModelApi : StatefulFlowModel @Parcelize data class State( diff --git a/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/flows/add_contact/AddContactFlowModel.kt b/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/flows/add_contact/AddContactFlowModel.kt index c2c8c2c..6ee9bb0 100644 --- a/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/flows/add_contact/AddContactFlowModel.kt +++ b/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/flows/add_contact/AddContactFlowModel.kt @@ -1,33 +1,34 @@ package com.revolut.kompot.sample.feature.contacts.ui.flows.add_contact import com.revolut.kompot.common.IOData -import com.revolut.kompot.navigable.Controller -import com.revolut.kompot.navigable.flow.BaseFlowModel -import com.revolut.kompot.navigable.screen.BaseScreen +import com.revolut.kompot.navigable.vc.ViewControllerModel +import com.revolut.kompot.navigable.vc.common.ModelState +import com.revolut.kompot.navigable.vc.flow.FlowCoordinator import com.revolut.kompot.sample.feature.contacts.data.ContactsRepository import com.revolut.kompot.sample.feature.contacts.domain.Contact -import com.revolut.kompot.sample.feature.contacts.ui.flows.add_contact.AddContactFlowContract.* -import com.revolut.kompot.sample.feature.contacts.ui.screens.input.InputScreen -import com.revolut.kompot.sample.feature.contacts.ui.screens.input.InputScreenContract.InputData -import com.revolut.kompot.sample.feature.contacts.ui.screens.input.InputScreenContract.InputType +import com.revolut.kompot.sample.feature.contacts.ui.flows.add_contact.AddContactFlowContract.FlowModelApi +import com.revolut.kompot.sample.feature.contacts.ui.flows.add_contact.AddContactFlowContract.State +import com.revolut.kompot.sample.feature.contacts.ui.flows.add_contact.AddContactFlowContract.Step +import com.revolut.kompot.sample.feature.contacts.ui.screens.input.InputContract.InputType +import com.revolut.kompot.sample.feature.contacts.ui.screens.input.InputViewController import javax.inject.Inject internal class AddContactFlowModel @Inject constructor( private val contactsRepository: ContactsRepository -) : BaseFlowModel(), FlowModelApi { - - override val initialStep = Step.InputFirstName - override val initialState = State() - - override fun getController(step: Step): Controller = when (step) { - is Step.InputFirstName -> InputScreen(InputData(InputType.FIRST_NAME)).onResult { output -> - currentState = currentState.copy(firstName = output.text) - next(Step.InputLastName, addCurrentStepToBackStack = true) - } - is Step.InputLastName -> InputScreen(InputData(InputType.LAST_NAME)).onResult { output -> - val firstName = currentState.firstName.orEmpty() - val lastName = output.text - saveContact(firstName, lastName) +) : ViewControllerModel(), FlowModelApi { + + override val state = ModelState(State()) + override val flowCoordinator = FlowCoordinator(Step.InputFirstName) { step -> + when (step) { + is Step.InputFirstName -> InputViewController(InputType.FIRST_NAME).withResult { output -> + state.update { copy(firstName = output.text) } + next(Step.InputLastName, addCurrentStepToBackStack = true) + } + is Step.InputLastName -> InputViewController(InputType.LAST_NAME).withResult { output -> + val firstName = state.current.firstName.orEmpty() + val lastName = output.text + saveContact(firstName, lastName) + } } } @@ -40,7 +41,7 @@ internal class AddContactFlowModel @Inject constructor( withLoading { contactsRepository.saveContact(contact) } - quitFlow() + flowCoordinator.quit() } } @@ -51,8 +52,4 @@ internal class AddContactFlowModel @Inject constructor( firstName = firstName, lastName = lastName ) - - //todo: probably add to kompot - private fun BaseScreen<*, *, O>.onResult(block: (O) -> Unit) = apply { onScreenResult = block } - } \ No newline at end of file diff --git a/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/flows/add_contact/di/AddContactFlowComponent.kt b/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/flows/add_contact/di/AddContactFlowComponent.kt index 78e8bd0..3684291 100644 --- a/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/flows/add_contact/di/AddContactFlowComponent.kt +++ b/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/flows/add_contact/di/AddContactFlowComponent.kt @@ -1,18 +1,18 @@ package com.revolut.kompot.sample.feature.contacts.ui.flows.add_contact.di -import com.revolut.kompot.di.flow.BaseFlowComponent import com.revolut.kompot.di.scope.FlowScope +import com.revolut.kompot.navigable.vc.di.FlowViewControllerComponent import com.revolut.kompot.sample.feature.contacts.ui.flows.add_contact.AddContactFlowContract -import com.revolut.kompot.sample.feature.contacts.ui.screens.input.di.InputScreenInjector +import com.revolut.kompot.sample.feature.contacts.ui.screens.input.di.InputControllerInjector import dagger.Subcomponent @FlowScope @Subcomponent( modules = [AddContactFlowModule::class] ) -interface AddContactFlowComponent : BaseFlowComponent, InputScreenInjector { - val flowModel: AddContactFlowContract.FlowModelApi +interface AddContactFlowComponent : FlowViewControllerComponent, InputControllerInjector { + val model: AddContactFlowContract.FlowModelApi @Subcomponent.Builder - interface Builder : BaseFlowComponent.Builder + interface Builder : FlowViewControllerComponent.Builder } \ No newline at end of file diff --git a/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/flows/add_contact/di/AddContactFlowModule.kt b/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/flows/add_contact/di/AddContactFlowModule.kt index c35b821..36f7634 100644 --- a/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/flows/add_contact/di/AddContactFlowModule.kt +++ b/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/flows/add_contact/di/AddContactFlowModule.kt @@ -1,15 +1,15 @@ package com.revolut.kompot.sample.feature.contacts.ui.flows.add_contact.di -import com.revolut.kompot.di.flow.BaseFlowModule import com.revolut.kompot.di.scope.FlowScope +import com.revolut.kompot.navigable.vc.di.FlowViewControllerModule import com.revolut.kompot.sample.feature.contacts.ui.flows.add_contact.AddContactFlowContract import com.revolut.kompot.sample.feature.contacts.ui.flows.add_contact.AddContactFlowModel import dagger.Binds import dagger.Module @Module -internal abstract class AddContactFlowModule : BaseFlowModule { - @Binds - @FlowScope +internal abstract class AddContactFlowModule : FlowViewControllerModule { + + @[Binds FlowScope] abstract fun bindFlowModel(flowModel: AddContactFlowModel): AddContactFlowContract.FlowModelApi } \ No newline at end of file diff --git a/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/contacts/ContactListContract.kt b/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/contacts/ContactListContract.kt new file mode 100644 index 0000000..2a8cf14 --- /dev/null +++ b/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/contacts/ContactListContract.kt @@ -0,0 +1,29 @@ +package com.revolut.kompot.sample.feature.contacts.ui.screens.contacts + +import com.revolut.kompot.common.IOData +import com.revolut.kompot.navigable.flow.FlowStep +import com.revolut.kompot.navigable.vc.composite.modal_ui_states.ModalHostUIListStatesModel +import com.revolut.kompot.navigable.vc.ui.States +import com.revolut.kompot.sample.feature.contacts.domain.Contact +import com.revolut.recyclerkit.delegates.ListItem +import kotlinx.parcelize.Parcelize + +interface ContactListContract { + + interface ModelApi : ModalHostUIListStatesModel { + fun onActionClick() + } + + data class DomainState( + val contacts: List + ) : States.Domain + + data class UIState( + override val items: List + ) : States.UIList + + sealed interface Step : FlowStep { + @Parcelize + object AddContact : Step + } +} \ No newline at end of file diff --git a/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/contacts/ContactListScreenContract.kt b/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/contacts/ContactListScreenContract.kt deleted file mode 100644 index 80146ce..0000000 --- a/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/contacts/ContactListScreenContract.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.revolut.kompot.sample.feature.contacts.ui.screens.contacts - -import com.revolut.kompot.common.IOData -import com.revolut.kompot.navigable.screen.ScreenModel -import com.revolut.kompot.navigable.screen.ScreenStates -import com.revolut.kompot.sample.feature.contacts.domain.Contact -import com.revolut.recyclerkit.delegates.ListItem - -interface ContactListScreenContract { - - interface ScreenModelApi : ScreenModel { - fun onActionClick() - } - - data class DomainState( - val contacts: List - ) : ScreenStates.Domain - - data class UIState( - override val items: List - ) : ScreenStates.UIList - -} \ No newline at end of file diff --git a/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/contacts/ContactListScreenModel.kt b/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/contacts/ContactListScreenModel.kt deleted file mode 100644 index ddac280..0000000 --- a/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/contacts/ContactListScreenModel.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.revolut.kompot.sample.feature.contacts.ui.screens.contacts - -import com.revolut.kompot.common.IOData -import com.revolut.kompot.navigable.screen.BaseScreenModel -import com.revolut.kompot.navigable.screen.StateMapper -import com.revolut.kompot.sample.feature.contacts.data.ContactsRepository -import com.revolut.kompot.sample.feature.contacts.ui.flows.add_contact.AddContactFlow -import com.revolut.kompot.sample.feature.contacts.ui.screens.contacts.ContactListScreenContract.* -import javax.inject.Inject - -class ContactListScreenModel @Inject constructor( - stateMapper: StateMapper, - private val repository: ContactsRepository -) : BaseScreenModel(stateMapper), ScreenModelApi { - - override val initialState = DomainState(contacts = emptyList()) - - override fun onCreated() { - super.onCreated() - - repository.contactsStream() - .collectTillFinish { contacts -> - updateState { - copy(contacts = contacts) - } - } - } - - override fun onActionClick() { - AddContactFlow().showModal() - } - -} \ No newline at end of file diff --git a/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/contacts/ContactListStateMapper.kt b/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/contacts/ContactListStateMapper.kt index 96344f4..5645f45 100644 --- a/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/contacts/ContactListStateMapper.kt +++ b/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/contacts/ContactListStateMapper.kt @@ -1,15 +1,15 @@ package com.revolut.kompot.sample.feature.contacts.ui.screens.contacts -import com.revolut.kompot.navigable.screen.StateMapper +import com.revolut.kompot.navigable.vc.ui.States import com.revolut.kompot.sample.feature.contacts.R import com.revolut.kompot.sample.feature.contacts.domain.Contact -import com.revolut.kompot.sample.feature.contacts.ui.screens.contacts.ContactListScreenContract.DomainState -import com.revolut.kompot.sample.feature.contacts.ui.screens.contacts.ContactListScreenContract.UIState +import com.revolut.kompot.sample.feature.contacts.ui.screens.contacts.ContactListContract.DomainState +import com.revolut.kompot.sample.feature.contacts.ui.screens.contacts.ContactListContract.UIState import com.revolut.kompot.sample.ui_common.RowDelegate import com.revolut.kompot.sample.ui_common.TextModel import javax.inject.Inject -class ContactListStateMapper @Inject constructor() : StateMapper { +class ContactListStateMapper @Inject constructor() : States.Mapper { override fun mapState(domainState: DomainState): UIState = UIState(createContactList(domainState.contacts)) diff --git a/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/contacts/ContactListScreen.kt b/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/contacts/ContactListViewController.kt similarity index 50% rename from samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/contacts/ContactListScreen.kt rename to samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/contacts/ContactListViewController.kt index f38a1f1..8524011 100644 --- a/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/contacts/ContactListScreen.kt +++ b/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/contacts/ContactListViewController.kt @@ -2,43 +2,44 @@ package com.revolut.kompot.sample.feature.contacts.ui.screens.contacts import android.view.View import com.revolut.kompot.common.IOData -import com.revolut.kompot.navigable.screen.BaseRecyclerViewScreen +import com.revolut.kompot.navigable.utils.viewBinding +import com.revolut.kompot.navigable.vc.ViewController +import com.revolut.kompot.navigable.vc.composite.modal_ui_states.ModalHostUIListStatesController +import com.revolut.kompot.navigable.vc.composite.modal_ui_states.ModelBinding import com.revolut.kompot.sample.feature.contacts.R import com.revolut.kompot.sample.feature.contacts.databinding.ScreenContactListBinding import com.revolut.kompot.sample.feature.contacts.di.ContactsApiProvider -import com.revolut.kompot.sample.feature.contacts.ui.screens.contacts.ContactListScreenContract.UIState -import com.revolut.kompot.sample.feature.contacts.ui.screens.contacts.di.ContactListScreenComponent +import com.revolut.kompot.sample.feature.contacts.ui.screens.contacts.ContactListContract.UIState import com.revolut.kompot.sample.ui_common.RowDelegate -import com.revolut.kompot.navigable.utils.viewBinding -class ContactListScreen : BaseRecyclerViewScreen(IOData.EmptyInput) { +class ContactListViewController : ViewController(), + ModalHostUIListStatesController { override val layoutId: Int = R.layout.screen_contact_list private val binding by viewBinding(ScreenContactListBinding::bind) override val fitStatusBar: Boolean = true - private val rowDelegate = RowDelegate() - override val delegates = listOf(rowDelegate) - - override val screenComponent: ContactListScreenComponent by lazy(LazyThreadSafetyMode.NONE) { + override val component by lazy(LazyThreadSafetyMode.NONE) { ContactsApiProvider.component - .getContactListScreenComponentBuilder() - .screen(this) - .inputData(inputData) + .getContactListComponentBuilder() + .controller(this) .build() } - - override val screenModel by lazy { - screenComponent.screenModel + override val controllerModel by lazy(LazyThreadSafetyMode.NONE) { + component.model + } + override val modelBinding by lazy(LazyThreadSafetyMode.NONE) { + ModelBinding( + model = controllerModel, + delegates = listOf(rowDelegate) + ) } - override fun onScreenViewAttached(view: View) { - super.onScreenViewAttached(view) - + override fun onShown(view: View) { binding.btnAction.setOnClickListener { - screenModel.onActionClick() + controllerModel.onActionClick() } } diff --git a/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/contacts/ContactListViewModel.kt b/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/contacts/ContactListViewModel.kt new file mode 100644 index 0000000..3232ac7 --- /dev/null +++ b/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/contacts/ContactListViewModel.kt @@ -0,0 +1,44 @@ +package com.revolut.kompot.sample.feature.contacts.ui.screens.contacts + +import com.revolut.kompot.common.IOData +import com.revolut.kompot.navigable.vc.ViewControllerModel +import com.revolut.kompot.navigable.vc.modal.ModalCoordinator +import com.revolut.kompot.navigable.vc.ui.ModelState +import com.revolut.kompot.navigable.vc.ui.States +import com.revolut.kompot.sample.feature.contacts.data.ContactsRepository +import com.revolut.kompot.sample.feature.contacts.ui.flows.add_contact.AddContactFlow +import com.revolut.kompot.sample.feature.contacts.ui.screens.contacts.ContactListContract.DomainState +import com.revolut.kompot.sample.feature.contacts.ui.screens.contacts.ContactListContract.ModelApi +import com.revolut.kompot.sample.feature.contacts.ui.screens.contacts.ContactListContract.Step +import com.revolut.kompot.sample.feature.contacts.ui.screens.contacts.ContactListContract.UIState +import javax.inject.Inject + +class ContactListViewModel @Inject constructor( + stateMapper: States.Mapper, + private val repository: ContactsRepository +) : ViewControllerModel(), ModelApi { + + override val state = ModelState( + initialState = DomainState(emptyList()), + stateMapper = stateMapper, + ) + override val modalCoordinator = ModalCoordinator { step -> + when (step) { + is Step.AddContact -> AddContactFlow() + } + } + + override fun onCreated() { + repository.contactsStream() + .collectTillFinish { contacts -> + state.update { + copy(contacts = contacts) + } + } + } + + override fun onActionClick() { + modalCoordinator.openModal(Step.AddContact) + } + +} \ No newline at end of file diff --git a/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/contacts/di/ContactListControllerComponent.kt b/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/contacts/di/ContactListControllerComponent.kt new file mode 100644 index 0000000..91e297d --- /dev/null +++ b/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/contacts/di/ContactListControllerComponent.kt @@ -0,0 +1,17 @@ +package com.revolut.kompot.sample.feature.contacts.ui.screens.contacts.di + +import com.revolut.kompot.navigable.vc.di.ViewControllerComponent +import com.revolut.kompot.navigable.vc.di.ViewControllerScope +import com.revolut.kompot.sample.feature.contacts.ui.screens.contacts.ContactListContract +import dagger.Subcomponent + +@ViewControllerScope +@Subcomponent( + modules = [ContactListControllerModule::class] +) +interface ContactListControllerComponent : ViewControllerComponent { + val model: ContactListContract.ModelApi + + @Subcomponent.Builder + interface Builder : ViewControllerComponent.Builder +} \ No newline at end of file diff --git a/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/contacts/di/ContactListControllerInjector.kt b/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/contacts/di/ContactListControllerInjector.kt new file mode 100644 index 0000000..dc4c34a --- /dev/null +++ b/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/contacts/di/ContactListControllerInjector.kt @@ -0,0 +1,5 @@ +package com.revolut.kompot.sample.feature.contacts.ui.screens.contacts.di + +interface ContactListControllerInjector { + fun getContactListComponentBuilder(): ContactListControllerComponent.Builder +} \ No newline at end of file diff --git a/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/contacts/di/ContactListControllerModule.kt b/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/contacts/di/ContactListControllerModule.kt new file mode 100644 index 0000000..2ad2f4d --- /dev/null +++ b/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/contacts/di/ContactListControllerModule.kt @@ -0,0 +1,22 @@ +package com.revolut.kompot.sample.feature.contacts.ui.screens.contacts.di + +import com.revolut.kompot.navigable.vc.di.ViewControllerModule +import com.revolut.kompot.navigable.vc.di.ViewControllerScope +import com.revolut.kompot.navigable.vc.ui.States +import com.revolut.kompot.sample.feature.contacts.ui.screens.contacts.ContactListContract.DomainState +import com.revolut.kompot.sample.feature.contacts.ui.screens.contacts.ContactListContract.ModelApi +import com.revolut.kompot.sample.feature.contacts.ui.screens.contacts.ContactListContract.UIState +import com.revolut.kompot.sample.feature.contacts.ui.screens.contacts.ContactListStateMapper +import com.revolut.kompot.sample.feature.contacts.ui.screens.contacts.ContactListViewModel +import dagger.Binds +import dagger.Module + +@Module +abstract class ContactListControllerModule : ViewControllerModule { + + @[Binds ViewControllerScope] + abstract fun bindsStateMapper(contactListStateMapper: ContactListStateMapper): States.Mapper + + @[Binds ViewControllerScope] + abstract fun bindsScreenModel(contactListScreenModel: ContactListViewModel): ModelApi +} \ No newline at end of file diff --git a/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/contacts/di/ContactListScreenComponent.kt b/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/contacts/di/ContactListScreenComponent.kt deleted file mode 100644 index 7f1e811..0000000 --- a/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/contacts/di/ContactListScreenComponent.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.revolut.kompot.sample.feature.contacts.ui.screens.contacts.di - -import com.revolut.kompot.common.IOData -import com.revolut.kompot.di.scope.ScreenScope -import com.revolut.kompot.di.screen.BaseScreenComponent -import com.revolut.kompot.sample.feature.contacts.ui.screens.contacts.ContactListScreenContract -import dagger.BindsInstance -import dagger.Subcomponent - -@ScreenScope -@Subcomponent( - modules = [ContactListScreenModule::class] -) -interface ContactListScreenComponent : BaseScreenComponent { - val screenModel: ContactListScreenContract.ScreenModelApi - - @Subcomponent.Builder - interface Builder : BaseScreenComponent.Builder { - @BindsInstance - fun inputData(ioData: IOData.EmptyInput): Builder - } - -} \ No newline at end of file diff --git a/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/contacts/di/ContactListScreenInjector.kt b/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/contacts/di/ContactListScreenInjector.kt index fcb138c..968e1cf 100644 --- a/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/contacts/di/ContactListScreenInjector.kt +++ b/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/contacts/di/ContactListScreenInjector.kt @@ -1,5 +1,5 @@ package com.revolut.kompot.sample.feature.contacts.ui.screens.contacts.di interface ContactListScreenInjector { - fun getContactListScreenComponentBuilder(): ContactListScreenComponent.Builder + fun getContactListComponentBuilder(): ContactListControllerComponent.Builder } \ No newline at end of file diff --git a/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/contacts/di/ContactListScreenModule.kt b/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/contacts/di/ContactListScreenModule.kt deleted file mode 100644 index 89aa8db..0000000 --- a/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/contacts/di/ContactListScreenModule.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.revolut.kompot.sample.feature.contacts.ui.screens.contacts.di - -import com.revolut.kompot.di.scope.ScreenScope -import com.revolut.kompot.di.screen.BaseScreenModule -import com.revolut.kompot.navigable.screen.StateMapper -import com.revolut.kompot.sample.feature.contacts.ui.screens.contacts.ContactListScreenContract.* -import com.revolut.kompot.sample.feature.contacts.ui.screens.contacts.ContactListScreenModel -import com.revolut.kompot.sample.feature.contacts.ui.screens.contacts.ContactListStateMapper -import dagger.Binds -import dagger.Module - -@Module -abstract class ContactListScreenModule : BaseScreenModule { - - @Binds - @ScreenScope - abstract fun bindsStateMapper(contactListStateMapper: ContactListStateMapper): StateMapper - - @Binds - @ScreenScope - abstract fun bindsScreenModel(contactListScreenModel: ContactListScreenModel): ScreenModelApi - -} \ No newline at end of file diff --git a/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/input/InputScreenContract.kt b/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/input/InputContract.kt similarity index 55% rename from samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/input/InputScreenContract.kt rename to samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/input/InputContract.kt index 55e967e..419f1f7 100644 --- a/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/input/InputScreenContract.kt +++ b/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/input/InputContract.kt @@ -1,24 +1,18 @@ package com.revolut.kompot.sample.feature.contacts.ui.screens.input import com.revolut.kompot.common.IOData -import com.revolut.kompot.navigable.screen.ScreenModel -import com.revolut.kompot.navigable.screen.ScreenStates -import kotlinx.parcelize.Parcelize +import com.revolut.kompot.navigable.vc.ui.States +import com.revolut.kompot.navigable.vc.ui.UIStatesModel -interface InputScreenContract { +interface InputContract { - interface ScreenModelApi : ScreenModel { + interface ModelApi : UIStatesModel { fun onInputChanged(text: String) fun onActionClick() } - - @Parcelize - data class InputData( - val inputType: InputType - ) : IOData.Input enum class InputType { FIRST_NAME, LAST_NAME } - + data class OutputData( val text: String ) : IOData.Output @@ -26,10 +20,10 @@ interface InputScreenContract { data class DomainState( val inputType: InputType, val inputText: String - ) : ScreenStates.Domain + ) : States.Domain data class UIState( val inputHint: String, val inputText: String - ) : ScreenStates.UI + ) : States.UI } \ No newline at end of file diff --git a/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/input/InputScreen.kt b/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/input/InputScreen.kt deleted file mode 100644 index 245e3d7..0000000 --- a/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/input/InputScreen.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.revolut.kompot.sample.feature.contacts.ui.screens.input - -import android.view.View -import com.revolut.kompot.navigable.screen.BaseScreen -import com.revolut.kompot.navigable.screen.ScreenStates -import com.revolut.kompot.sample.feature.contacts.R -import com.revolut.kompot.sample.feature.contacts.databinding.ScreenInputBinding -import com.revolut.kompot.sample.feature.contacts.ui.screens.input.InputScreenContract.* -import com.revolut.kompot.sample.feature.contacts.ui.screens.input.di.InputScreenInjector -import com.revolut.kompot.navigable.utils.viewBinding - -class InputScreen(inputData: InputData) : BaseScreen(inputData) { - - override val layoutId = R.layout.screen_input - private val binding by viewBinding(ScreenInputBinding::bind) - - override val screenComponent by lazy(LazyThreadSafetyMode.NONE) { - (flowComponent as InputScreenInjector) - .getInputScreenComponentBuilder() - .screen(this) - .inputData(inputData) - .build() - } - - override val screenModel by lazy(LazyThreadSafetyMode.NONE) { screenComponent.screenModel } - - override fun onScreenViewAttached(view: View) { - super.onScreenViewAttached(view) - - binding.vInput.textFlow() - .collectTillDetachView { text -> - screenModel.onInputChanged(text) - } - - binding.btnContinue.setOnClickListener { - screenModel.onActionClick() - } - } - - override fun bindScreen(uiState: UIState, payload: ScreenStates.UIPayload?) { - binding.vInput.setInputHint(uiState.inputHint) - binding.vInput.setInputText(uiState.inputText) - } -} \ No newline at end of file diff --git a/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/input/InputScreenModel.kt b/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/input/InputScreenModel.kt deleted file mode 100644 index 1ae50a7..0000000 --- a/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/input/InputScreenModel.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.revolut.kompot.sample.feature.contacts.ui.screens.input - -import com.revolut.kompot.navigable.screen.BaseScreenModel -import com.revolut.kompot.navigable.screen.StateMapper -import com.revolut.kompot.sample.feature.contacts.ui.screens.input.InputScreenContract.* -import javax.inject.Inject - -internal class InputScreenModel @Inject constructor( - stateMapper: StateMapper, - inputData: InputData -) : BaseScreenModel(stateMapper), ScreenModelApi { - - override val initialState = DomainState( - inputType = inputData.inputType, - inputText = "" - ) - - override fun onActionClick() { - postScreenResult(OutputData(state.inputText)) - } - - override fun onInputChanged(text: String) { - updateState { - copy(inputText = text) - } - } -} \ No newline at end of file diff --git a/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/input/InputStateMapper.kt b/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/input/InputStateMapper.kt index a198396..c7e75db 100644 --- a/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/input/InputStateMapper.kt +++ b/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/input/InputStateMapper.kt @@ -1,10 +1,12 @@ package com.revolut.kompot.sample.feature.contacts.ui.screens.input -import com.revolut.kompot.navigable.screen.StateMapper -import com.revolut.kompot.sample.feature.contacts.ui.screens.input.InputScreenContract.* +import com.revolut.kompot.navigable.vc.ui.States +import com.revolut.kompot.sample.feature.contacts.ui.screens.input.InputContract.DomainState +import com.revolut.kompot.sample.feature.contacts.ui.screens.input.InputContract.InputType +import com.revolut.kompot.sample.feature.contacts.ui.screens.input.InputContract.UIState import javax.inject.Inject -class InputStateMapper @Inject constructor() : StateMapper { +class InputStateMapper @Inject constructor() : States.Mapper { override fun mapState(domainState: DomainState): UIState { return UIState( diff --git a/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/input/InputViewController.kt b/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/input/InputViewController.kt new file mode 100644 index 0000000..3a38936 --- /dev/null +++ b/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/input/InputViewController.kt @@ -0,0 +1,45 @@ +package com.revolut.kompot.sample.feature.contacts.ui.screens.input + +import android.view.View +import com.revolut.kompot.navigable.utils.viewBinding +import com.revolut.kompot.navigable.vc.ViewController +import com.revolut.kompot.navigable.vc.ui.ModelBinding +import com.revolut.kompot.navigable.vc.ui.UIStatesController +import com.revolut.kompot.sample.feature.contacts.R +import com.revolut.kompot.sample.feature.contacts.databinding.ScreenInputBinding +import com.revolut.kompot.sample.feature.contacts.ui.screens.input.InputContract.* +import com.revolut.kompot.sample.feature.contacts.ui.screens.input.di.InputControllerInjector + +class InputViewController(inputType: InputType) : ViewController(), UIStatesController { + + override val layoutId = R.layout.screen_input + private val binding by viewBinding(ScreenInputBinding::bind) + + override val fitStatusBar = true + override val component by lazy(LazyThreadSafetyMode.NONE) { + (parentComponent as InputControllerInjector) + .getInputComponentBuilder() + .controller(this) + .inputType(inputType) + .build() + } + override val controllerModel by lazy(LazyThreadSafetyMode.NONE) { component.model } + override val modelBinding by lazy(LazyThreadSafetyMode.NONE) { + ModelBinding(controllerModel) + } + + override fun onShown(view: View) { + binding.vInput.textFlow() + .collectTillDetachView { text -> + controllerModel.onInputChanged(text) + } + binding.btnContinue.setOnClickListener { + controllerModel.onActionClick() + } + } + + override fun render(uiState: UIState, payload: Any?) { + binding.vInput.setInputHint(uiState.inputHint) + binding.vInput.setInputText(uiState.inputText) + } +} \ No newline at end of file diff --git a/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/input/InputViewModel.kt b/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/input/InputViewModel.kt new file mode 100644 index 0000000..a234893 --- /dev/null +++ b/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/input/InputViewModel.kt @@ -0,0 +1,34 @@ +package com.revolut.kompot.sample.feature.contacts.ui.screens.input + +import com.revolut.kompot.navigable.vc.ViewControllerModel +import com.revolut.kompot.navigable.vc.ui.ModelState +import com.revolut.kompot.navigable.vc.ui.States +import com.revolut.kompot.sample.feature.contacts.ui.screens.input.InputContract.DomainState +import com.revolut.kompot.sample.feature.contacts.ui.screens.input.InputContract.ModelApi +import com.revolut.kompot.sample.feature.contacts.ui.screens.input.InputContract.OutputData +import com.revolut.kompot.sample.feature.contacts.ui.screens.input.InputContract.UIState +import javax.inject.Inject + +internal class InputViewModel @Inject constructor( + stateMapper: States.Mapper, + inputType: InputContract.InputType +) : ViewControllerModel(), ModelApi { + + override val state = ModelState( + initialState = DomainState( + inputType = inputType, + inputText = "" + ), + stateMapper = stateMapper, + ) + + override fun onActionClick() { + postResult(OutputData(state.current.inputText)) + } + + override fun onInputChanged(text: String) { + state.update { + copy(inputText = text) + } + } +} \ No newline at end of file diff --git a/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/input/di/InputControllerComponent.kt b/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/input/di/InputControllerComponent.kt new file mode 100644 index 0000000..0b85201 --- /dev/null +++ b/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/input/di/InputControllerComponent.kt @@ -0,0 +1,22 @@ +package com.revolut.kompot.sample.feature.contacts.ui.screens.input.di + +import com.revolut.kompot.navigable.vc.di.ViewControllerComponent +import com.revolut.kompot.navigable.vc.di.ViewControllerScope +import com.revolut.kompot.sample.feature.contacts.ui.screens.input.InputContract +import dagger.BindsInstance +import dagger.Subcomponent + +@ViewControllerScope +@Subcomponent( + modules = [InputControllerModule::class] +) +interface InputControllerComponent : ViewControllerComponent { + + val model: InputContract.ModelApi + + @Subcomponent.Builder + interface Builder : ViewControllerComponent.Builder { + @BindsInstance + fun inputType(inputType: InputContract.InputType): Builder + } +} \ No newline at end of file diff --git a/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/input/di/InputControllerInjector.kt b/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/input/di/InputControllerInjector.kt new file mode 100644 index 0000000..9ca8cc4 --- /dev/null +++ b/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/input/di/InputControllerInjector.kt @@ -0,0 +1,5 @@ +package com.revolut.kompot.sample.feature.contacts.ui.screens.input.di + +interface InputControllerInjector { + fun getInputComponentBuilder(): InputControllerComponent.Builder +} \ No newline at end of file diff --git a/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/input/di/InputControllerModule.kt b/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/input/di/InputControllerModule.kt new file mode 100644 index 0000000..3334afb --- /dev/null +++ b/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/input/di/InputControllerModule.kt @@ -0,0 +1,22 @@ +package com.revolut.kompot.sample.feature.contacts.ui.screens.input.di + +import com.revolut.kompot.navigable.vc.di.ViewControllerModule +import com.revolut.kompot.navigable.vc.di.ViewControllerScope +import com.revolut.kompot.navigable.vc.ui.States +import com.revolut.kompot.sample.feature.contacts.ui.screens.input.InputContract +import com.revolut.kompot.sample.feature.contacts.ui.screens.input.InputContract.DomainState +import com.revolut.kompot.sample.feature.contacts.ui.screens.input.InputContract.UIState +import com.revolut.kompot.sample.feature.contacts.ui.screens.input.InputStateMapper +import com.revolut.kompot.sample.feature.contacts.ui.screens.input.InputViewModel +import dagger.Binds +import dagger.Module + +@Module +internal abstract class InputControllerModule : ViewControllerModule { + + @[Binds ViewControllerScope] + abstract fun provideMapper(mapper: InputStateMapper): States.Mapper + + @[Binds ViewControllerScope] + abstract fun provideViewModel(model: InputViewModel): InputContract.ModelApi +} \ No newline at end of file diff --git a/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/input/di/InputScreenComponent.kt b/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/input/di/InputScreenComponent.kt deleted file mode 100644 index d61b5ad..0000000 --- a/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/input/di/InputScreenComponent.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.revolut.kompot.sample.feature.contacts.ui.screens.input.di - -import com.revolut.kompot.di.scope.ScreenScope -import com.revolut.kompot.di.screen.BaseScreenComponent -import com.revolut.kompot.sample.feature.contacts.ui.screens.input.InputScreenContract -import com.revolut.kompot.sample.feature.contacts.ui.screens.input.InputScreenContract.InputData -import dagger.BindsInstance -import dagger.Subcomponent - -@ScreenScope -@Subcomponent( - modules = [InputScreenModule::class] -) -interface InputScreenComponent : BaseScreenComponent { - - val screenModel: InputScreenContract.ScreenModelApi - - @Subcomponent.Builder - interface Builder : BaseScreenComponent.Builder { - @BindsInstance - fun inputData(inputData: InputData): Builder - } -} \ No newline at end of file diff --git a/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/input/di/InputScreenInjector.kt b/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/input/di/InputScreenInjector.kt deleted file mode 100644 index fb1a850..0000000 --- a/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/input/di/InputScreenInjector.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.revolut.kompot.sample.feature.contacts.ui.screens.input.di - -interface InputScreenInjector { - fun getInputScreenComponentBuilder(): InputScreenComponent.Builder -} \ No newline at end of file diff --git a/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/input/di/InputScreenModule.kt b/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/input/di/InputScreenModule.kt deleted file mode 100644 index 8c24c49..0000000 --- a/samples/messenger/feature_contacts_impl/src/main/java/com/revolut/kompot/sample/feature/contacts/ui/screens/input/di/InputScreenModule.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.revolut.kompot.sample.feature.contacts.ui.screens.input.di - -import com.revolut.kompot.di.scope.ScreenScope -import com.revolut.kompot.di.screen.BaseScreenModule -import com.revolut.kompot.navigable.screen.StateMapper -import com.revolut.kompot.sample.feature.contacts.ui.screens.input.InputScreenContract -import com.revolut.kompot.sample.feature.contacts.ui.screens.input.InputScreenContract.DomainState -import com.revolut.kompot.sample.feature.contacts.ui.screens.input.InputScreenContract.UIState -import com.revolut.kompot.sample.feature.contacts.ui.screens.input.InputScreenModel -import com.revolut.kompot.sample.feature.contacts.ui.screens.input.InputStateMapper -import dagger.Binds -import dagger.Module - -@Module -internal abstract class InputScreenModule : BaseScreenModule { - - @Binds - @ScreenScope - abstract fun provideMapper(mapper: InputStateMapper): StateMapper - - @Binds - @ScreenScope - abstract fun provideScreenModel(model: InputScreenModel): InputScreenContract.ScreenModelApi -} \ No newline at end of file diff --git a/samples/messenger/feature_contacts_impl/src/test/java/com/revolut/kompot/sample/feature/contacts/ui/flows/add_contact/AddContactFlowModelTest.kt b/samples/messenger/feature_contacts_impl/src/test/java/com/revolut/kompot/sample/feature/contacts/ui/flows/add_contact/AddContactFlowModelTest.kt index 7edc4cd..886988b 100644 --- a/samples/messenger/feature_contacts_impl/src/test/java/com/revolut/kompot/sample/feature/contacts/ui/flows/add_contact/AddContactFlowModelTest.kt +++ b/samples/messenger/feature_contacts_impl/src/test/java/com/revolut/kompot/sample/feature/contacts/ui/flows/add_contact/AddContactFlowModelTest.kt @@ -9,7 +9,7 @@ import com.revolut.kompot.coroutines.test.dispatchBlockingTest import com.revolut.kompot.sample.feature.contacts.data.ContactsRepository import com.revolut.kompot.sample.feature.contacts.domain.Contact import com.revolut.kompot.sample.feature.contacts.ui.flows.add_contact.AddContactFlowContract.Step -import com.revolut.kompot.sample.feature.contacts.ui.screens.input.InputScreenContract +import com.revolut.kompot.sample.feature.contacts.ui.screens.input.InputContract import org.junit.jupiter.api.Test class AddContactFlowModelTest { @@ -33,11 +33,11 @@ class AddContactFlowModelTest { flowModel.test() .assertStep( step = Step.InputFirstName, - result = InputScreenContract.OutputData(firstName) + result = InputContract.OutputData(firstName) ) .assertStep( step = Step.InputLastName, - result = InputScreenContract.OutputData(lastName) + result = InputContract.OutputData(lastName) ).also { verify(contactsRepository).saveContact(expectedContact) } diff --git a/samples/messenger/feature_contacts_impl/src/test/java/com/revolut/kompot/sample/feature/contacts/ui/screens/contacts/ContactListStateMapperTest.kt b/samples/messenger/feature_contacts_impl/src/test/java/com/revolut/kompot/sample/feature/contacts/ui/screens/contacts/ContactListStateMapperTest.kt index 1ffbb7d..ac604c4 100644 --- a/samples/messenger/feature_contacts_impl/src/test/java/com/revolut/kompot/sample/feature/contacts/ui/screens/contacts/ContactListStateMapperTest.kt +++ b/samples/messenger/feature_contacts_impl/src/test/java/com/revolut/kompot/sample/feature/contacts/ui/screens/contacts/ContactListStateMapperTest.kt @@ -2,7 +2,7 @@ package com.revolut.kompot.sample.feature.contacts.ui.screens.contacts import com.revolut.kompot.sample.feature.contacts.R import com.revolut.kompot.sample.feature.contacts.domain.Contact -import com.revolut.kompot.sample.feature.contacts.ui.screens.contacts.ContactListScreenContract.* +import com.revolut.kompot.sample.feature.contacts.ui.screens.contacts.ContactListContract.* import com.revolut.kompot.sample.ui_common.RowDelegate import com.revolut.kompot.sample.ui_common.TextModel import org.junit.jupiter.api.Assertions.assertEquals diff --git a/samples/messenger/feature_contacts_impl/src/test/java/com/revolut/kompot/sample/feature/contacts/ui/screens/contacts/ContactListScreenModelTest.kt b/samples/messenger/feature_contacts_impl/src/test/java/com/revolut/kompot/sample/feature/contacts/ui/screens/contacts/ContactListViewModelTest.kt similarity index 73% rename from samples/messenger/feature_contacts_impl/src/test/java/com/revolut/kompot/sample/feature/contacts/ui/screens/contacts/ContactListScreenModelTest.kt rename to samples/messenger/feature_contacts_impl/src/test/java/com/revolut/kompot/sample/feature/contacts/ui/screens/contacts/ContactListViewModelTest.kt index e276abe..78d9d49 100644 --- a/samples/messenger/feature_contacts_impl/src/test/java/com/revolut/kompot/sample/feature/contacts/ui/screens/contacts/ContactListScreenModelTest.kt +++ b/samples/messenger/feature_contacts_impl/src/test/java/com/revolut/kompot/sample/feature/contacts/ui/screens/contacts/ContactListViewModelTest.kt @@ -6,36 +6,35 @@ import com.nhaarman.mockitokotlin2.mock import com.nhaarman.mockitokotlin2.verify import com.revolut.kompot.common.ModalDestination import com.revolut.kompot.common.NavigationEvent +import com.revolut.kompot.core.test.assertion.applyTestDependencies import com.revolut.kompot.coroutines.test.dispatchBlockingTest import com.revolut.kompot.coroutines.test.flow.testIn +import com.revolut.kompot.navigable.vc.test.testDomainStateStream import com.revolut.kompot.sample.feature.contacts.createSampleContact import com.revolut.kompot.sample.feature.contacts.data.ContactsRepository import com.revolut.kompot.sample.feature.contacts.ui.flows.add_contact.AddContactFlow -import com.revolut.kompot.sample.feature.contacts.ui.screens.contacts.ContactListScreenContract.DomainState +import com.revolut.kompot.sample.feature.contacts.ui.screens.contacts.ContactListContract.DomainState import kotlinx.coroutines.flow.flowOf import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test -class ContactListScreenModelTest { +class ContactListViewModelTest { private val defaultContacts = listOf(createSampleContact()) private val repository: ContactsRepository = mock { on { contactsStream() } doReturn flowOf(defaultContacts) } - private val screenModel = ContactListScreenModel( + private val viewModel = ContactListViewModel( stateMapper = mock(), repository = repository - ).apply { - injectDependencies(mock(), mock(), mock()) - } + ).applyTestDependencies() @Test fun `should load chat list after created`() = dispatchBlockingTest { + val streamTest = viewModel.testDomainStateStream().testIn(this) - val streamTest = screenModel.domainStateStream().testIn(this) - - screenModel.onCreated() + viewModel.onCreated() val expected = listOf( DomainState( @@ -53,12 +52,12 @@ class ContactListScreenModelTest { @Test fun `should go to add contact when action clicked`() { - screenModel.onCreated() - screenModel.onActionClick() + viewModel.onCreated() + viewModel.onActionClick() argumentCaptor { - verify(screenModel.eventsDispatcher).handleEvent(capture()) - val destination = firstValue.destination as ModalDestination.ExplicitFlow<*> - assertTrue(destination.flow is AddContactFlow) + verify(viewModel.eventsDispatcher).handleEvent(capture()) + val destination = firstValue.destination as ModalDestination.CallbackController + assertTrue(destination.controller is AddContactFlow) } } diff --git a/samples/messenger/feature_contacts_impl/src/test/java/com/revolut/kompot/sample/feature/contacts/ui/screens/input/InputStateMapperTest.kt b/samples/messenger/feature_contacts_impl/src/test/java/com/revolut/kompot/sample/feature/contacts/ui/screens/input/InputStateMapperTest.kt index bd524c5..71f2121 100644 --- a/samples/messenger/feature_contacts_impl/src/test/java/com/revolut/kompot/sample/feature/contacts/ui/screens/input/InputStateMapperTest.kt +++ b/samples/messenger/feature_contacts_impl/src/test/java/com/revolut/kompot/sample/feature/contacts/ui/screens/input/InputStateMapperTest.kt @@ -1,6 +1,6 @@ package com.revolut.kompot.sample.feature.contacts.ui.screens.input -import com.revolut.kompot.sample.feature.contacts.ui.screens.input.InputScreenContract.* +import com.revolut.kompot.sample.feature.contacts.ui.screens.input.InputContract.* import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test diff --git a/samples/messenger/feature_contacts_impl/src/test/java/com/revolut/kompot/sample/feature/contacts/ui/screens/input/InputScreenModelTest.kt b/samples/messenger/feature_contacts_impl/src/test/java/com/revolut/kompot/sample/feature/contacts/ui/screens/input/InputViewModelTest.kt similarity index 57% rename from samples/messenger/feature_contacts_impl/src/test/java/com/revolut/kompot/sample/feature/contacts/ui/screens/input/InputScreenModelTest.kt rename to samples/messenger/feature_contacts_impl/src/test/java/com/revolut/kompot/sample/feature/contacts/ui/screens/input/InputViewModelTest.kt index 1089f84..9502681 100644 --- a/samples/messenger/feature_contacts_impl/src/test/java/com/revolut/kompot/sample/feature/contacts/ui/screens/input/InputScreenModelTest.kt +++ b/samples/messenger/feature_contacts_impl/src/test/java/com/revolut/kompot/sample/feature/contacts/ui/screens/input/InputViewModelTest.kt @@ -4,18 +4,21 @@ import com.nhaarman.mockitokotlin2.mock import com.revolut.kompot.core.test.assertion.resultStream import com.revolut.kompot.coroutines.test.dispatchBlockingTest import com.revolut.kompot.coroutines.test.flow.testIn -import com.revolut.kompot.sample.feature.contacts.ui.screens.input.InputScreenContract.* +import com.revolut.kompot.navigable.vc.test.testDomainStateStream +import com.revolut.kompot.sample.feature.contacts.ui.screens.input.InputContract.DomainState +import com.revolut.kompot.sample.feature.contacts.ui.screens.input.InputContract.InputType +import com.revolut.kompot.sample.feature.contacts.ui.screens.input.InputContract.OutputData import org.junit.jupiter.api.Test -class InputScreenModelTest { +class InputViewModelTest { @Test fun `should have input type in domain state`() = dispatchBlockingTest { - val screenModel = createScreenModel(InputType.FIRST_NAME) + val model = createModel(InputType.FIRST_NAME) - val streamTest = screenModel.domainStateStream().testIn(this) + val streamTest = model.testDomainStateStream().testIn(this) - screenModel.onCreated() + model.onCreated() val expectedState = DomainState(InputType.FIRST_NAME, "") @@ -24,14 +27,14 @@ class InputScreenModelTest { @Test fun `should update state with new input`() = dispatchBlockingTest { - val screenModel = createScreenModel(InputType.FIRST_NAME) + val model = createModel(InputType.FIRST_NAME) - val streamTest = screenModel.domainStateStream().testIn(this) + val streamTest = model.testDomainStateStream().testIn(this) val input = "hello" - screenModel.onCreated() - screenModel.onInputChanged(input) + model.onCreated() + model.onInputChanged(input) val expectedStates = listOf( DomainState( @@ -49,22 +52,21 @@ class InputScreenModelTest { @Test fun `should return result with the latest input when action clicked`() = dispatchBlockingTest { - val screenModel = createScreenModel() + val model = createModel() - val streamTest = screenModel.resultStream().testIn(this) + val streamTest = model.resultStream().testIn(this) val input = "hello" - screenModel.onCreated() - screenModel.onInputChanged(input) - screenModel.onActionClick() + model.onCreated() + model.onInputChanged(input) + model.onActionClick() streamTest.assertValues(OutputData(input)) } - private fun createScreenModel(inputType: InputType = InputType.FIRST_NAME) = InputScreenModel( + private fun createModel(inputType: InputType = InputType.FIRST_NAME) = InputViewModel( stateMapper = mock(), - inputData = InputData(inputType) + inputType = inputType, ) - } \ No newline at end of file diff --git a/samples/messenger/gradle/wrapper/gradle-wrapper.properties b/samples/messenger/gradle/wrapper/gradle-wrapper.properties index f7c1cfd..a5a6fd6 100644 --- a/samples/messenger/gradle/wrapper/gradle-wrapper.properties +++ b/samples/messenger/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-all.zip diff --git a/samples/messenger/kompot_sample_dependencies.gradle b/samples/messenger/kompot_sample_dependencies.gradle index e59980c..6936b66 100644 --- a/samples/messenger/kompot_sample_dependencies.gradle +++ b/samples/messenger/kompot_sample_dependencies.gradle @@ -1,8 +1,8 @@ ext{ - kotlin_version = '1.6.10' + kotlin_version = '1.7.21' coroutines_version = '1.6.0' - dagger_version = '2.38.1' + dagger_version = '2.44.2' room_version = '2.4.2' jupiter_version = '5.4.2' mockito_version = '3.5.10' @@ -11,11 +11,11 @@ ext{ appcompat_version = '1.1.0' constraint_version = '1.1.3' material_version = '1.2.0-beta01' - recycler_kit_version = '1.0.10' + recycler_kit_version = '1.1.2' timber_version = '4.7.1' - kompotVersion = '0.0.2' + kompotVersion = '0.0.3' as_room_dependencies = [ [configuration: "implementation", dependency: "androidx.room:room-runtime:$room_version"], @@ -26,6 +26,7 @@ ext{ as_test_dependencies = [ [configuration: "testImplementation", dependency: "org.junit.jupiter:junit-jupiter-api:$jupiter_version"], [configuration: "testRuntimeOnly", dependency: "org.junit.jupiter:junit-jupiter-engine:$jupiter_version"], + [configuration: "testImplementation", dependency: "org.junit.jupiter:junit-jupiter-params:$jupiter_version"], [configuration: "testImplementation", dependency: "org.mockito:mockito-core:$mockito_version"], [configuration: "testImplementation", dependency: "org.mockito:mockito-inline:$mockito_version"], [configuration: "testImplementation", dependency: "com.nhaarman.mockitokotlin2:mockito-kotlin:$mockito_kotlin_version"], @@ -33,18 +34,24 @@ ext{ ] as_general_dependencies_api = [ + [configuration: "implementation", dependency: "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"], + [configuration: "implementation", dependency: "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"], + [configuration: "implementation", dependency: "com.google.dagger:dagger:$dagger_version"], [configuration: "implementation", dependency: "com.jakewharton.timber:timber:$timber_version"], - [configuration: "implementation", dependency: "com.revolut.kompot:kompot:$kompotVersion"], - [configuration: "testImplementation", dependency: "com.revolut.kompot:core-test:$kompotVersion"], - [configuration: "testImplementation", dependency: "com.revolut.kompot:coroutines-test:$kompotVersion"] + [configuration: "implementation", dependency: project(':kompot')], + [configuration: "testImplementation", dependency: project(':kompot_core_test')], + [configuration: "testImplementation", dependency: project(':kompot_coroutines_test')] ] as_general_dependencies_impl = as_general_dependencies_api + as_test_dependencies + [ [configuration: "kapt", dependency: "com.google.dagger:dagger-compiler:$dagger_version"], + [configuration: "implementation", dependency: "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version"], + [configuration: "implementation", dependency: "androidx.appcompat:appcompat:$appcompat_version"], [configuration: "implementation", dependency: "androidx.constraintlayout:constraintlayout:$constraint_version"], [configuration: "implementation", dependency: "com.google.android.material:material:$material_version"], + [configuration: "implementation", dependency: "com.revolut.recyclerkit:delegates:$recycler_kit_version"], [configuration: "implementation", dependency: project(':data')], [configuration: "implementation", dependency: project(':ui_common')], diff --git a/samples/messenger/playground/src/main/java/com/revolut/kompot/sample/playground/PlaygroundFeatureGateway.kt b/samples/messenger/playground/src/main/java/com/revolut/kompot/sample/playground/PlaygroundFeatureGateway.kt index d300e18..dcfd708 100644 --- a/samples/messenger/playground/src/main/java/com/revolut/kompot/sample/playground/PlaygroundFeatureGateway.kt +++ b/samples/messenger/playground/src/main/java/com/revolut/kompot/sample/playground/PlaygroundFeatureGateway.kt @@ -18,7 +18,7 @@ class PlaygroundFeatureGateway(argsProvider: () -> PlaygroundArguments) : Featur destination: NavigationDestination, flowModel: BaseFlowModel<*, *, *> ): Controller? = when (destination) { - is DemoFlowDestination -> DemoFlow() + DemoFlowDestination -> DemoFlow() else -> null } diff --git a/samples/messenger/playground/src/main/java/com/revolut/kompot/sample/playground/delegates/ButtonDelegate.kt b/samples/messenger/playground/src/main/java/com/revolut/kompot/sample/playground/delegates/ButtonDelegate.kt index 884d08e..8047ca3 100644 --- a/samples/messenger/playground/src/main/java/com/revolut/kompot/sample/playground/delegates/ButtonDelegate.kt +++ b/samples/messenger/playground/src/main/java/com/revolut/kompot/sample/playground/delegates/ButtonDelegate.kt @@ -21,7 +21,7 @@ class ButtonDelegate : BaseRecyclerViewDelegate?) { + override fun onBindViewHolder(holder: ViewHolder, data: Model, pos: Int, payloads: List) { super.onBindViewHolder(holder, data, pos, payloads) val currentPayloads = payloads?.mapNotNull { it as? Payload } diff --git a/samples/messenger/playground/src/main/java/com/revolut/kompot/sample/playground/delegates/RowDelegate.kt b/samples/messenger/playground/src/main/java/com/revolut/kompot/sample/playground/delegates/RowDelegate.kt index 52aa408..3afc7de 100644 --- a/samples/messenger/playground/src/main/java/com/revolut/kompot/sample/playground/delegates/RowDelegate.kt +++ b/samples/messenger/playground/src/main/java/com/revolut/kompot/sample/playground/delegates/RowDelegate.kt @@ -17,7 +17,7 @@ class RowDelegate : BaseRecyclerViewDelegate?) { + override fun onBindViewHolder(holder: ViewHolder, data: Model, pos: Int, payloads: List) { super.onBindViewHolder(holder, data, pos, payloads) val currentPayloads = payloads?.mapNotNull { it as? Payload } diff --git a/samples/messenger/playground/src/main/java/com/revolut/kompot/sample/playground/di/PlaygroundFeatureComponent.kt b/samples/messenger/playground/src/main/java/com/revolut/kompot/sample/playground/di/PlaygroundFeatureComponent.kt index 78037c1..8fb3d62 100644 --- a/samples/messenger/playground/src/main/java/com/revolut/kompot/sample/playground/di/PlaygroundFeatureComponent.kt +++ b/samples/messenger/playground/src/main/java/com/revolut/kompot/sample/playground/di/PlaygroundFeatureComponent.kt @@ -10,8 +10,7 @@ import dagger.Component @FeatureScope @Component -interface PlaygroundFeatureComponent : PlaygroundApi, DemoFlowInjector, DemoScreenInjector, - DemoScrollerFlowInjector { +interface PlaygroundFeatureComponent : PlaygroundApi, DemoFlowInjector, DemoScreenInjector, DemoScrollerFlowInjector { @Component.Factory interface Builder { fun create(): PlaygroundFeatureComponent diff --git a/samples/messenger/playground/src/main/java/com/revolut/kompot/sample/playground/flows/scroller/DemoScrollerFlow.kt b/samples/messenger/playground/src/main/java/com/revolut/kompot/sample/playground/flows/scroller/DemoScrollerFlow.kt index cc0d99c..e88bd08 100644 --- a/samples/messenger/playground/src/main/java/com/revolut/kompot/sample/playground/flows/scroller/DemoScrollerFlow.kt +++ b/samples/messenger/playground/src/main/java/com/revolut/kompot/sample/playground/flows/scroller/DemoScrollerFlow.kt @@ -1,6 +1,5 @@ package com.revolut.kompot.sample.playground.flows.scroller -import com.revolut.kompot.ExperimentalKompotApi import com.revolut.kompot.common.IOData import com.revolut.kompot.navigable.flow.scroller.BaseScrollerFlow import com.revolut.kompot.sample.playground.di.PlaygroundApiProvider @@ -8,9 +7,7 @@ import com.revolut.kompot.sample.playground.flows.scroller.DemoScrollerFlowContr import com.revolut.kompot.sample.playground.flows.scroller.di.DemoScrollerFlowComponent import timber.log.Timber -@OptIn(ExperimentalKompotApi::class) -class DemoScrollerFlow : - BaseScrollerFlow(IOData.EmptyInput) { +class DemoScrollerFlow : BaseScrollerFlow(IOData.EmptyInput) { override val component: DemoScrollerFlowComponent by lazy(LazyThreadSafetyMode.NONE) { PlaygroundApiProvider.component diff --git a/samples/messenger/playground/src/main/java/com/revolut/kompot/sample/playground/flows/scroller/DemoScrollerFlowContract.kt b/samples/messenger/playground/src/main/java/com/revolut/kompot/sample/playground/flows/scroller/DemoScrollerFlowContract.kt index d3c83cf..4c4fce7 100644 --- a/samples/messenger/playground/src/main/java/com/revolut/kompot/sample/playground/flows/scroller/DemoScrollerFlowContract.kt +++ b/samples/messenger/playground/src/main/java/com/revolut/kompot/sample/playground/flows/scroller/DemoScrollerFlowContract.kt @@ -1,7 +1,7 @@ package com.revolut.kompot.sample.playground.flows.scroller import com.revolut.kompot.common.IOData -import com.revolut.kompot.navigable.flow.FlowStep +import com.revolut.kompot.navigable.flow.scroller.FixedIdScrollerStep import com.revolut.kompot.navigable.flow.scroller.ScrollerFlowModel import kotlinx.parcelize.Parcelize @@ -9,7 +9,7 @@ interface DemoScrollerFlowContract { interface FlowModelApi : ScrollerFlowModel - sealed class Step : FlowStep { + sealed class Step : FixedIdScrollerStep() { @Parcelize object FirstStep : Step() diff --git a/samples/messenger/playground/src/main/java/com/revolut/kompot/sample/playground/flows/scroller/DemoScrollerFlowModel.kt b/samples/messenger/playground/src/main/java/com/revolut/kompot/sample/playground/flows/scroller/DemoScrollerFlowModel.kt index 04a5082..81cda99 100644 --- a/samples/messenger/playground/src/main/java/com/revolut/kompot/sample/playground/flows/scroller/DemoScrollerFlowModel.kt +++ b/samples/messenger/playground/src/main/java/com/revolut/kompot/sample/playground/flows/scroller/DemoScrollerFlowModel.kt @@ -1,7 +1,6 @@ package com.revolut.kompot.sample.playground.flows.scroller import android.graphics.Color -import com.revolut.kompot.ExperimentalKompotApi import com.revolut.kompot.common.IOData import com.revolut.kompot.navigable.Controller import com.revolut.kompot.navigable.flow.scroller.BaseScrollerFlowModel @@ -12,9 +11,7 @@ import com.revolut.kompot.sample.playground.screens.demo.DemoScreen import com.revolut.kompot.sample.playground.screens.demo.DemoScreenContract import javax.inject.Inject -@OptIn(ExperimentalKompotApi::class) -class DemoScrollerFlowModel @Inject constructor() : - BaseScrollerFlowModel(), FlowModelApi { +class DemoScrollerFlowModel @Inject constructor() : BaseScrollerFlowModel(), FlowModelApi { override val initialSteps = Steps(Step.FirstStep, Step.SecondStep, Step.ThirdStep) diff --git a/samples/messenger/playground/src/main/java/com/revolut/kompot/sample/playground/flows/scroller/di/DemoScrollerFlowComponent.kt b/samples/messenger/playground/src/main/java/com/revolut/kompot/sample/playground/flows/scroller/di/DemoScrollerFlowComponent.kt index b4c97ae..d806bc5 100644 --- a/samples/messenger/playground/src/main/java/com/revolut/kompot/sample/playground/flows/scroller/di/DemoScrollerFlowComponent.kt +++ b/samples/messenger/playground/src/main/java/com/revolut/kompot/sample/playground/flows/scroller/di/DemoScrollerFlowComponent.kt @@ -1,12 +1,10 @@ package com.revolut.kompot.sample.playground.flows.scroller.di -import com.revolut.kompot.ExperimentalKompotApi import com.revolut.kompot.di.flow.scroller.BaseScrollerFlowComponent import com.revolut.kompot.di.scope.FlowScope import com.revolut.kompot.sample.playground.flows.scroller.DemoScrollerFlowContract import dagger.Subcomponent -@OptIn(ExperimentalKompotApi::class) @FlowScope @Subcomponent( modules = [DemoScrollerFlowModule::class] diff --git a/samples/messenger/playground/src/main/java/com/revolut/kompot/sample/playground/flows/scroller/di/DemoScrollerFlowModule.kt b/samples/messenger/playground/src/main/java/com/revolut/kompot/sample/playground/flows/scroller/di/DemoScrollerFlowModule.kt index 22157bc..62ba87e 100644 --- a/samples/messenger/playground/src/main/java/com/revolut/kompot/sample/playground/flows/scroller/di/DemoScrollerFlowModule.kt +++ b/samples/messenger/playground/src/main/java/com/revolut/kompot/sample/playground/flows/scroller/di/DemoScrollerFlowModule.kt @@ -1,6 +1,5 @@ package com.revolut.kompot.sample.playground.flows.scroller.di -import com.revolut.kompot.ExperimentalKompotApi import com.revolut.kompot.di.flow.scroller.BaseScrollerFlowModule import com.revolut.kompot.di.scope.FlowScope import com.revolut.kompot.sample.playground.flows.scroller.DemoScrollerFlowContract @@ -8,7 +7,6 @@ import com.revolut.kompot.sample.playground.flows.scroller.DemoScrollerFlowModel import dagger.Binds import dagger.Module -@OptIn(ExperimentalKompotApi::class) @Module abstract class DemoScrollerFlowModule : BaseScrollerFlowModule { @Binds diff --git a/samples/messenger/settings.gradle b/samples/messenger/settings.gradle index f6d73d6..a710be8 100644 --- a/samples/messenger/settings.gradle +++ b/samples/messenger/settings.gradle @@ -1,3 +1,15 @@ +include ':kompot' +project(':kompot').projectDir = new File('../../kompot/') + +include ':kompot_core_test' +project(':kompot_core_test').projectDir = new File('../../kompot_core_test/') + +include ':kompot_coroutines' +project(':kompot_coroutines').projectDir = new File('../../kompot_coroutines/') + +include ':kompot_coroutines_test' +project(':kompot_coroutines_test').projectDir = new File('../../kompot_coroutines_test/') + include ':app', ':data', ':utils', diff --git a/samples/messenger/ui_common/build.gradle b/samples/messenger/ui_common/build.gradle index 4c82d71..e1fa30b 100644 --- a/samples/messenger/ui_common/build.gradle +++ b/samples/messenger/ui_common/build.gradle @@ -7,6 +7,7 @@ apply from: '../kompot_sample_dependencies.gradle' ext { ui_common_dependencies = [ + [configuration: "implementation", dependency: "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"], [configuration: "implementation", dependency: "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"], [configuration: "implementation", dependency: "androidx.appcompat:appcompat:$appcompat_version"], @@ -16,4 +17,8 @@ ext { ] } -addDependencies(ui_common_dependencies) \ No newline at end of file +addDependencies(ui_common_dependencies) + +android { + namespace "com.revolut.kompot.sample.ui_common" +} \ No newline at end of file diff --git a/samples/messenger/ui_common/src/main/java/com/revolut/kompot/sample/ui_common/RowDelegate.kt b/samples/messenger/ui_common/src/main/java/com/revolut/kompot/sample/ui_common/RowDelegate.kt index 7efc369..fb0641b 100644 --- a/samples/messenger/ui_common/src/main/java/com/revolut/kompot/sample/ui_common/RowDelegate.kt +++ b/samples/messenger/ui_common/src/main/java/com/revolut/kompot/sample/ui_common/RowDelegate.kt @@ -26,12 +26,12 @@ class RowDelegate : BaseRecyclerViewDelegate?) { + override fun onBindViewHolder(holder: ViewHolder, data: Model, pos: Int, payloads: List) { super.onBindViewHolder(holder, data, pos, payloads) holder.itemView.setOnClickListener { onItemClickSharedFlow.tryEmit(data) } - if (payloads.isNullOrEmpty()) { + if (payloads.isEmpty()) { holder.image.setImageResource(data.image) holder.title.setTextOrHide(data.title) holder.subtitle.setTextOrHide(data.subtitle) diff --git a/samples/messenger/utils/build.gradle b/samples/messenger/utils/build.gradle index a6a0ca1..0cf23c3 100644 --- a/samples/messenger/utils/build.gradle +++ b/samples/messenger/utils/build.gradle @@ -9,4 +9,8 @@ addDependencies( as_general_dependencies_api + [ [configuration: "kapt", dependency: "com.google.dagger:dagger-compiler:$dagger_version"], ] -) \ No newline at end of file +) + +android { + namespace "com.revolut.kompot.sample.utils" +} \ No newline at end of file