diff --git a/samples/containers/app-poetry/src/main/java/com/squareup/sample/poetryapp/PoetryActivity.kt b/samples/containers/app-poetry/src/main/java/com/squareup/sample/poetryapp/PoetryActivity.kt index 9879be6246..ee85fa30b3 100644 --- a/samples/containers/app-poetry/src/main/java/com/squareup/sample/poetryapp/PoetryActivity.kt +++ b/samples/containers/app-poetry/src/main/java/com/squareup/sample/poetryapp/PoetryActivity.kt @@ -16,12 +16,11 @@ import com.squareup.workflow1.WorkflowExperimentalRuntime import com.squareup.workflow1.config.AndroidRuntimeConfigTools import com.squareup.workflow1.ui.Screen import com.squareup.workflow1.ui.WorkflowLayout +import com.squareup.workflow1.ui.navigation.reportNavigation import com.squareup.workflow1.ui.renderWorkflowIn -import com.squareup.workflow1.ui.unwrap import com.squareup.workflow1.ui.withRegistry import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach import timber.log.Timber private val viewRegistry = SampleContainers @@ -53,8 +52,8 @@ class PoetryModel(savedState: SavedStateHandle) : ViewModel() { prop = 0 to 0 to Poem.allPoems, savedStateHandle = savedState, runtimeConfig = AndroidRuntimeConfigTools.getAppWorkflowRuntimeConfig() - ).onEach { - Timber.i("Navigated to %s", it.unwrap()) + ).reportNavigation { + Timber.i("Navigated to %s", it) } } } diff --git a/samples/containers/app-raven/src/main/java/com/squareup/sample/ravenapp/RavenActivity.kt b/samples/containers/app-raven/src/main/java/com/squareup/sample/ravenapp/RavenActivity.kt index 65c65d70bc..84b2823bbb 100644 --- a/samples/containers/app-raven/src/main/java/com/squareup/sample/ravenapp/RavenActivity.kt +++ b/samples/containers/app-raven/src/main/java/com/squareup/sample/ravenapp/RavenActivity.kt @@ -16,13 +16,12 @@ import com.squareup.workflow1.WorkflowExperimentalRuntime import com.squareup.workflow1.config.AndroidRuntimeConfigTools import com.squareup.workflow1.ui.Screen import com.squareup.workflow1.ui.WorkflowLayout +import com.squareup.workflow1.ui.navigation.reportNavigation import com.squareup.workflow1.ui.renderWorkflowIn -import com.squareup.workflow1.ui.unwrap import com.squareup.workflow1.ui.withRegistry import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import timber.log.Timber @@ -64,8 +63,8 @@ class RavenModel(savedState: SavedStateHandle) : ViewModel() { runtimeConfig = AndroidRuntimeConfigTools.getAppWorkflowRuntimeConfig() ) { running.complete() - }.onEach { - Timber.i("Navigated to %s", it.unwrap()) + }.reportNavigation { + Timber.i("Navigated to %s", it) } } diff --git a/samples/containers/hello-back-button/src/main/java/com/squareup/sample/hellobackbutton/HelloBackButtonActivity.kt b/samples/containers/hello-back-button/src/main/java/com/squareup/sample/hellobackbutton/HelloBackButtonActivity.kt index 936a51eb14..688db25d79 100644 --- a/samples/containers/hello-back-button/src/main/java/com/squareup/sample/hellobackbutton/HelloBackButtonActivity.kt +++ b/samples/containers/hello-back-button/src/main/java/com/squareup/sample/hellobackbutton/HelloBackButtonActivity.kt @@ -14,13 +14,12 @@ import com.squareup.workflow1.WorkflowExperimentalRuntime import com.squareup.workflow1.config.AndroidRuntimeConfigTools import com.squareup.workflow1.ui.Screen import com.squareup.workflow1.ui.WorkflowLayout +import com.squareup.workflow1.ui.navigation.reportNavigation import com.squareup.workflow1.ui.renderWorkflowIn -import com.squareup.workflow1.ui.unwrap import com.squareup.workflow1.ui.withRegistry import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import timber.log.Timber @@ -63,8 +62,8 @@ class HelloBackButtonModel(savedState: SavedStateHandle) : ViewModel() { // This workflow handles the back button itself, so the activity can't. // Instead, the workflow emits an output to signal that it's time to shut things down. running.complete() - }.onEach { - Timber.i("Navigated to %s", it.unwrap()) + }.reportNavigation { + Timber.i("Navigated to %s", it) } } diff --git a/workflow-ui/core-common/api/core-common.api b/workflow-ui/core-common/api/core-common.api index 49e536fb37..a20c15e183 100644 --- a/workflow-ui/core-common/api/core-common.api +++ b/workflow-ui/core-common/api/core-common.api @@ -154,6 +154,7 @@ public abstract interface class com/squareup/workflow1/ui/Wrapper : com/squareup public abstract fun asSequence ()Lkotlin/sequences/Sequence; public abstract fun getCompatibilityKey ()Ljava/lang/String; public abstract fun getContent ()Ljava/lang/Object; + public abstract fun getUnwrapped ()Ljava/lang/Object; public abstract fun map (Lkotlin/jvm/functions/Function1;)Lcom/squareup/workflow1/ui/Wrapper; } @@ -294,6 +295,18 @@ public final class com/squareup/workflow1/ui/navigation/FullScreenModal : com/sq public abstract interface class com/squareup/workflow1/ui/navigation/ModalOverlay : com/squareup/workflow1/ui/navigation/Overlay { } +public final class com/squareup/workflow1/ui/navigation/NavigationMonitor { + public fun ()V + public fun (ZLkotlin/jvm/functions/Function1;)V + public synthetic fun (ZLkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun update (Ljava/lang/Object;)V +} + +public final class com/squareup/workflow1/ui/navigation/NavigationMonitorKt { + public static final fun reportNavigation (Lkotlinx/coroutines/flow/Flow;ZLkotlin/jvm/functions/Function1;)Lkotlinx/coroutines/flow/Flow; + public static synthetic fun reportNavigation$default (Lkotlinx/coroutines/flow/Flow;ZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lkotlinx/coroutines/flow/Flow; +} + public abstract interface class com/squareup/workflow1/ui/navigation/Overlay { } diff --git a/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/Container.kt b/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/Container.kt index 619059e7e1..e65d6482c2 100644 --- a/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/Container.kt +++ b/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/Container.kt @@ -87,6 +87,8 @@ public interface Container : Composite { public interface Wrapper : Container, Compatible { public val content: C + override val unwrapped: Any get() = content + /** * Default implementation makes this [Wrapper] compatible with others of the same type, * and which wrap compatible [content]. diff --git a/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/navigation/BackStackScreen.kt b/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/navigation/BackStackScreen.kt index bc20d3108b..e0710db641 100644 --- a/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/navigation/BackStackScreen.kt +++ b/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/navigation/BackStackScreen.kt @@ -48,6 +48,8 @@ public class BackStackScreen internal constructor( override val compatibilityKey: String = keyFor(this, name) + override val unwrapped: Any get() = top + override fun asSequence(): Sequence = frames.asSequence() /** diff --git a/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/navigation/BodyAndOverlaysScreen.kt b/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/navigation/BodyAndOverlaysScreen.kt index 73c9f0c83c..735765c8a0 100644 --- a/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/navigation/BodyAndOverlaysScreen.kt +++ b/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/navigation/BodyAndOverlaysScreen.kt @@ -79,6 +79,8 @@ public class BodyAndOverlaysScreen( ) : Screen, Compatible, Composite { override val compatibilityKey: String = keyFor(this, name) + override val unwrapped: Any = overlays.lastOrNull() ?: body + override fun asSequence(): Sequence = sequenceOf(body) + overlays.asSequence() public fun mapBody(transform: (B) -> S): BodyAndOverlaysScreen { diff --git a/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/navigation/NavigationMonitor.kt b/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/navigation/NavigationMonitor.kt new file mode 100644 index 0000000000..2701f85aec --- /dev/null +++ b/workflow-ui/core-common/src/main/java/com/squareup/workflow1/ui/navigation/NavigationMonitor.kt @@ -0,0 +1,52 @@ +package com.squareup.workflow1.ui.navigation + +import com.squareup.workflow1.ui.Compatible +import com.squareup.workflow1.ui.unwrap +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.onEach + +/** + * Reports navigation across a series of calls to [update], probably made + * for each rendering posted by + * [renderWorkflowIn][com.squareup.workflow1.renderWorkflowIn]. + * + * Takes advantage of [unwrap()] and [Compatible.keyFor] to provide navigation + * logging by reporting the top (read: last-most, inner-most) sub-rendering, + * which conventionally is the one that is visible and accessible to the user. + * + * Reports each time the [Compatible.keyFor] the top is unequal to the previous one, + * which conventionally indicates that a new view object will replace the previous one. + */ +public class NavigationMonitor( + skipFirstScreen: Boolean = false, + private val onNavigate: (Any) -> Unit = { println(it) } +) { + @Volatile + private var lastKey: String? = if (skipFirstScreen) null else "" + + /** + * Uses [unwrap] to find the topmost element of [rendering] and + * reports it with [onNavigate] if [Compatible.keyFor] reveals that + * it is of a different kind from the previous top. + */ + public fun update(rendering: Any) { + val unwrapped = rendering.unwrap() + + Compatible.keyFor(unwrapped).takeIf { it != lastKey }?.let { newKey -> + if (lastKey != null) onNavigate(unwrapped) + lastKey = newKey + } + } +} + +/** + * Creates a [NavigationMonitor] and [updates it][NavigationMonitor.update] + * with [each element collected][Flow.onEach] by the receiving [Flow]. + */ +public fun Flow.reportNavigation( + skipFirstScreen: Boolean = false, + onNavigate: (Any) -> Unit = { println(it) } +): Flow { + val monitor = NavigationMonitor(skipFirstScreen, onNavigate) + return onEach { monitor.update(it) } +} diff --git a/workflow-ui/core-common/src/test/java/com/squareup/workflow1/ui/navigation/NavigationMonitorTest.kt b/workflow-ui/core-common/src/test/java/com/squareup/workflow1/ui/navigation/NavigationMonitorTest.kt new file mode 100644 index 0000000000..811ac80f3a --- /dev/null +++ b/workflow-ui/core-common/src/test/java/com/squareup/workflow1/ui/navigation/NavigationMonitorTest.kt @@ -0,0 +1,189 @@ +package com.squareup.workflow1.ui.navigation + +import com.squareup.workflow1.ui.Compatible +import com.squareup.workflow1.ui.Compatible.Companion.keyFor +import com.squareup.workflow1.ui.Container +import com.squareup.workflow1.ui.Screen +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertSame + +class NavigationMonitorTest { + private data class NotScreen( + val name: String, + val baggage: String = "" + ) : Compatible { + override val compatibilityKey: String = keyFor(this, name) + } + + private data class TestScreen( + val name: String, + val baggage: String = "" + ) : Screen, Compatible { + override val compatibilityKey: String = keyFor(this, name) + } + + private data class TestContainer( + val content: List + ) : Container { + override fun asSequence(): Sequence = content.asSequence() + + override fun map(transform: (T) -> D): Container = error("not relevant") + } + + private class TestOverlay( + override val content: T + ) : ScreenOverlay { + override fun map(transform: (T) -> ContentU) = error("not relevant") + } + + private var lastTop: Any? = null + private var updates = 0 + + private fun onUpdate(top: Any) { + lastTop = top + updates++ + } + + private val monitor = NavigationMonitor(onNavigate = ::onUpdate) + + @Test + fun `reports first by default`() { + val screen = TestScreen("first") + assertNull(lastTop) + monitor.update(screen) + assertSame(screen, lastTop) + } + + @Test + fun `can skip first`() { + val monitor = NavigationMonitor(skipFirstScreen = true, ::onUpdate) + + assertNull(lastTop) + monitor.update(TestScreen("first")) + assertNull(lastTop) + + monitor.update(TestScreen("second")) + assertEquals(TestScreen("second"), lastTop) + } + + @Test + fun `reports only on compatibility change`() { + val type1Instance1 = TestScreen("first") + assertEquals(0, updates) + + monitor.update(type1Instance1) + assertEquals(1, updates) + + val type1Instance2 = type1Instance1.copy(baggage = "baggage") + assertNotEquals(type1Instance1, type1Instance2) + monitor.update(type1Instance2) + assertEquals(1, updates) + assertSame(type1Instance1, lastTop) + + val type2 = TestScreen("second") + monitor.update(type2) + assertEquals(2, updates) + assertSame(type2, lastTop) + } + + @Test + fun `handles non-Screens`() { + val first = NotScreen("first") + + monitor.update(first) + assertSame(first, lastTop) + + monitor.update(first.copy(baggage = "fnord")) + assertSame(first, lastTop) + assertEquals(1, updates) + + monitor.update(NotScreen("second", baggage = "fnord")) + assertEquals(NotScreen("second", baggage = "fnord"), lastTop) + assertEquals(2, updates) + } + + @Test + fun unwraps() { + monitor.update(container(TestScreen("0"), TestScreen("1"), TestScreen("2"))) + assertEquals(TestScreen("2"), lastTop) + assertEquals(1, updates) + + monitor.update(container(TestScreen("0"), TestScreen("1"), TestScreen("2"))) + assertEquals(TestScreen("2"), lastTop) + assertEquals(1, updates) + + monitor.update(container(TestScreen("0"), TestScreen("Hidden Update"), TestScreen("2"))) + assertEquals(TestScreen("2"), lastTop) + assertEquals(1, updates) + + monitor.update(container(TestScreen("0"), TestScreen("Hidden Update"), TestScreen("3"))) + assertEquals(TestScreen("3"), lastTop) + assertEquals(2, updates) + + monitor.update(container(TestScreen("3", "baggage"))) + assertEquals(TestScreen("3"), lastTop) + assertEquals(2, updates) + } + + @Test + fun `stock navigation types play nice`() { + val body = TestScreen("Body") + + monitor.update(bodyAndOverlays(body)) + assertSame(body, lastTop) + + monitor.update(bodyAndOverlays(body.copy(baggage = "updated"))) + assertSame(body, lastTop) + + val firstWindowBody = TestScreen("first window") + monitor.update(bodyAndOverlays(body, TestOverlay(firstWindowBody))) + assertSame(firstWindowBody, lastTop) + + val wizardOne = TestScreen("wizard one") + monitor.update( + bodyAndOverlays( + body, + TestOverlay(firstWindowBody), + TestOverlay(BackStackScreen(wizardOne)) + ) + ) + assertSame(wizardOne, lastTop) + + monitor.update( + bodyAndOverlays( + body, + TestOverlay(firstWindowBody), + TestOverlay(BackStackScreen(wizardOne.copy(baggage = "updated"))) + ) + ) + assertSame(wizardOne, lastTop) + + val wizardTwo = TestScreen("wizard two") + monitor.update( + bodyAndOverlays( + body, + TestOverlay(firstWindowBody), + TestOverlay( + BackStackScreen( + wizardOne.copy(baggage = "updated"), + wizardTwo + ) + ) + ) + ) + assertSame(wizardTwo, lastTop) + } + + private fun container(vararg elements: T): TestContainer = + TestContainer(elements.toList()) + + private fun bodyAndOverlays( + body: Screen, + vararg overlays: Overlay + ): BodyAndOverlaysScreen<*, *> { + return BodyAndOverlaysScreen(body, overlays.asList()) + } +}