diff --git a/decompose/api/android/decompose.api b/decompose/api/android/decompose.api index 829c49e0a..93b7ddb52 100644 --- a/decompose/api/android/decompose.api +++ b/decompose/api/android/decompose.api @@ -187,6 +187,7 @@ public final class com/arkivanov/decompose/router/pages/ChildPagesFactoryKt { } public final class com/arkivanov/decompose/router/pages/Pages { + public static final field Companion Lcom/arkivanov/decompose/router/pages/Pages$Companion; public fun ()V public fun (Ljava/util/List;I)V public final fun component1 ()Ljava/util/List; @@ -200,6 +201,21 @@ public final class com/arkivanov/decompose/router/pages/Pages { public fun toString ()Ljava/lang/String; } +public synthetic class com/arkivanov/decompose/router/pages/Pages$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public fun (Lkotlinx/serialization/KSerializer;)V + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lcom/arkivanov/decompose/router/pages/Pages; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lcom/arkivanov/decompose/router/pages/Pages;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public final fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class com/arkivanov/decompose/router/pages/Pages$Companion { + public final fun serializer (Lkotlinx/serialization/KSerializer;)Lkotlinx/serialization/KSerializer; +} + public abstract interface class com/arkivanov/decompose/router/pages/PagesNavigation : com/arkivanov/decompose/router/children/NavigationSource, com/arkivanov/decompose/router/pages/PagesNavigator { } @@ -234,6 +250,11 @@ public final class com/arkivanov/decompose/router/pages/PagesNavigatorExtKt { public static synthetic fun selectPrev$default (Lcom/arkivanov/decompose/router/pages/PagesNavigator;ZLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V } +public final class com/arkivanov/decompose/router/pages/PagesWebNavigationKt { + public static final fun childPagesWebNavigation (Lcom/arkivanov/decompose/router/pages/PagesNavigator;Lcom/arkivanov/decompose/value/Value;Lkotlinx/serialization/KSerializer;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;)Lcom/arkivanov/decompose/router/webhistory/WebNavigation; + public static synthetic fun childPagesWebNavigation$default (Lcom/arkivanov/decompose/router/pages/PagesNavigator;Lcom/arkivanov/decompose/value/Value;Lkotlinx/serialization/KSerializer;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/arkivanov/decompose/router/webhistory/WebNavigation; +} + public final class com/arkivanov/decompose/router/panels/ChildPanels { public fun (Lcom/arkivanov/decompose/Child$Created;Lcom/arkivanov/decompose/Child$Created;Lcom/arkivanov/decompose/Child$Created;Lcom/arkivanov/decompose/router/panels/ChildPanelsMode;)V public synthetic fun (Lcom/arkivanov/decompose/Child$Created;Lcom/arkivanov/decompose/Child$Created;Lcom/arkivanov/decompose/Child$Created;Lcom/arkivanov/decompose/router/panels/ChildPanelsMode;ILkotlin/jvm/internal/DefaultConstructorMarker;)V @@ -354,6 +375,11 @@ public final class com/arkivanov/decompose/router/panels/PanelsNavigatorExtKt { public static synthetic fun setMode$default (Lcom/arkivanov/decompose/router/panels/PanelsNavigator;Lcom/arkivanov/decompose/router/panels/ChildPanelsMode;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)V } +public final class com/arkivanov/decompose/router/panels/PanelsWebNavigationKt { + public static final fun childPanelsWebNavigation (Lcom/arkivanov/decompose/router/panels/PanelsNavigator;Lcom/arkivanov/decompose/value/Value;Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/KSerializer;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;)Lcom/arkivanov/decompose/router/webhistory/WebNavigation; + public static synthetic fun childPanelsWebNavigation$default (Lcom/arkivanov/decompose/router/panels/PanelsNavigator;Lcom/arkivanov/decompose/value/Value;Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/KSerializer;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/arkivanov/decompose/router/webhistory/WebNavigation; +} + public final class com/arkivanov/decompose/router/slot/ChildSlot { public fun ()V public fun (Lcom/arkivanov/decompose/Child$Created;)V @@ -472,6 +498,11 @@ public final class com/arkivanov/decompose/router/stack/StackNavigatorExtKt { public static synthetic fun replaceCurrent$default (Lcom/arkivanov/decompose/router/stack/StackNavigator;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)V } +public final class com/arkivanov/decompose/router/stack/StackWebNavigationKt { + public static final fun childStackWebNavigation (Lcom/arkivanov/decompose/router/stack/StackNavigator;Lcom/arkivanov/decompose/value/Value;Lkotlinx/serialization/KSerializer;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;)Lcom/arkivanov/decompose/router/webhistory/WebNavigation; + public static synthetic fun childStackWebNavigation$default (Lcom/arkivanov/decompose/router/stack/StackNavigator;Lcom/arkivanov/decompose/value/Value;Lkotlinx/serialization/KSerializer;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/arkivanov/decompose/router/webhistory/WebNavigation; +} + public final class com/arkivanov/decompose/router/stack/ValueExtKt { public static final fun getActive (Lcom/arkivanov/decompose/value/Value;)Lcom/arkivanov/decompose/Child$Created; public static final fun getBackStack (Lcom/arkivanov/decompose/value/Value;)Ljava/util/List; @@ -487,6 +518,33 @@ public final class com/arkivanov/decompose/router/stack/webhistory/WebHistoryCon public static synthetic fun attach$default (Lcom/arkivanov/decompose/router/stack/webhistory/WebHistoryController;Lcom/arkivanov/decompose/router/stack/StackNavigator;Lcom/arkivanov/decompose/value/Value;Lkotlinx/serialization/KSerializer;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)V } +public abstract interface class com/arkivanov/decompose/router/webhistory/WebNavigation { + public abstract fun getHistory ()Lcom/arkivanov/decompose/value/Value; + public abstract fun getSerializer ()Lkotlinx/serialization/KSerializer; + public abstract fun navigate (Ljava/util/List;)V + public abstract fun onBeforeNavigate ()Z +} + +public final class com/arkivanov/decompose/router/webhistory/WebNavigation$HistoryItem { + public fun (Ljava/lang/Object;Ljava/lang/String;Ljava/util/Map;Lcom/arkivanov/decompose/router/webhistory/WebNavigationOwner;)V + public final fun getChild ()Lcom/arkivanov/decompose/router/webhistory/WebNavigationOwner; + public final fun getKey ()Ljava/lang/Object; + public final fun getParameters ()Ljava/util/Map; + public final fun getPath ()Ljava/lang/String; +} + +public abstract interface class com/arkivanov/decompose/router/webhistory/WebNavigationOwner { + public abstract fun getWebNavigation ()Lcom/arkivanov/decompose/router/webhistory/WebNavigation; +} + +public abstract interface class com/arkivanov/decompose/router/webhistory/WebNavigationOwner$NoOp : com/arkivanov/decompose/router/webhistory/WebNavigationOwner { + public abstract fun getWebNavigation ()Lcom/arkivanov/decompose/router/webhistory/WebNavigation; +} + +public final class com/arkivanov/decompose/router/webhistory/WebNavigationOwner$NoOp$DefaultImpls { + public static fun getWebNavigation (Lcom/arkivanov/decompose/router/webhistory/WebNavigationOwner$NoOp;)Lcom/arkivanov/decompose/router/webhistory/WebNavigation; +} + public abstract class com/arkivanov/decompose/value/MutableValue : com/arkivanov/decompose/value/Value { public fun ()V public abstract fun compareAndSet (Ljava/lang/Object;Ljava/lang/Object;)Z diff --git a/decompose/api/decompose.klib.api b/decompose/api/decompose.klib.api index 59791561f..4f410b1aa 100644 --- a/decompose/api/decompose.klib.api +++ b/decompose/api/decompose.klib.api @@ -114,6 +114,29 @@ abstract interface <#A: kotlin/Any> com.arkivanov.decompose.router.stack/StackNa abstract fun navigate(kotlin/Function1, kotlin.collections/List<#A>>, kotlin/Function2, kotlin.collections/List<#A>, kotlin/Unit>) // com.arkivanov.decompose.router.stack/StackNavigator.navigate|navigate(kotlin.Function1,kotlin.collections.List<1:0>>;kotlin.Function2,kotlin.collections.List<1:0>,kotlin.Unit>){}[0] } +abstract interface <#A: kotlin/Any> com.arkivanov.decompose.router.webhistory/WebNavigation { // com.arkivanov.decompose.router.webhistory/WebNavigation|null[0] + abstract val history // com.arkivanov.decompose.router.webhistory/WebNavigation.history|{}history[0] + abstract fun (): com.arkivanov.decompose.value/Value>> // com.arkivanov.decompose.router.webhistory/WebNavigation.history.|(){}[0] + abstract val serializer // com.arkivanov.decompose.router.webhistory/WebNavigation.serializer|{}serializer[0] + abstract fun (): kotlinx.serialization/KSerializer<#A> // com.arkivanov.decompose.router.webhistory/WebNavigation.serializer.|(){}[0] + + abstract fun navigate(kotlin.collections/List<#A>) // com.arkivanov.decompose.router.webhistory/WebNavigation.navigate|navigate(kotlin.collections.List<1:0>){}[0] + abstract fun onBeforeNavigate(): kotlin/Boolean // com.arkivanov.decompose.router.webhistory/WebNavigation.onBeforeNavigate|onBeforeNavigate(){}[0] + + final class <#A1: out kotlin/Any?> HistoryItem { // com.arkivanov.decompose.router.webhistory/WebNavigation.HistoryItem|null[0] + constructor (#A1, kotlin/String, kotlin.collections/Map, com.arkivanov.decompose.router.webhistory/WebNavigationOwner?) // com.arkivanov.decompose.router.webhistory/WebNavigation.HistoryItem.|(1:0;kotlin.String;kotlin.collections.Map;com.arkivanov.decompose.router.webhistory.WebNavigationOwner?){}[0] + + final val child // com.arkivanov.decompose.router.webhistory/WebNavigation.HistoryItem.child|{}child[0] + final fun (): com.arkivanov.decompose.router.webhistory/WebNavigationOwner? // com.arkivanov.decompose.router.webhistory/WebNavigation.HistoryItem.child.|(){}[0] + final val key // com.arkivanov.decompose.router.webhistory/WebNavigation.HistoryItem.key|{}key[0] + final fun (): #A1 // com.arkivanov.decompose.router.webhistory/WebNavigation.HistoryItem.key.|(){}[0] + final val parameters // com.arkivanov.decompose.router.webhistory/WebNavigation.HistoryItem.parameters|{}parameters[0] + final fun (): kotlin.collections/Map // com.arkivanov.decompose.router.webhistory/WebNavigation.HistoryItem.parameters.|(){}[0] + final val path // com.arkivanov.decompose.router.webhistory/WebNavigation.HistoryItem.path|{}path[0] + final fun (): kotlin/String // com.arkivanov.decompose.router.webhistory/WebNavigation.HistoryItem.path.|(){}[0] + } +} + abstract interface <#A: out kotlin/Any> com.arkivanov.decompose.router.children/ChildNavState { // com.arkivanov.decompose.router.children/ChildNavState|null[0] abstract val configuration // com.arkivanov.decompose.router.children/ChildNavState.configuration|{}configuration[0] abstract fun (): #A // com.arkivanov.decompose.router.children/ChildNavState.configuration.|(){}[0] @@ -157,6 +180,16 @@ abstract interface com.arkivanov.decompose.router.stack.webhistory/WebHistoryCon abstract fun <#A1: kotlin/Any> attach(com.arkivanov.decompose.router.stack/StackNavigator<#A1>, com.arkivanov.decompose.value/Value>, kotlinx.serialization/KSerializer<#A1>, kotlin/Function1<#A1, kotlin/String>, kotlin/Function1, kotlin/Function2, kotlin.collections/List<#A1>, kotlin/Boolean> = ...) // com.arkivanov.decompose.router.stack.webhistory/WebHistoryController.attach|attach(com.arkivanov.decompose.router.stack.StackNavigator<0:0>;com.arkivanov.decompose.value.Value>;kotlinx.serialization.KSerializer<0:0>;kotlin.Function1<0:0,kotlin.String>;kotlin.Function1;kotlin.Function2,kotlin.collections.List<0:0>,kotlin.Boolean>){0§}[0] } +abstract interface com.arkivanov.decompose.router.webhistory/WebNavigationOwner { // com.arkivanov.decompose.router.webhistory/WebNavigationOwner|null[0] + abstract val webNavigation // com.arkivanov.decompose.router.webhistory/WebNavigationOwner.webNavigation|{}webNavigation[0] + abstract fun (): com.arkivanov.decompose.router.webhistory/WebNavigation<*> // com.arkivanov.decompose.router.webhistory/WebNavigationOwner.webNavigation.|(){}[0] + + abstract interface NoOp : com.arkivanov.decompose.router.webhistory/WebNavigationOwner { // com.arkivanov.decompose.router.webhistory/WebNavigationOwner.NoOp|null[0] + open val webNavigation // com.arkivanov.decompose.router.webhistory/WebNavigationOwner.NoOp.webNavigation|{}webNavigation[0] + open fun (): com.arkivanov.decompose.router.webhistory/WebNavigation<*> // com.arkivanov.decompose.router.webhistory/WebNavigationOwner.NoOp.webNavigation.|(){}[0] + } +} + abstract interface com.arkivanov.decompose/ComponentContext : com.arkivanov.decompose/GenericComponentContext // com.arkivanov.decompose/ComponentContext|null[0] abstract class <#A: kotlin/Any> com.arkivanov.decompose.value/MutableValue : com.arkivanov.decompose.value/Value<#A> { // com.arkivanov.decompose.value/MutableValue|null[0] @@ -334,6 +367,27 @@ final class <#A: out kotlin/Any> com.arkivanov.decompose.router.pages/Pages { // final fun equals(kotlin/Any?): kotlin/Boolean // com.arkivanov.decompose.router.pages/Pages.equals|equals(kotlin.Any?){}[0] final fun hashCode(): kotlin/Int // com.arkivanov.decompose.router.pages/Pages.hashCode|hashCode(){}[0] final fun toString(): kotlin/String // com.arkivanov.decompose.router.pages/Pages.toString|toString(){}[0] + + final class <#A1: kotlin/Any?> $serializer : kotlinx.serialization.internal/GeneratedSerializer> { // com.arkivanov.decompose.router.pages/Pages.$serializer|null[0] + constructor (kotlinx.serialization/KSerializer<#A1>) // com.arkivanov.decompose.router.pages/Pages.$serializer.|(kotlinx.serialization.KSerializer<1:0>){}[0] + + final val descriptor // com.arkivanov.decompose.router.pages/Pages.$serializer.descriptor|{}descriptor[0] + final fun (): kotlinx.serialization.descriptors/SerialDescriptor // com.arkivanov.decompose.router.pages/Pages.$serializer.descriptor.|(){}[0] + final val typeSerial0 // com.arkivanov.decompose.router.pages/Pages.$serializer.typeSerial0|{}typeSerial0[0] + + final fun childSerializers(): kotlin/Array> // com.arkivanov.decompose.router.pages/Pages.$serializer.childSerializers|childSerializers(){}[0] + final fun deserialize(kotlinx.serialization.encoding/Decoder): com.arkivanov.decompose.router.pages/Pages<#A1> // com.arkivanov.decompose.router.pages/Pages.$serializer.deserialize|deserialize(kotlinx.serialization.encoding.Decoder){}[0] + final fun serialize(kotlinx.serialization.encoding/Encoder, com.arkivanov.decompose.router.pages/Pages<#A1>) // com.arkivanov.decompose.router.pages/Pages.$serializer.serialize|serialize(kotlinx.serialization.encoding.Encoder;com.arkivanov.decompose.router.pages.Pages<1:0>){}[0] + final fun typeParametersSerializers(): kotlin/Array> // com.arkivanov.decompose.router.pages/Pages.$serializer.typeParametersSerializers|typeParametersSerializers(){}[0] + } + + final object Companion : kotlinx.serialization.internal/SerializerFactory { // com.arkivanov.decompose.router.pages/Pages.Companion|null[0] + final val $cachedDescriptor // com.arkivanov.decompose.router.pages/Pages.Companion.$cachedDescriptor|{}$cachedDescriptor[0] + final fun (): kotlinx.serialization.descriptors/SerialDescriptor // com.arkivanov.decompose.router.pages/Pages.Companion.$cachedDescriptor.|(){}[0] + + final fun <#A2: kotlin/Any?> serializer(kotlinx.serialization/KSerializer<#A2>): kotlinx.serialization/KSerializer> // com.arkivanov.decompose.router.pages/Pages.Companion.serializer|serializer(kotlinx.serialization.KSerializer<0:0>){0§}[0] + final fun serializer(kotlin/Array>...): kotlinx.serialization/KSerializer<*> // com.arkivanov.decompose.router.pages/Pages.Companion.serializer|serializer(kotlin.Array>...){}[0] + } } final class com.arkivanov.decompose/DefaultComponentContext : com.arkivanov.decompose/ComponentContext { // com.arkivanov.decompose/DefaultComponentContext|null[0] @@ -441,6 +495,7 @@ final fun <#A: com.arkivanov.decompose/GenericComponentContext<#A>, #B: kotlin/A final fun <#A: com.arkivanov.decompose/GenericComponentContext<#A>, #B: kotlin/Any, #C: kotlin/Any> (#A).com.arkivanov.decompose.router.stack/childStack(com.arkivanov.decompose.router.children/NavigationSource>, kotlinx.serialization/KSerializer<#B>?, #B, kotlin/String = ..., kotlin/Boolean = ..., kotlin/Function2<#B, #A, #C>): com.arkivanov.decompose.value/Value> // com.arkivanov.decompose.router.stack/childStack|childStack@0:0(com.arkivanov.decompose.router.children.NavigationSource>;kotlinx.serialization.KSerializer<0:1>?;0:1;kotlin.String;kotlin.Boolean;kotlin.Function2<0:1,0:0,0:2>){0§>;1§;2§}[0] final fun <#A: com.arkivanov.decompose/GenericComponentContext<#A>, #B: kotlin/Any, #C: kotlin/Any> (#A).com.arkivanov.decompose.router.stack/childStack(com.arkivanov.decompose.router.children/NavigationSource>, kotlinx.serialization/KSerializer<#B>?, kotlin/Function0>, kotlin/String = ..., kotlin/Boolean = ..., kotlin/Function2<#B, #A, #C>): com.arkivanov.decompose.value/Value> // com.arkivanov.decompose.router.stack/childStack|childStack@0:0(com.arkivanov.decompose.router.children.NavigationSource>;kotlinx.serialization.KSerializer<0:1>?;kotlin.Function0>;kotlin.String;kotlin.Boolean;kotlin.Function2<0:1,0:0,0:2>){0§>;1§;2§}[0] final fun <#A: com.arkivanov.decompose/GenericComponentContext<#A>> (#A).com.arkivanov.decompose/childContext(kotlin/String, com.arkivanov.essenty.lifecycle/Lifecycle? = ...): #A // com.arkivanov.decompose/childContext|childContext@0:0(kotlin.String;com.arkivanov.essenty.lifecycle.Lifecycle?){0§>}[0] +final fun <#A: kotlin/Any, #B: kotlin/Any, #C: kotlin/Any, #D: kotlin/Any, #E: kotlin/Any, #F: kotlin/Any> com.arkivanov.decompose.router.panels/childPanelsWebNavigation(com.arkivanov.decompose.router.panels/PanelsNavigator<#A, #C, #E>, com.arkivanov.decompose.value/Value>, kotlinx.serialization/KSerializer<#A>, kotlinx.serialization/KSerializer<#C>, kotlinx.serialization/KSerializer<#E>, kotlin/Function1, kotlin/String?> = ..., kotlin/Function1, kotlin.collections/Map?> = ..., kotlin/Function0 = ..., kotlin/Function1, com.arkivanov.decompose.router.webhistory/WebNavigationOwner?> = ...): com.arkivanov.decompose.router.webhistory/WebNavigation<*> // com.arkivanov.decompose.router.panels/childPanelsWebNavigation|childPanelsWebNavigation(com.arkivanov.decompose.router.panels.PanelsNavigator<0:0,0:2,0:4>;com.arkivanov.decompose.value.Value>;kotlinx.serialization.KSerializer<0:0>;kotlinx.serialization.KSerializer<0:2>;kotlinx.serialization.KSerializer<0:4>;kotlin.Function1,kotlin.String?>;kotlin.Function1,kotlin.collections.Map?>;kotlin.Function0;kotlin.Function1,com.arkivanov.decompose.router.webhistory.WebNavigationOwner?>){0§;1§;2§;3§;4§;5§}[0] final fun <#A: kotlin/Any, #B: kotlin/Any, #C: kotlin/Any> (com.arkivanov.decompose.router.panels/PanelsNavigator<#A, #B, #C>).com.arkivanov.decompose.router.panels/activateDetails(#B, kotlin/Function2, com.arkivanov.decompose.router.panels/Panels<#A, #B, #C>, kotlin/Unit> = ...) // com.arkivanov.decompose.router.panels/activateDetails|activateDetails@com.arkivanov.decompose.router.panels.PanelsNavigator<0:0,0:1,0:2>(0:1;kotlin.Function2,com.arkivanov.decompose.router.panels.Panels<0:0,0:1,0:2>,kotlin.Unit>){0§;1§;2§}[0] final fun <#A: kotlin/Any, #B: kotlin/Any, #C: kotlin/Any> (com.arkivanov.decompose.router.panels/PanelsNavigator<#A, #B, #C>).com.arkivanov.decompose.router.panels/activateExtra(#C, kotlin/Function2, com.arkivanov.decompose.router.panels/Panels<#A, #B, #C>, kotlin/Unit> = ...) // com.arkivanov.decompose.router.panels/activateExtra|activateExtra@com.arkivanov.decompose.router.panels.PanelsNavigator<0:0,0:1,0:2>(0:2;kotlin.Function2,com.arkivanov.decompose.router.panels.Panels<0:0,0:1,0:2>,kotlin.Unit>){0§;1§;2§}[0] final fun <#A: kotlin/Any, #B: kotlin/Any, #C: kotlin/Any> (com.arkivanov.decompose.router.panels/PanelsNavigator<#A, #B, #C>).com.arkivanov.decompose.router.panels/activateMain(#A, kotlin/Function2, com.arkivanov.decompose.router.panels/Panels<#A, #B, #C>, kotlin/Unit> = ...) // com.arkivanov.decompose.router.panels/activateMain|activateMain@com.arkivanov.decompose.router.panels.PanelsNavigator<0:0,0:1,0:2>(0:0;kotlin.Function2,com.arkivanov.decompose.router.panels.Panels<0:0,0:1,0:2>,kotlin.Unit>){0§;1§;2§}[0] @@ -454,6 +509,8 @@ final fun <#A: kotlin/Any, #B: kotlin/Any, #C: kotlin/Any> (com.arkivanov.decomp final fun <#A: kotlin/Any, #B: kotlin/Any, #C: kotlin/Any> (com.arkivanov.decompose.router.panels/PanelsNavigator<#A, #B, #C>).com.arkivanov.decompose.router.panels/setMode(com.arkivanov.decompose.router.panels/ChildPanelsMode, kotlin/Function2, com.arkivanov.decompose.router.panels/Panels<#A, #B, #C>, kotlin/Unit> = ...) // com.arkivanov.decompose.router.panels/setMode|setMode@com.arkivanov.decompose.router.panels.PanelsNavigator<0:0,0:1,0:2>(com.arkivanov.decompose.router.panels.ChildPanelsMode;kotlin.Function2,com.arkivanov.decompose.router.panels.Panels<0:0,0:1,0:2>,kotlin.Unit>){0§;1§;2§}[0] final fun <#A: kotlin/Any, #B: kotlin/Any, #C: kotlin/Any> com.arkivanov.decompose.router.panels/PanelsNavigation(): com.arkivanov.decompose.router.panels/PanelsNavigation<#A, #B, #C> // com.arkivanov.decompose.router.panels/PanelsNavigation|PanelsNavigation(){0§;1§;2§}[0] final fun <#A: kotlin/Any, #B: kotlin/Any> (com.arkivanov.decompose.value/Value<#A>).com.arkivanov.decompose.value.operator/map(kotlin/Function1<#A, #B>): com.arkivanov.decompose.value/Value<#B> // com.arkivanov.decompose.value.operator/map|map@com.arkivanov.decompose.value.Value<0:0>(kotlin.Function1<0:0,0:1>){0§;1§}[0] +final fun <#A: kotlin/Any, #B: kotlin/Any> com.arkivanov.decompose.router.pages/childPagesWebNavigation(com.arkivanov.decompose.router.pages/PagesNavigator<#A>, com.arkivanov.decompose.value/Value>, kotlinx.serialization/KSerializer<#A>, kotlin/Function1, kotlin/String?> = ..., kotlin/Function1, kotlin.collections/Map?> = ..., kotlin/Function0 = ..., kotlin/Function1, com.arkivanov.decompose.router.webhistory/WebNavigationOwner?> = ...): com.arkivanov.decompose.router.webhistory/WebNavigation<*> // com.arkivanov.decompose.router.pages/childPagesWebNavigation|childPagesWebNavigation(com.arkivanov.decompose.router.pages.PagesNavigator<0:0>;com.arkivanov.decompose.value.Value>;kotlinx.serialization.KSerializer<0:0>;kotlin.Function1,kotlin.String?>;kotlin.Function1,kotlin.collections.Map?>;kotlin.Function0;kotlin.Function1,com.arkivanov.decompose.router.webhistory.WebNavigationOwner?>){0§;1§}[0] +final fun <#A: kotlin/Any, #B: kotlin/Any> com.arkivanov.decompose.router.stack/childStackWebNavigation(com.arkivanov.decompose.router.stack/StackNavigator<#A>, com.arkivanov.decompose.value/Value>, kotlinx.serialization/KSerializer<#A>, kotlin/Boolean = ..., kotlin/Function1, kotlin/String?> = ..., kotlin/Function1, kotlin.collections/Map?> = ..., kotlin/Function0 = ..., kotlin/Function1, com.arkivanov.decompose.router.webhistory/WebNavigationOwner?> = ...): com.arkivanov.decompose.router.webhistory/WebNavigation<*> // com.arkivanov.decompose.router.stack/childStackWebNavigation|childStackWebNavigation(com.arkivanov.decompose.router.stack.StackNavigator<0:0>;com.arkivanov.decompose.value.Value>;kotlinx.serialization.KSerializer<0:0>;kotlin.Boolean;kotlin.Function1,kotlin.String?>;kotlin.Function1,kotlin.collections.Map?>;kotlin.Function0;kotlin.Function1,com.arkivanov.decompose.router.webhistory.WebNavigationOwner?>){0§;1§}[0] final fun <#A: kotlin/Any> (com.arkivanov.decompose.router.pages/PagesNavigator<#A>).com.arkivanov.decompose.router.pages/clear(kotlin/Function2, com.arkivanov.decompose.router.pages/Pages<#A>, kotlin/Unit> = ...) // com.arkivanov.decompose.router.pages/clear|clear@com.arkivanov.decompose.router.pages.PagesNavigator<0:0>(kotlin.Function2,com.arkivanov.decompose.router.pages.Pages<0:0>,kotlin.Unit>){0§}[0] final fun <#A: kotlin/Any> (com.arkivanov.decompose.router.pages/PagesNavigator<#A>).com.arkivanov.decompose.router.pages/navigate(kotlin/Function1, com.arkivanov.decompose.router.pages/Pages<#A>>) // com.arkivanov.decompose.router.pages/navigate|navigate@com.arkivanov.decompose.router.pages.PagesNavigator<0:0>(kotlin.Function1,com.arkivanov.decompose.router.pages.Pages<0:0>>){0§}[0] final fun <#A: kotlin/Any> (com.arkivanov.decompose.router.pages/PagesNavigator<#A>).com.arkivanov.decompose.router.pages/select(kotlin/Int, kotlin/Function2, com.arkivanov.decompose.router.pages/Pages<#A>, kotlin/Unit> = ...) // com.arkivanov.decompose.router.pages/select|select@com.arkivanov.decompose.router.pages.PagesNavigator<0:0>(kotlin.Int;kotlin.Function2,com.arkivanov.decompose.router.pages.Pages<0:0>,kotlin.Unit>){0§}[0] @@ -496,3 +553,6 @@ final class com.arkivanov.decompose.router.stack.webhistory/DefaultWebHistoryCon final fun <#A1: kotlin/Any> attach(com.arkivanov.decompose.router.stack/StackNavigator<#A1>, com.arkivanov.decompose.value/Value>, kotlinx.serialization/KSerializer<#A1>, kotlin/Function1<#A1, kotlin/String>, kotlin/Function1, kotlin/Function2, kotlin.collections/List<#A1>, kotlin/Boolean>) // com.arkivanov.decompose.router.stack.webhistory/DefaultWebHistoryController.attach|attach(com.arkivanov.decompose.router.stack.StackNavigator<0:0>;com.arkivanov.decompose.value.Value>;kotlinx.serialization.KSerializer<0:0>;kotlin.Function1<0:0,kotlin.String>;kotlin.Function1;kotlin.Function2,kotlin.collections.List<0:0>,kotlin.Boolean>){0§}[0] } + +// Targets: [js, wasmJs] +final fun <#A: com.arkivanov.decompose.router.webhistory/WebNavigationOwner> com.arkivanov.decompose.router.webhistory/withWebHistory(kotlin/Function2): #A // com.arkivanov.decompose.router.webhistory/withWebHistory|withWebHistory(kotlin.Function2){0§}[0] diff --git a/decompose/api/jvm/decompose.api b/decompose/api/jvm/decompose.api index 81673ba37..33d26549c 100644 --- a/decompose/api/jvm/decompose.api +++ b/decompose/api/jvm/decompose.api @@ -167,6 +167,7 @@ public final class com/arkivanov/decompose/router/pages/ChildPagesFactoryKt { } public final class com/arkivanov/decompose/router/pages/Pages { + public static final field Companion Lcom/arkivanov/decompose/router/pages/Pages$Companion; public fun ()V public fun (Ljava/util/List;I)V public final fun component1 ()Ljava/util/List; @@ -180,6 +181,21 @@ public final class com/arkivanov/decompose/router/pages/Pages { public fun toString ()Ljava/lang/String; } +public synthetic class com/arkivanov/decompose/router/pages/Pages$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public fun (Lkotlinx/serialization/KSerializer;)V + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lcom/arkivanov/decompose/router/pages/Pages; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lcom/arkivanov/decompose/router/pages/Pages;)V + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public final fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class com/arkivanov/decompose/router/pages/Pages$Companion { + public final fun serializer (Lkotlinx/serialization/KSerializer;)Lkotlinx/serialization/KSerializer; +} + public abstract interface class com/arkivanov/decompose/router/pages/PagesNavigation : com/arkivanov/decompose/router/children/NavigationSource, com/arkivanov/decompose/router/pages/PagesNavigator { } @@ -214,6 +230,11 @@ public final class com/arkivanov/decompose/router/pages/PagesNavigatorExtKt { public static synthetic fun selectPrev$default (Lcom/arkivanov/decompose/router/pages/PagesNavigator;ZLkotlin/jvm/functions/Function2;ILjava/lang/Object;)V } +public final class com/arkivanov/decompose/router/pages/PagesWebNavigationKt { + public static final fun childPagesWebNavigation (Lcom/arkivanov/decompose/router/pages/PagesNavigator;Lcom/arkivanov/decompose/value/Value;Lkotlinx/serialization/KSerializer;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;)Lcom/arkivanov/decompose/router/webhistory/WebNavigation; + public static synthetic fun childPagesWebNavigation$default (Lcom/arkivanov/decompose/router/pages/PagesNavigator;Lcom/arkivanov/decompose/value/Value;Lkotlinx/serialization/KSerializer;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/arkivanov/decompose/router/webhistory/WebNavigation; +} + public final class com/arkivanov/decompose/router/panels/ChildPanels { public fun (Lcom/arkivanov/decompose/Child$Created;Lcom/arkivanov/decompose/Child$Created;Lcom/arkivanov/decompose/Child$Created;Lcom/arkivanov/decompose/router/panels/ChildPanelsMode;)V public synthetic fun (Lcom/arkivanov/decompose/Child$Created;Lcom/arkivanov/decompose/Child$Created;Lcom/arkivanov/decompose/Child$Created;Lcom/arkivanov/decompose/router/panels/ChildPanelsMode;ILkotlin/jvm/internal/DefaultConstructorMarker;)V @@ -334,6 +355,11 @@ public final class com/arkivanov/decompose/router/panels/PanelsNavigatorExtKt { public static synthetic fun setMode$default (Lcom/arkivanov/decompose/router/panels/PanelsNavigator;Lcom/arkivanov/decompose/router/panels/ChildPanelsMode;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)V } +public final class com/arkivanov/decompose/router/panels/PanelsWebNavigationKt { + public static final fun childPanelsWebNavigation (Lcom/arkivanov/decompose/router/panels/PanelsNavigator;Lcom/arkivanov/decompose/value/Value;Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/KSerializer;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;)Lcom/arkivanov/decompose/router/webhistory/WebNavigation; + public static synthetic fun childPanelsWebNavigation$default (Lcom/arkivanov/decompose/router/panels/PanelsNavigator;Lcom/arkivanov/decompose/value/Value;Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/KSerializer;Lkotlinx/serialization/KSerializer;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/arkivanov/decompose/router/webhistory/WebNavigation; +} + public final class com/arkivanov/decompose/router/slot/ChildSlot { public fun ()V public fun (Lcom/arkivanov/decompose/Child$Created;)V @@ -452,6 +478,11 @@ public final class com/arkivanov/decompose/router/stack/StackNavigatorExtKt { public static synthetic fun replaceCurrent$default (Lcom/arkivanov/decompose/router/stack/StackNavigator;Ljava/lang/Object;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)V } +public final class com/arkivanov/decompose/router/stack/StackWebNavigationKt { + public static final fun childStackWebNavigation (Lcom/arkivanov/decompose/router/stack/StackNavigator;Lcom/arkivanov/decompose/value/Value;Lkotlinx/serialization/KSerializer;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;)Lcom/arkivanov/decompose/router/webhistory/WebNavigation; + public static synthetic fun childStackWebNavigation$default (Lcom/arkivanov/decompose/router/stack/StackNavigator;Lcom/arkivanov/decompose/value/Value;Lkotlinx/serialization/KSerializer;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/arkivanov/decompose/router/webhistory/WebNavigation; +} + public final class com/arkivanov/decompose/router/stack/ValueExtKt { public static final fun getActive (Lcom/arkivanov/decompose/value/Value;)Lcom/arkivanov/decompose/Child$Created; public static final fun getBackStack (Lcom/arkivanov/decompose/value/Value;)Ljava/util/List; @@ -467,6 +498,33 @@ public final class com/arkivanov/decompose/router/stack/webhistory/WebHistoryCon public static synthetic fun attach$default (Lcom/arkivanov/decompose/router/stack/webhistory/WebHistoryController;Lcom/arkivanov/decompose/router/stack/StackNavigator;Lcom/arkivanov/decompose/value/Value;Lkotlinx/serialization/KSerializer;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)V } +public abstract interface class com/arkivanov/decompose/router/webhistory/WebNavigation { + public abstract fun getHistory ()Lcom/arkivanov/decompose/value/Value; + public abstract fun getSerializer ()Lkotlinx/serialization/KSerializer; + public abstract fun navigate (Ljava/util/List;)V + public abstract fun onBeforeNavigate ()Z +} + +public final class com/arkivanov/decompose/router/webhistory/WebNavigation$HistoryItem { + public fun (Ljava/lang/Object;Ljava/lang/String;Ljava/util/Map;Lcom/arkivanov/decompose/router/webhistory/WebNavigationOwner;)V + public final fun getChild ()Lcom/arkivanov/decompose/router/webhistory/WebNavigationOwner; + public final fun getKey ()Ljava/lang/Object; + public final fun getParameters ()Ljava/util/Map; + public final fun getPath ()Ljava/lang/String; +} + +public abstract interface class com/arkivanov/decompose/router/webhistory/WebNavigationOwner { + public abstract fun getWebNavigation ()Lcom/arkivanov/decompose/router/webhistory/WebNavigation; +} + +public abstract interface class com/arkivanov/decompose/router/webhistory/WebNavigationOwner$NoOp : com/arkivanov/decompose/router/webhistory/WebNavigationOwner { + public abstract fun getWebNavigation ()Lcom/arkivanov/decompose/router/webhistory/WebNavigation; +} + +public final class com/arkivanov/decompose/router/webhistory/WebNavigationOwner$NoOp$DefaultImpls { + public static fun getWebNavigation (Lcom/arkivanov/decompose/router/webhistory/WebNavigationOwner$NoOp;)Lcom/arkivanov/decompose/router/webhistory/WebNavigation; +} + public abstract class com/arkivanov/decompose/value/MutableValue : com/arkivanov/decompose/value/Value { public fun ()V public abstract fun compareAndSet (Ljava/lang/Object;Ljava/lang/Object;)Z diff --git a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/pages/Pages.kt b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/pages/Pages.kt index b692a6639..6f8954c56 100644 --- a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/pages/Pages.kt +++ b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/pages/Pages.kt @@ -1,5 +1,7 @@ package com.arkivanov.decompose.router.pages +import kotlinx.serialization.Serializable + /** * Represents a state of Child Pages navigation model. * @@ -7,6 +9,7 @@ package com.arkivanov.decompose.router.pages * @param selectedIndex an index of the selected child configuration. * Must be within the range of [items] indices if [items] is not empty, otherwise can be any number. */ +@Serializable data class Pages( val items: List, val selectedIndex: Int, diff --git a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/pages/PagesWebNavigation.kt b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/pages/PagesWebNavigation.kt new file mode 100644 index 000000000..76671233a --- /dev/null +++ b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/pages/PagesWebNavigation.kt @@ -0,0 +1,96 @@ +package com.arkivanov.decompose.router.pages + +import com.arkivanov.decompose.Child +import com.arkivanov.decompose.ExperimentalDecomposeApi +import com.arkivanov.decompose.router.webhistory.WebNavigation +import com.arkivanov.decompose.router.webhistory.WebNavigationOwner +import com.arkivanov.decompose.value.Value +import com.arkivanov.decompose.value.operator.map +import kotlinx.serialization.KSerializer + +/** + * Creates and returns an implementation of [WebNavigation] attached + * to the provided [navigator] and [pages]. The navigation observes page + * changes and manipulates the browser history to match the state. It also + * observes the browser history changes and navigates the pages as needed. + * + * The navigation only takes into account the currently selected page, replacing + * the current entry of the browser history when the selected page changes. + * Multiple entries can still be pushed/popped to/from the browser history at the + * same time if required to follow the nested navigation. + * + * @param navigator a [PagesNavigator] that should be used for navigation when + * the user presses the browser back and forward buttons. + * @param pages an observable [ChildPages] that should be bound to the browser + * navigation history. + * @param serializer a [KSerializer] of configurations [C]. + * @param pathMapper an optional function that returns a part of the URL path for + * the provided [ChildPages], or `null` (or empty string) if the path shouldn't be + * affected. The resulting URL path is constructed by concatenating all URL parts from + * every parent and child navigation, top to bottom. Default return value is `null`. + * @param parametersMapper an optional function that returns a [Map] containing all + * required URL parameters for the provided [ChildPages], or `null` (or empty Map) + * if parameters are not required. The resulting URL parameters string is constructed + * by merging all URL parameters from every parent and child navigation. Default + * return value is `null`. + * @param onBeforeNavigate an optional callback that can be used to allow (by + * returning `true`) or deny (by returning `false`) the browser-initiated navigation. + * Can be used e.g. to display an alert dialog before closing a screen. Returns `true` + * by default. + * @param childSelector an optional function that selects and returns a child + * [WebNavigationOwner] for the provided child. + */ +@ExperimentalDecomposeApi +fun childPagesWebNavigation( + navigator: PagesNavigator, + pages: Value>, + serializer: KSerializer, + pathMapper: (ChildPages) -> String? = { null }, + parametersMapper: (ChildPages) -> Map? = { null }, + onBeforeNavigate: () -> Boolean = { true }, + childSelector: (Child.Created) -> WebNavigationOwner? = { null }, +): WebNavigation<*> = + PagesWebNavigation( + navigator = navigator, + pages = pages, + serializer = serializer, + pathMapper = pathMapper, + parametersMapper = parametersMapper, + onBeforeNavigate = onBeforeNavigate, + childSelector = childSelector, + ) + +private class PagesWebNavigation( + private val navigator: PagesNavigator, + pages: Value>, + serializer: KSerializer, + private val pathMapper: (ChildPages) -> String?, + private val parametersMapper: (ChildPages) -> Map?, + private val onBeforeNavigate: () -> Boolean, + private val childSelector: (Child.Created) -> WebNavigationOwner?, +) : WebNavigation> { + + override val serializer: KSerializer> = Pages.serializer(serializer) + + override val history: Value>>> = + pages.map { pages -> + listOf( + WebNavigation.HistoryItem( + path = pathMapper(pages) ?: "", + parameters = parametersMapper(pages) ?: emptyMap(), + key = Pages( + items = pages.items.map { it.configuration }, + selectedIndex = pages.selectedIndex, + ), + child = (pages.items.getOrNull(pages.selectedIndex) as? Child.Created)?.let(childSelector), + ) + ) + } + + override fun onBeforeNavigate(): Boolean = + onBeforeNavigate.invoke() + + override fun navigate(history: List>) { + navigator.navigate { history.first() } + } +} diff --git a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/panels/PanelsWebNavigation.kt b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/panels/PanelsWebNavigation.kt new file mode 100644 index 000000000..0f54630ab --- /dev/null +++ b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/panels/PanelsWebNavigation.kt @@ -0,0 +1,116 @@ +package com.arkivanov.decompose.router.panels + +import com.arkivanov.decompose.ExperimentalDecomposeApi +import com.arkivanov.decompose.router.pages.PagesNavigator +import com.arkivanov.decompose.router.webhistory.WebNavigation +import com.arkivanov.decompose.router.webhistory.WebNavigation.HistoryItem +import com.arkivanov.decompose.router.webhistory.WebNavigationOwner +import com.arkivanov.decompose.value.Value +import com.arkivanov.decompose.value.operator.map +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable + +/** + * Creates and returns an implementation of [WebNavigation] attached + * to the provided [navigator] and [panels]. The navigation observes panel + * changes and manipulates the browser history to match the state. It also + * observes the browser history changes and navigates the panels as needed. + * + * The navigation replaces the current entry of the browser history when the + * [ChildPanels] state changes. Multiple entries can still be pushed/popped + * to/from the browser history at the same time if required to follow the nested + * navigation. + * + * @param navigator a [PagesNavigator] that should be used for navigation when + * the user presses the browser back and forward buttons. + * @param panels an observable [ChildPanels] that should be bound to the browser + * navigation history. + * @param mainSerializer a [KSerializer] of the `main` configuration [MC]. + * @param detailsSerializer a [KSerializer] of the `details` configuration [DC]. + * @param extraSerializer a [KSerializer] of the `extra` configuration [EC]. + * @param pathMapper an optional function that returns a part of the URL path for + * the provided [ChildPanels], or `null` (or empty string) if the path shouldn't be + * affected. The resulting URL path is constructed by concatenating all URL parts + * from every parent and child navigation, top to bottom. Default return value is `null`. + * @param parametersMapper an optional function that returns a [Map] containing all + * required URL parameters for the provided [ChildPanels], or `null` (or empty Map) + * if parameters are not required The resulting URL parameters string is constructed + * by merging all URL parameters from every parent and child navigation. Default + * return value is `null`. + * @param onBeforeNavigate an optional callback that can be used to allow (by + * returning `true`) or deny (by returning `false`) the browser-initiated navigation. + * Can be used e.g. to display an alert dialog before closing a screen. Returns `true` + * by default. + * @param childSelector an optional function that selects and returns a child + * [WebNavigationOwner] for the provided [ChildPanels]. + */ +@ExperimentalDecomposeApi +fun childPanelsWebNavigation( + navigator: PanelsNavigator, + panels: Value>, + mainSerializer: KSerializer, + detailsSerializer: KSerializer, + extraSerializer: KSerializer, + pathMapper: (ChildPanels) -> String? = { null }, + parametersMapper: (ChildPanels) -> Map? = { null }, + onBeforeNavigate: () -> Boolean = { true }, + childSelector: (ChildPanels) -> WebNavigationOwner? = { null }, +): WebNavigation<*> = + PanelsWebNavigation( + navigator = navigator, + panels = panels, + mainSerializer = mainSerializer, + detailsSerializer = detailsSerializer, + extraSerializer = extraSerializer, + pathMapper = pathMapper, + parametersMapper = parametersMapper, + onBeforeNavigate = onBeforeNavigate, + childSelector = childSelector, + ) + +private class PanelsWebNavigation( + private val navigator: PanelsNavigator, + panels: Value>, + mainSerializer: KSerializer, + detailsSerializer: KSerializer, + extraSerializer: KSerializer, + private val pathMapper: (ChildPanels) -> String?, + private val parametersMapper: (ChildPanels) -> Map?, + private val onBeforeNavigate: () -> Boolean, + private val childSelector: (ChildPanels) -> WebNavigationOwner?, +) : WebNavigation> { + + override val serializer: KSerializer> = + HistoryItemKey.serializer(mainSerializer, detailsSerializer, extraSerializer) + + override val history: Value>>> = + panels.map { panels -> + listOf( + HistoryItem( + path = pathMapper(panels) ?: "", + parameters = parametersMapper(panels) ?: emptyMap(), + key = HistoryItemKey( + main = panels.main.configuration, + extra = panels.extra?.configuration, + details = panels.details?.configuration, + ), + child = childSelector(panels), + ), + ) + } + + override fun onBeforeNavigate(): Boolean = + onBeforeNavigate.invoke() + + override fun navigate(history: List>) { + val data = history.single() + navigator.navigate(main = data.main, details = data.details, extra = data.extra) + } + + @Serializable + data class HistoryItemKey( + val main: MC, + val details: DC?, + val extra: EC?, + ) +} diff --git a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/stack/ChildStackFactory.kt b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/stack/ChildStackFactory.kt index b965512ab..e334160de 100644 --- a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/stack/ChildStackFactory.kt +++ b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/stack/ChildStackFactory.kt @@ -30,7 +30,7 @@ import kotlinx.serialization.builtins.ListSerializer * @param childFactory a factory function that creates new child instances. * @return an observable [Value] of [ChildStack]. */ -fun , C : Any, T : Any> Ctx.childStack( +fun , C : Any, T : Any> Ctx.childStack( source: NavigationSource>, serializer: KSerializer?, initialStack: () -> List, diff --git a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/stack/StackWebNavigation.kt b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/stack/StackWebNavigation.kt new file mode 100644 index 000000000..2314465a6 --- /dev/null +++ b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/stack/StackWebNavigation.kt @@ -0,0 +1,109 @@ +package com.arkivanov.decompose.router.stack + +import com.arkivanov.decompose.Child +import com.arkivanov.decompose.ExperimentalDecomposeApi +import com.arkivanov.decompose.router.webhistory.WebNavigation +import com.arkivanov.decompose.router.webhistory.WebNavigation.HistoryItem +import com.arkivanov.decompose.router.webhistory.WebNavigationOwner +import com.arkivanov.decompose.value.Value +import com.arkivanov.decompose.value.operator.map +import kotlinx.serialization.KSerializer + +/** + * Creates and returns an implementation of [WebNavigation] attached + * to the provided [navigator] and [stack]. The navigation observes stack + * changes and manipulates the browser history to match the state. It also + * observes the browser history changes and navigates the stack as needed. + * + * When [enableHistory] parameter is `true`, the browser history follows + * the entire stack, taking the back stack into account. E.g. the navigation + * pushes an entry to the browser history when a child is pushed to the stack, + * pops an entry from the browser history when a child is popped from the stack, + * replaces the current entry of the browser history when the active child of + * the stack is changed. Multiple entries can be pushed/popped to/from the + * browser history at the same time if required to follow the stack. + * + * When [enableHistory] parameter is `false`, the browser history only takes + * into account the currently active child of the stack. Multiple entries can + * still be pushed/popped to/from the browser history at the same time if + * required to follow the nested navigation. This mode can be useful for bottom + * tab navigation. + * + * @param navigator a [StackNavigator] that should be used for navigation when + * the user presses the browser back and forward buttons. + * @param stack an observable [ChildStack] that should be bound to the browser + * navigation history. + * @param serializer a [KSerializer] of configurations [C]. + * @param enableHistory a flag that enabled or disables the back stack history. + * When `true`, the browser navigation history follows the entire stack. + * When `false`, the browser navigation history follows only the currently + * active child (i.e. the back stack will not be taken into account), can be useful + * for bottom tab navigation. Default value is `true`. + * @param pathMapper an optional function that returns a part of the URL path for + * the provided child, or `null` (or empty string) if the path shouldn't be affected. + * The resulting URL path is constructed by concatenating all URL parts from every + * parent and child navigation, top to bottom. Default return value is `null`. + * @param parametersMapper an optional function that returns a [Map] containing all + * required URL parameters for the provided child, or `null` (or empty Map) if + * parameters are not required. The resulting URL parameters string is constructed by + * merging all URL parameters from every parent and child navigation. Default return + * value is `null`. + * @param onBeforeNavigate an optional callback that can be used to allow (by + * returning `true`) or deny (by returning `false`) the browser-initiated navigation. + * Can be used e.g. to display an alert dialog before closing a screen. Returns `true` + * by default. + * @param childSelector an optional function that selects and returns a child + * [WebNavigationOwner] for the provided child. + */ +@ExperimentalDecomposeApi +fun childStackWebNavigation( + navigator: StackNavigator, + stack: Value>, + serializer: KSerializer, + enableHistory: Boolean = true, + pathMapper: (Child.Created) -> String? = { null }, + parametersMapper: (Child.Created) -> Map? = { null }, + onBeforeNavigate: () -> Boolean = { true }, + childSelector: (Child.Created) -> WebNavigationOwner? = { null }, +): WebNavigation<*> = + StackWebNavigation( + navigator = navigator, + stack = stack, + serializer = serializer, + enableHistory = enableHistory, + pathMapper = pathMapper, + parametersMapper = parametersMapper, + onBeforeNavigate = onBeforeNavigate, + childSelector = childSelector, + ) + +private class StackWebNavigation( + private val navigator: StackNavigator, + stack: Value>, + override val serializer: KSerializer, + private val enableHistory: Boolean, + private val pathMapper: (Child.Created) -> String?, + private val parametersMapper: (Child.Created) -> Map?, + private val onBeforeNavigate: () -> Boolean, + private val childSelector: (Child.Created) -> WebNavigationOwner?, +) : WebNavigation { + + override val history: Value>> = stack.map { it.toHistory() } + + private fun ChildStack.toHistory(): List> = + (if (enableHistory) items else items.takeLast(1)).map { child -> + HistoryItem( + path = pathMapper(child) ?: "", + parameters = parametersMapper(child) ?: emptyMap(), + key = child.configuration, + child = childSelector(child), + ) + } + + override fun onBeforeNavigate(): Boolean = + onBeforeNavigate.invoke() + + override fun navigate(history: List) { + navigator.navigate { history } + } +} diff --git a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/webhistory/NoOpWebNavigation.kt b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/webhistory/NoOpWebNavigation.kt new file mode 100644 index 000000000..f855e75d8 --- /dev/null +++ b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/webhistory/NoOpWebNavigation.kt @@ -0,0 +1,22 @@ +package com.arkivanov.decompose.router.webhistory + +import com.arkivanov.decompose.router.webhistory.WebNavigation.HistoryItem +import com.arkivanov.decompose.value.MutableValue +import com.arkivanov.decompose.value.Value +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.builtins.NothingSerializer + +internal object NoOpWebNavigation : WebNavigation { + + @OptIn(ExperimentalSerializationApi::class) + override val serializer: KSerializer get() = NothingSerializer() + + override val history: Value>> = MutableValue(emptyList()) + + override fun onBeforeNavigate(): Boolean = true + + override fun navigate(history: List) { + // No-op + } +} diff --git a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/webhistory/WebNavigation.kt b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/webhistory/WebNavigation.kt new file mode 100644 index 000000000..c30b4834b --- /dev/null +++ b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/webhistory/WebNavigation.kt @@ -0,0 +1,63 @@ +package com.arkivanov.decompose.router.webhistory + +import com.arkivanov.decompose.ExperimentalDecomposeApi +import com.arkivanov.decompose.value.Value +import kotlinx.serialization.KSerializer + +/** + * A two-way navigation controller for Web browsers that connects a navigation + * model (e.g. `Child Stack`) with the Web browser's navigation history. + */ +@ExperimentalDecomposeApi +interface WebNavigation { + + /** + * A serializer of [HistoryItem.key]. + */ + val serializer: KSerializer + + /** + * An observable list of history items, must not be empty. The last item is + * considered active, i.e. its [HistoryItem.child] navigation controller is + * also observed. The browser history is automatically updated (pushed, + * popped or replaced) when the history list changes. + */ + val history: Value>> + + /** + * A callback that can be used to allow (by returning `true`) or deny (by + * returning `false`) the browser-initiated navigation. Can be used e.g. to + * display an alert dialog before closing a screen. + */ + fun onBeforeNavigate(): Boolean + + /** + * Manipulates the underlying navigation model when the browser navigation history + * is changed, e.g. when the user presses the browser's back or forward button. + * + * @param history a list of history item keys to be applied to the underlying + * navigation model. + */ + fun navigate(history: List) + + /** + * Represents an item of the browser history. + * + * @param key a key of the item that can be serialized using + * [WebNavigation.serializer] and later restored and passed back to + * [WebNavigation.navigate] for navigation. + * @param path a part of the URL path. The resulting URL path is constructed by + * concatenating all URL parts from every parent and child navigation, top to bottom. + * Can be empty. + * @param parameters a [Map] containing all required URL parameters for the provided + * item. The resulting URL parameters string is constructed by merging all URL + * parameters from every parent and child navigation. Can be empty. + * @param child an optional child [WebNavigationOwner]. + */ + class HistoryItem( + val key: T, + val path: String, + val parameters: Map, + val child: WebNavigationOwner?, + ) +} diff --git a/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/webhistory/WebNavigationOwner.kt b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/webhistory/WebNavigationOwner.kt new file mode 100644 index 000000000..86ba3ba77 --- /dev/null +++ b/decompose/src/commonMain/kotlin/com/arkivanov/decompose/router/webhistory/WebNavigationOwner.kt @@ -0,0 +1,21 @@ +package com.arkivanov.decompose.router.webhistory + +import com.arkivanov.decompose.ExperimentalDecomposeApi + +/** + * Represents a holder of [WebNavigation], typically implemented by + * a Decompose component. + */ +@ExperimentalDecomposeApi +interface WebNavigationOwner { + + val webNavigation: WebNavigation<*> + + /** + * A no-op interface that extends [WebNavigationOwner]. Can be useful + * for fake or preview component implementations. + */ + interface NoOp : WebNavigationOwner { + override val webNavigation: WebNavigation<*> get() = NoOpWebNavigation + } +} diff --git a/decompose/src/jsMain/kotlin/com/arkivanov/decompose/router/webhistory/WebHistoryNavigation.kt b/decompose/src/jsMain/kotlin/com/arkivanov/decompose/router/webhistory/WebHistoryNavigation.kt new file mode 100644 index 000000000..969e729a4 --- /dev/null +++ b/decompose/src/jsMain/kotlin/com/arkivanov/decompose/router/webhistory/WebHistoryNavigation.kt @@ -0,0 +1,49 @@ +package com.arkivanov.decompose.router.webhistory + +import com.arkivanov.decompose.ExperimentalDecomposeApi +import com.arkivanov.decompose.JsonString +import com.arkivanov.decompose.decodeContainer +import com.arkivanov.decompose.encodeToJson +import com.arkivanov.essenty.statekeeper.StateKeeper +import com.arkivanov.essenty.statekeeper.StateKeeperDispatcher +import kotlinx.browser.sessionStorage +import kotlinx.browser.window +import org.w3c.dom.get +import org.w3c.dom.set + +/** + * Enables Web browser history navigation for the root [WebNavigationOwner] + * returned by [block] function. + * + * @param block a function that accepts a [StateKeeper] and an optional deep + * link string and creates and returns a root [WebNavigationOwner]. + */ +@ExperimentalDecomposeApi +fun withWebHistory( + block: (StateKeeper, deepLink: String?) -> T, +): T { + val deepLink = window.location.href.takeUnless { it == sessionStorage[KEY_SAVED_URL] } + + val stateKeeper = + StateKeeperDispatcher( + savedState = sessionStorage[KEY_SAVED_STATE] + ?.takeIf { deepLink == null } + ?.let(::JsonString) + ?.decodeContainer(), + ) + + window.onbeforeunload = + { + sessionStorage[KEY_SAVED_STATE] = stateKeeper.save().encodeToJson().value + sessionStorage[KEY_SAVED_URL] = window.location.href + null + } + + val root = block(stateKeeper, deepLink) + enableWebHistory(root.webNavigation, DefaultBrowserHistory) + + return root +} + +private const val KEY_SAVED_STATE = "decompose_saved_state" +private const val KEY_SAVED_URL = "decompose_saved_url" diff --git a/decompose/src/wasmJsMain/kotlin/com/arkivanov/decompose/router/webhistory/WebHistoryNavigation.kt b/decompose/src/wasmJsMain/kotlin/com/arkivanov/decompose/router/webhistory/WebHistoryNavigation.kt new file mode 100644 index 000000000..969e729a4 --- /dev/null +++ b/decompose/src/wasmJsMain/kotlin/com/arkivanov/decompose/router/webhistory/WebHistoryNavigation.kt @@ -0,0 +1,49 @@ +package com.arkivanov.decompose.router.webhistory + +import com.arkivanov.decompose.ExperimentalDecomposeApi +import com.arkivanov.decompose.JsonString +import com.arkivanov.decompose.decodeContainer +import com.arkivanov.decompose.encodeToJson +import com.arkivanov.essenty.statekeeper.StateKeeper +import com.arkivanov.essenty.statekeeper.StateKeeperDispatcher +import kotlinx.browser.sessionStorage +import kotlinx.browser.window +import org.w3c.dom.get +import org.w3c.dom.set + +/** + * Enables Web browser history navigation for the root [WebNavigationOwner] + * returned by [block] function. + * + * @param block a function that accepts a [StateKeeper] and an optional deep + * link string and creates and returns a root [WebNavigationOwner]. + */ +@ExperimentalDecomposeApi +fun withWebHistory( + block: (StateKeeper, deepLink: String?) -> T, +): T { + val deepLink = window.location.href.takeUnless { it == sessionStorage[KEY_SAVED_URL] } + + val stateKeeper = + StateKeeperDispatcher( + savedState = sessionStorage[KEY_SAVED_STATE] + ?.takeIf { deepLink == null } + ?.let(::JsonString) + ?.decodeContainer(), + ) + + window.onbeforeunload = + { + sessionStorage[KEY_SAVED_STATE] = stateKeeper.save().encodeToJson().value + sessionStorage[KEY_SAVED_URL] = window.location.href + null + } + + val root = block(stateKeeper, deepLink) + enableWebHistory(root.webNavigation, DefaultBrowserHistory) + + return root +} + +private const val KEY_SAVED_STATE = "decompose_saved_state" +private const val KEY_SAVED_URL = "decompose_saved_url" diff --git a/decompose/src/webMain/kotlin/com/arkivanov/decompose/Utils.kt b/decompose/src/webMain/kotlin/com/arkivanov/decompose/Utils.kt index b79d0ccf4..3bbd70bb4 100644 --- a/decompose/src/webMain/kotlin/com/arkivanov/decompose/Utils.kt +++ b/decompose/src/webMain/kotlin/com/arkivanov/decompose/Utils.kt @@ -1,8 +1,25 @@ package com.arkivanov.decompose +import com.arkivanov.essenty.statekeeper.SerializableContainer import kotlinx.serialization.json.Json internal val Json = Json { allowStructuredMapKeys = true } + +internal fun SerializableContainer.encodeToJson(): JsonString = + JsonString(Json.encodeToString(SerializableContainer.serializer(), this)) + +internal fun JsonString.decodeContainer(): SerializableContainer? = + try { + Json.decodeFromString(SerializableContainer.serializer(), value) + } catch (e: Exception) { + null + } + +internal value class JsonString(val value: String) + +internal external fun encodeURIComponent(str: String): String + +internal external fun decodeURIComponent(str: String): String diff --git a/decompose/src/webMain/kotlin/com/arkivanov/decompose/router/stack/Utils.kt b/decompose/src/webMain/kotlin/com/arkivanov/decompose/router/stack/Utils.kt index 758a71b2e..8dfa3cfb0 100644 --- a/decompose/src/webMain/kotlin/com/arkivanov/decompose/router/stack/Utils.kt +++ b/decompose/src/webMain/kotlin/com/arkivanov/decompose/router/stack/Utils.kt @@ -1,5 +1,6 @@ package com.arkivanov.decompose.router.stack +import com.arkivanov.decompose.Cancellation import com.arkivanov.decompose.value.Value import kotlin.math.min @@ -32,9 +33,10 @@ internal fun List.findFirstDifferentIndex(other: List): Int { return i } -internal fun Value.subscribe(observer: (new: T, old: T) -> Unit) { +internal fun Value.subscribe(observer: (new: T, old: T) -> Unit): Cancellation { var old = value - subscribe { new -> + + return subscribe callback@{ new -> val tmp = old old = new observer(new, tmp) diff --git a/decompose/src/webMain/kotlin/com/arkivanov/decompose/router/webhistory/WebHistoryNavigation.kt b/decompose/src/webMain/kotlin/com/arkivanov/decompose/router/webhistory/WebHistoryNavigation.kt new file mode 100644 index 000000000..1521d97bb --- /dev/null +++ b/decompose/src/webMain/kotlin/com/arkivanov/decompose/router/webhistory/WebHistoryNavigation.kt @@ -0,0 +1,363 @@ +package com.arkivanov.decompose.router.webhistory + +import com.arkivanov.decompose.Cancellation +import com.arkivanov.decompose.Json +import com.arkivanov.decompose.encodeURIComponent +import com.arkivanov.decompose.router.stack.startsWith +import com.arkivanov.decompose.router.stack.subscribe +import com.arkivanov.decompose.router.webhistory.WebNavigation.HistoryItem +import com.arkivanov.essenty.statekeeper.SerializableContainer +import com.arkivanov.essenty.statekeeper.consumeRequired +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.ListSerializer + +internal fun enableWebHistory(navigation: WebNavigation, browserHistory: BrowserHistory) { + if (browserHistory.state == null) { + browserHistory.replaceState(navigation.nodeHistory()) + } + + var isEnabled = true + + fun onPopState(state: String?) { + val deserializedState = state?.deserializeState() ?: return + + if (navigation.onBeforeNavigateRecursive()) { + isEnabled = false + navigation.navigate(deserializedState.nodesContainer.consumeNodes()) + isEnabled = true + } else { + val delta = navigation.history().lastIndex - deserializedState.index + if (delta != 0) { + browserHistory.setOnPopStateListener { browserHistory.setOnPopStateListener(::onPopState) } + browserHistory.go(delta) + } + } + } + + browserHistory.setOnPopStateListener(::onPopState) + + navigation.subscribe( + isEnabled = { isEnabled }, + onPush = { history -> + history.forEach(browserHistory::pushState) + }, + onPop = { count, nodes -> + val popCount = count.coerceAtMost(browserHistory.currentIndex()) + + if (popCount > 0) { + browserHistory.setOnPopStateListener { + browserHistory.setOnPopStateListener(::onPopState) + + if (popCount < count) { + browserHistory.replaceState(nodes) + } + } + + browserHistory.go(-popCount) + } else { + browserHistory.replaceState(nodes) + } + }, + onRewrite = { oldSize, newHistory -> + if (oldSize > 1) { + browserHistory.setOnPopStateListener { + browserHistory.setOnPopStateListener(::onPopState) + browserHistory.replaceState(newHistory.first()) + newHistory.drop(1).forEach(browserHistory::pushState) + } + + browserHistory.go(-oldSize + 1) + } else { + browserHistory.replaceState(newHistory.first()) + newHistory.drop(1).forEach(browserHistory::pushState) + } + }, + onUpdateUrl = browserHistory::replaceState + ) +} + +private fun WebNavigation.onBeforeNavigateRecursive(): Boolean { + val isChildAllowed = history.value.lastOrNull()?.child?.webNavigation?.onBeforeNavigateRecursive() ?: true + + return if (isChildAllowed) onBeforeNavigate() else false +} + +private fun WebNavigation.navigate(nodes: List) { + val items: List = nodes.map { it.data.consumeRequired(serializer) } + navigate(items) + + history.value.forEachIndexed { index, item -> + item.child?.webNavigation?.navigate(nodes[index].children) + } +} + +private fun BrowserHistory.replaceState(nodes: NodeHistory<*>) { + replaceState( + data = serializeState(index = currentIndex(), nodes = nodes), + url = nodes.last().url(), + ) +} + +private fun BrowserHistory.pushState(nodes: NodeHistory<*>) { + pushState( + data = serializeState(index = currentIndex() + 1, nodes = nodes), + url = nodes.last().url() + ) +} + +private fun BrowserHistory.currentIndex(): Int = + state?.deserializeState()?.index ?: 0 + +private fun Node.toSerializableNode(): SerializableNode = + SerializableNode( + data = SerializableContainer(value = key, strategy = serializer), + children = children.map { it.toSerializableNode() }, + ) + +private fun HistoryState.serialize(): String = + Json.encodeToString(serializer = HistoryState.serializer(), value = this) + +private fun String.deserializeState(): HistoryState = + Json.decodeFromString(deserializer = HistoryState.serializer(), string = this) + +private fun SerializableContainer.consumeNodes(): List = + consumeRequired(SerializableNode.listSerializer) + +private fun serializeState( + index: Int, + nodes: NodeHistory<*>, +): String = + HistoryState(index = index, nodes = nodes.map { it.toSerializableNode() }).serialize() + +private fun WebNavigation.subscribe( + isEnabled: () -> Boolean, + onPush: (List>) -> Unit, + onPop: (count: Int, NodeHistory) -> Unit, + onRewrite: (oldSize: Int, newHistory: List>) -> Unit, + onUpdateUrl: (NodeHistory) -> Unit, +): Cancellation { + var activeChildCancellation: Cancellation? = null + var isInitial = true + + return history.subscribe { newHistory, oldHistory -> + activeChildCancellation?.cancel() + + check(newHistory.isNotEmpty()) + + if (!isInitial && isEnabled()) { + onHistoryChanged(newHistory, oldHistory, onPush, onPop, onRewrite, onUpdateUrl) + } + + isInitial = false + + val activeItem = newHistory.last() + val inactiveNodes = newHistory.dropLast(1).map(::nodeTreeOf) + + activeChildCancellation = + activeItem.child?.webNavigation?.subscribe( + isEnabled = isEnabled, + onPush = { childHistory -> + onPush(childHistory.map { childNodes -> inactiveNodes + nodeOf(item = activeItem, children = childNodes) }) + }, + onPop = { count, childNodes -> + onPop(count, inactiveNodes + nodeOf(item = activeItem, children = childNodes)) + }, + onRewrite = { oldSize, childHistory -> + onRewrite( + oldSize, + childHistory.map { childNodes -> inactiveNodes + nodeOf(item = activeItem, children = childNodes) }, + ) + }, + onUpdateUrl = { childNodes -> + onUpdateUrl(inactiveNodes + nodeOf(item = activeItem, children = childNodes)) + }, + ) + } +} + +private fun WebNavigation.onHistoryChanged( + newHistory: List>, + oldHistory: List>, + onPush: (List>) -> Unit, + onPop: (count: Int, NodeHistory) -> Unit, + onRewrite: (oldSize: Int, newHistory: List>) -> Unit, + onUpdateUrl: (NodeHistory) -> Unit, +) { + val newKeys = newHistory.map { it.key } + val oldKeys = oldHistory.map { it.key } + + when { + newKeys == oldKeys -> { // History is not changed, but path or parameters might be + onUpdateUrl(newHistory.map(::nodeTreeOf)) + } + + newKeys.startsWith(oldKeys) -> { // Items pushed + val newItems = newHistory.takeLast(newHistory.size - oldHistory.size) + val historyChange = ArrayList>() + val previousNodes = oldHistory.mapTo(ArrayList(), ::nodeTreeOf) + + newItems.forEach { item -> + val itemHistory = historyOf(item) + itemHistory.forEach { + historyChange += previousNodes + it + } + previousNodes += itemHistory.last() + } + + onPush(historyChange) + } + + oldKeys.startsWith(newKeys) -> { // Items popped + val oldPaths = oldHistory.takeLast(oldHistory.size - newHistory.size).flatMap(::historyOf) + onPop(oldPaths.size, newHistory.map(::nodeTreeOf)) + } + + else -> { // Rewriting the history + val historyChange = ArrayList>() + val previousNodes = ArrayList>() + + newHistory.forEach { item -> + val itemHistory = historyOf(item) + itemHistory.forEach { + historyChange += previousNodes + it + } + previousNodes += itemHistory.last() + } + + val oldPaths = oldHistory.flatMap(::historyOf) + + onRewrite(oldPaths.size, historyChange) + } + } +} + +private fun Node<*>.url(): String { + val path = path() + val parameters = parameters() + + return when { + path.isNotEmpty() && parameters.isNotEmpty() -> "$path?$parameters" + path.isNotEmpty() -> path + parameters.isNotEmpty() -> parameters + else -> "" + } +} + +private fun Node<*>.path(): String { + val segments = ArrayList() + collectPath(segments) + + return segments.joinToString(prefix = "/", separator = "/") +} + +private fun Node<*>.collectPath(segments: MutableList) { + if (path.isNotEmpty()) { + segments += path.trim('/') + } + + children.lastOrNull()?.collectPath(segments) +} + +private fun Node<*>.parameters(): String { + val parameters = LinkedHashMap() + collectParameters(parameters) + + return parameters.entries.joinToString(separator = "&") { (name, value) -> + "${encodeURIComponent(name)}=${encodeURIComponent(value)}" + } +} + +private fun Node<*>.collectParameters(parameters: MutableMap) { + parameters += this.parameters + children.lastOrNull()?.collectParameters(parameters) +} + +private fun WebNavigation.nodeTreeOf(item: HistoryItem): Node = + Node( + item = item, + serializer = serializer, + children = item.child?.webNavigation?.nodeHistory() ?: emptyList(), + ) + +private fun WebNavigation.nodeHistory(): NodeHistory { + val items = history.value + check(items.isNotEmpty()) + + return items.map(::nodeTreeOf) +} + +private fun WebNavigation.nodeOf( + item: HistoryItem, + children: NodeHistory<*> = emptyList(), +): Node = + Node( + item = item, + serializer = serializer, + children = children, + ) + +private fun WebNavigation.historyOf(item: HistoryItem): NodeHistory = + item.child + ?.webNavigation + ?.history() + ?.map { Node(item = item, serializer = serializer, children = it) } + ?: listOf(Node(item = item, serializer = serializer)) + +private fun WebNavigation.history(): List> { + val nodes = ArrayList>() + val historyNodes = ArrayList>() + history.value.forEach { item -> + historyOf(item).forEach { node -> + historyNodes += node + nodes += historyNodes.toList() + } + } + + return nodes +} + +@Serializable +private data class SerializableNode( + val data: SerializableContainer, + val children: List, +) { + companion object { + val listSerializer: KSerializer> by lazy { ListSerializer(serializer()) } + } +} + +private data class Node( + val path: String, + val parameters: Map, + val key: T, + val serializer: KSerializer, + val children: NodeHistory<*>, +) + +private fun Node( + item: HistoryItem, + serializer: KSerializer, + children: NodeHistory<*> = emptyList(), +): Node = + Node( + path = item.path, + parameters = item.parameters, + key = item.key, + serializer = serializer, + children = children, + ) + +private typealias NodeHistory = List> + +@Serializable +private class HistoryState( + val index: Int, + val nodesContainer: SerializableContainer, +) + +private fun HistoryState(index: Int, nodes: List): HistoryState = + HistoryState( + index = index, + nodesContainer = SerializableContainer(value = nodes, strategy = SerializableNode.listSerializer), + ) diff --git a/decompose/src/webTest/kotlin/com/arkivanov/decompose/UrlEncodeTest.kt b/decompose/src/webTest/kotlin/com/arkivanov/decompose/UrlEncodeTest.kt new file mode 100644 index 000000000..537f9bdcc --- /dev/null +++ b/decompose/src/webTest/kotlin/com/arkivanov/decompose/UrlEncodeTest.kt @@ -0,0 +1,16 @@ +package com.arkivanov.decompose + +import kotlin.test.Test +import kotlin.test.assertEquals + +class UrlEncodeTest { + + @Test + fun encode_decode() { + val original = "asd кек" + + val decoded = decodeURIComponent(encodeURIComponent(original)) + + assertEquals(original, decoded) + } +} diff --git a/decompose/src/webTest/kotlin/com/arkivanov/decompose/router/webhistory/TestWebNavigation.kt b/decompose/src/webTest/kotlin/com/arkivanov/decompose/router/webhistory/TestWebNavigation.kt new file mode 100644 index 000000000..944e14b10 --- /dev/null +++ b/decompose/src/webTest/kotlin/com/arkivanov/decompose/router/webhistory/TestWebNavigation.kt @@ -0,0 +1,89 @@ +package com.arkivanov.decompose.router.webhistory + +import com.arkivanov.decompose.router.webhistory.WebNavigation.HistoryItem +import com.arkivanov.decompose.value.MutableValue +import com.arkivanov.decompose.value.Value +import kotlinx.serialization.KSerializer +import kotlinx.serialization.builtins.serializer +import kotlin.test.assertContentEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class TestWebNavigation( + initialHistory: List, + private val onBeforeNavigate: () -> Boolean = { true }, + private val childFactory: (Int) -> TestWebNavigation? = { null }, +) : WebNavigation { + + override val serializer: KSerializer = Int.serializer() + + private val _history = MutableValue>>(emptyList()) + override val history: Value>> = _history + + private var children = HashMap() + + init { + navigate(initialHistory) + } + + fun requireChild(config: Int): TestWebNavigation = + requireNotNull(children[config]) + + override fun onBeforeNavigate(): Boolean = + onBeforeNavigate.invoke() + + override fun navigate(history: List) { + children.keys.removeAll { it !in history } + + history.forEach { config -> + if (config !in children) { + val child = childFactory(config) + if (child != null) { + children[config] = child + } + } + } + + _history.value = + history.map { config -> + HistoryItem( + key = config, + path = config.toString(), + parameters = emptyMap(), + child = children[config]?.let(TestWebNavigation::Owner), + ) + } + } + + fun assertHistory(configs: Iterable) { + assertContentEquals(configs, _history.value.map { it.key }) + } + + fun assertHistory(urls: List) { + val configToChildUrlsMap = + urls + .map { it.removePrefix("/") } + .groupBy( + keySelector = { it.substringBefore(delimiter = "/").toInt() }, + valueTransform = { it.substringAfter(delimiter = "/", missingDelimiterValue = "") }, + ) + .mapValues { (_, childUrls) -> childUrls.filterNot(String::isEmpty) } + + assertHistory(configToChildUrlsMap.keys) + + configToChildUrlsMap.entries.forEachIndexed { index, (_, childUrls) -> + val child = _history.value[index].child?.webNavigation as TestWebNavigation? + if (childUrls.isEmpty()) { + assertNull(child) + } else { + assertNotNull(child).assertHistory(childUrls) + } + } + } + + private class Owner(override val webNavigation: TestWebNavigation) : WebNavigationOwner { + override fun toString(): String { + return webNavigation.toString() + } + } +} diff --git a/decompose/src/webTest/kotlin/com/arkivanov/decompose/router/webhistory/WebHistoryNavigationTest.kt b/decompose/src/webTest/kotlin/com/arkivanov/decompose/router/webhistory/WebHistoryNavigationTest.kt new file mode 100644 index 000000000..4914e8934 --- /dev/null +++ b/decompose/src/webTest/kotlin/com/arkivanov/decompose/router/webhistory/WebHistoryNavigationTest.kt @@ -0,0 +1,768 @@ +package com.arkivanov.decompose.router.webhistory + +import kotlin.test.Test +import kotlin.test.assertFailsWith + +@Suppress("TestFunctionName") +class WebHistoryNavigationTest { + + private val history = TestBrowserHistory() + + @Test + fun WHEN_created_with_no_items_in_root_THEN_ISE_thrown() { + val nav = TestWebNavigation(initialHistory = emptyList()) + + assertFailsWith { + enableWebHistory(nav, history) + } + } + + @Test + fun WHEN_created_with_no_items_in_child_THEN_ISE_thrown() { + val nav = + TestWebNavigation( + initialHistory = listOf(1), + childFactory = { config -> + when (config) { + 1 -> TestWebNavigation(initialHistory = emptyList()) + else -> null + } + }, + ) + + assertFailsWith { + enableWebHistory(nav, history) + } + } + + @Test + fun WHEN_removed_all_items_from_child_THEN_ISE_thrown() { + val nav = + TestWebNavigation( + initialHistory = listOf(1), + childFactory = { config -> + when (config) { + 1 -> TestWebNavigation(initialHistory = listOf(11)) + else -> null + } + }, + ) + + enableWebHistory(nav, history) + + assertFailsWith { + nav.requireChild(config = 1).navigate(emptyList()) + } + } + + @Test + fun WHEN_created_with_one_item_THEN_one_item_in_browser_history() { + val nav = TestWebNavigation(initialHistory = listOf(1)) + enableWebHistory(nav, history) + + assertHistory(nav = nav, urls = listOf("/1")) + } + + @Test + fun WHEN_created_with_two_items_THEN_one_item_in_browser_history() { + val nav = TestWebNavigation(initialHistory = listOf(1, 2)) + enableWebHistory(nav, history) + + history.assertStack(urls = listOf("/2")) + nav.assertHistory(listOf(1, 2)) + } + + @Test + fun WHEN_pushed_one_item_THEN_two_items_in_browser_history() { + val nav = TestWebNavigation(initialHistory = listOf(1)) + enableWebHistory(nav, history) + nav.navigate(listOf(1, 2)) + + assertHistory(nav = nav, urls = listOf("/1", "/2")) + } + + @Test + fun WHEN_pushed_one_item_and_popped_one_item_THEN_two_items_in_browser_history_and_first_item_active() { + val nav = TestWebNavigation(initialHistory = listOf(1)) + enableWebHistory(nav, history) + nav.navigate(listOf(1, 2)) + nav.navigate(listOf(1)) + history.runPendingOperations() + + assertHistory(nav = nav, urls = listOf("/1", "/2"), index = 0) + } + + @Test + fun WHEN_pushed_one_item_and_popped_one_item_and_pushed_another_item_THEN_two_items_in_browser_history() { + val nav = TestWebNavigation(initialHistory = listOf(1)) + enableWebHistory(nav, history) + nav.navigate(listOf(1, 2)) + nav.navigate(listOf(1)) + history.runPendingOperations() + nav.navigate(listOf(1, 3)) + + assertHistory(nav = nav, urls = listOf("/1", "/3")) + } + + @Test + fun WHEN_pushed_two_items_and_popped_two_items_THEN_three_items_in_browser_history_and_first_item_active() { + val nav = TestWebNavigation(initialHistory = listOf(1)) + enableWebHistory(nav, history) + nav.navigate(listOf(1, 2, 3)) + nav.navigate(listOf(1)) + history.runPendingOperations() + + assertHistory(nav = nav, urls = listOf("/1", "/2", "/3"), index = 0) + } + + @Test + fun WHEN_pushed_two_items_and_popped_two_items_and_pushed_another_item_THEN_two_items_in_browser_history() { + val nav = TestWebNavigation(initialHistory = listOf(1)) + enableWebHistory(nav, history) + nav.navigate(listOf(1, 2, 3)) + nav.navigate(listOf(1)) + history.runPendingOperations() + nav.navigate(listOf(1, 4)) + + assertHistory(nav = nav, urls = listOf("/1", "/4")) + } + + @Test + fun WHEN_pushed_one_item_and_history_go_back_one_item_THEN_two_items_in_browser_history_and_first_item_active() { + val nav = TestWebNavigation(initialHistory = listOf(1)) + enableWebHistory(nav, history) + nav.navigate(listOf(1, 2)) + history.navigate(delta = -1) + + assertHistory(nav = nav, urls = listOf("/1", "/2"), index = 0) + } + + @Test + fun WHEN_pushed_one_item_and_history_go_back_one_item_and_history_go_forward_one_item_THEN_two_items_in_browser_history() { + val nav = TestWebNavigation(initialHistory = listOf(1)) + enableWebHistory(nav, history) + nav.navigate(listOf(1, 2)) + history.navigate(delta = -1) + history.navigate(delta = 1) + + assertHistory(nav = nav, urls = listOf("/1", "/2")) + } + + @Test + fun WHEN_pushed_one_item_and_popped_one_item_and_history_go_forward_one_item_THEN_two_items_in_browser_history() { + val nav = TestWebNavigation(initialHistory = listOf(1)) + enableWebHistory(nav, history) + nav.navigate(listOf(1, 2)) + nav.navigate(listOf(1)) + history.runPendingOperations() + history.navigate(delta = 1) + + assertHistory(nav = nav, urls = listOf("/1", "/2")) + } + + @Test + fun WHEN_created_with_one_root_item_and_one_child_item_and_child_pushed_one_item_THEN_two_items_in_browser_history() { + val nav = + TestWebNavigation( + initialHistory = listOf(1), + childFactory = { config -> + when (config) { + 1 -> TestWebNavigation(initialHistory = listOf(11)) + else -> null + } + }, + ) + + enableWebHistory(nav, history) + nav.requireChild(config = 1).navigate(listOf(11, 22)) + + assertHistory(nav = nav, urls = listOf("/1/11", "/1/22")) + } + + @Test + fun WHEN_created_with_one_root_item_and_one_child_item_and_child_pushed_one_item_and_child_popped_one_item_THEN_two_items_in_browser_history_and_first_item_active() { + val nav = + TestWebNavigation( + initialHistory = listOf(1), + childFactory = { config -> + when (config) { + 1 -> TestWebNavigation(initialHistory = listOf(11)) + else -> null + } + }, + ) + + enableWebHistory(nav, history) + nav.requireChild(config = 1).navigate(listOf(11, 22)) + nav.requireChild(config = 1).navigate(listOf(11)) + history.runPendingOperations() + + assertHistory(nav = nav, urls = listOf("/1/11", "/1/22"), index = 0) + } + + @Test + fun WHEN_created_with_one_root_item_and_one_child_item_and_root_pushed_one_item_THEN_two_items_in_browser_history() { + val nav = + TestWebNavigation( + initialHistory = listOf(1), + childFactory = { config -> + when (config) { + 1 -> TestWebNavigation(initialHistory = listOf(11)) + else -> null + } + }, + ) + + enableWebHistory(nav, history) + nav.navigate(listOf(1, 2)) + + assertHistory(nav = nav, urls = listOf("/1/11", "/2")) + } + + @Test + fun WHEN_created_with_one_root_item_and_one_child_item_and_root_pushed_one_item_and_root_popped_one_item_THEN_two_items_in_browser_history_and_first_item_active() { + val nav = + TestWebNavigation( + initialHistory = listOf(1), + childFactory = { config -> + when (config) { + 1 -> TestWebNavigation(initialHistory = listOf(11)) + else -> null + } + }, + ) + + enableWebHistory(nav, history) + nav.navigate(listOf(1, 2)) + nav.navigate(listOf(1)) + history.runPendingOperations() + + assertHistory(nav = nav, urls = listOf("/1/11", "/2"), index = 0) + } + + @Test + fun WHEN_created_with_one_root_item_and_one_child_item_and_child_pushed_one_item_and_root_pushed_one_item_THEN_three_items_in_browser_history() { + val nav = + TestWebNavigation( + initialHistory = listOf(1), + childFactory = { config -> + when (config) { + 1 -> TestWebNavigation(initialHistory = listOf(11)) + else -> null + } + }, + ) + + enableWebHistory(nav, history) + nav.requireChild(config = 1).navigate(listOf(11, 22)) + nav.navigate(listOf(1, 2)) + + assertHistory(nav = nav, urls = listOf("/1/11", "/1/22", "/2")) + } + + @Test + fun WHEN_created_with_one_root_item_and_one_child_item_and_child_pushed_one_item_and_root_pushed_one_item_and_root_popped_item_THEN_three_items_in_browser_history_and_second_item_active() { + val nav = + TestWebNavigation( + initialHistory = listOf(1), + childFactory = { config -> + when (config) { + 1 -> TestWebNavigation(initialHistory = listOf(11)) + else -> null + } + }, + ) + + enableWebHistory(nav, history) + nav.requireChild(config = 1).navigate(listOf(11, 22)) + nav.navigate(listOf(1, 2)) + nav.navigate(listOf(1)) + history.runPendingOperations() + + assertHistory(nav = nav, urls = listOf("/1/11", "/1/22", "/2"), index = 1) + } + + @Test + fun WHEN_created_with_one_root_item_and_one_child_item_and_child_pushed_one_item_and_root_pushed_one_item_and_root_popped_one_item_and_child_popped_one_item_THEN_three_items_in_browser_history_and_first_item_active() { + val nav = + TestWebNavigation( + initialHistory = listOf(1), + childFactory = { config -> + when (config) { + 1 -> TestWebNavigation(initialHistory = listOf(11)) + else -> null + } + }, + ) + + enableWebHistory(nav, history) + nav.requireChild(config = 1).navigate(listOf(11, 22)) + nav.navigate(listOf(1, 2)) + nav.navigate(listOf(1)) + history.runPendingOperations() + nav.requireChild(config = 1).navigate(listOf(11)) + history.runPendingOperations() + + assertHistory(nav = nav, urls = listOf("/1/11", "/1/22", "/2"), index = 0) + } + + @Test + fun WHEN_created_with_one_root_item_and_root_pushed_one_item_with_one_child_item_THEN_two_items_in_browser_history() { + val nav = + TestWebNavigation( + initialHistory = listOf(1), + childFactory = { config -> + when (config) { + 2 -> TestWebNavigation(initialHistory = listOf(11)) + else -> null + } + }, + ) + + enableWebHistory(nav, history) + nav.navigate(listOf(1, 2)) + + assertHistory(nav = nav, urls = listOf("/1", "/2/11")) + } + + @Test + fun WHEN_created_with_one_root_item_and_root_pushed_one_item_with_one_child_item_and_child_pushed_one_item_THEN_three_items_in_browser_history() { + val nav = + TestWebNavigation( + initialHistory = listOf(1), + childFactory = { config -> + when (config) { + 2 -> TestWebNavigation(initialHistory = listOf(11)) + else -> null + } + }, + ) + + enableWebHistory(nav, history) + nav.navigate(listOf(1, 2)) + nav.requireChild(config = 2).navigate(listOf(11, 22)) + + assertHistory(nav = nav, urls = listOf("/1", "/2/11", "/2/22")) + } + + @Test + fun WHEN_created_with_one_root_item_and_root_pushed_one_item_with_one_child_item_and_child_pushed_one_item_and_child_popped_one_item_THEN_three_items_in_browser_history_and_second_item_active() { + val nav = + TestWebNavigation( + initialHistory = listOf(1), + childFactory = { config -> + when (config) { + 2 -> TestWebNavigation(initialHistory = listOf(11)) + else -> null + } + }, + ) + + enableWebHistory(nav, history) + nav.navigate(listOf(1, 2)) + nav.requireChild(config = 2).navigate(listOf(11, 22)) + nav.requireChild(config = 2).navigate(listOf(11)) + history.runPendingOperations() + + assertHistory(nav = nav, urls = listOf("/1", "/2/11", "/2/22"), index = 1) + } + + @Test + fun WHEN_created_with_one_root_item_and_root_pushed_one_item_with_one_child_item_and_child_pushed_one_item_and_root_popped_one_item_THEN_three_items_in_browser_history_and_first_item_active() { + val nav = + TestWebNavigation( + initialHistory = listOf(1), + childFactory = { config -> + when (config) { + 2 -> TestWebNavigation(initialHistory = listOf(11)) + else -> null + } + }, + ) + + enableWebHistory(nav, history) + nav.navigate(listOf(1, 2)) + nav.requireChild(config = 2).navigate(listOf(11, 22)) + nav.navigate(listOf(1)) + history.runPendingOperations() + + assertHistory(nav = nav, urls = listOf("/1", "/2/11", "/2/22"), index = 0) + } + + @Test + fun WHEN_created_with_one_root_item_and_root_pushed_one_item_with_one_child_item_and_child_pushed_one_item_and_root_popped_one_item_and_root_pushed_one_item_THEN_two_items_in_browser_history() { + val nav = + TestWebNavigation( + initialHistory = listOf(1), + childFactory = { config -> + when (config) { + 2 -> TestWebNavigation(initialHistory = listOf(11)) + else -> null + } + }, + ) + + enableWebHistory(nav, history) + nav.navigate(listOf(1, 2)) + nav.requireChild(config = 2).navigate(listOf(11, 22)) + nav.navigate(listOf(1)) + history.runPendingOperations() + nav.navigate(listOf(1, 2)) + + assertHistory(nav = nav, urls = listOf("/1", "/2/11")) + } + + @Test + fun WHEN_created_with_one_root_item_and_one_child_item_and_child_pushed_one_item_and_root_pushed_one_item_with_one_child_item_and_child_pushed_one_item_THEN_four_items_in_browser_history() { + val nav = + TestWebNavigation( + initialHistory = listOf(1), + childFactory = { config -> + when (config) { + 1 -> TestWebNavigation(initialHistory = listOf(11)) + 2 -> TestWebNavigation(initialHistory = listOf(111)) + else -> null + } + }, + ) + + enableWebHistory(nav, history) + nav.requireChild(config = 1).navigate(listOf(11, 22)) + nav.navigate(listOf(1, 2)) + nav.requireChild(config = 2).navigate(listOf(111, 222)) + + assertHistory(nav = nav, urls = listOf("/1/11", "/1/22", "/2/111", "/2/222")) + } + + @Test + fun WHEN_created_with_one_root_item_and_one_child_item_and_child_pushed_one_item_and_root_pushed_one_item_with_one_child_item_and_child_pushed_one_item_and_root_popped_one_item_THEN_four_items_in_browser_history_and_second_item_active() { + val nav = + TestWebNavigation( + initialHistory = listOf(1), + childFactory = { config -> + when (config) { + 1 -> TestWebNavigation(initialHistory = listOf(11)) + 2 -> TestWebNavigation(initialHistory = listOf(111)) + else -> null + } + }, + ) + + enableWebHistory(nav, history) + nav.requireChild(config = 1).navigate(listOf(11, 22)) + nav.navigate(listOf(1, 2)) + nav.requireChild(config = 2).navigate(listOf(111, 222)) + nav.navigate(listOf(1)) + history.runPendingOperations() + + assertHistory(nav = nav, urls = listOf("/1/11", "/1/22", "/2/111", "/2/222"), index = 1) + } + + @Test + fun WHEN_created_with_one_root_item_and_one_child_item_and_child_pushed_one_item_and_root_pushed_one_item_with_one_child_item_and_child_pushed_one_item_and_root_popped_one_item_and_root_pushed_one_item_THEN_three_items_in_browser_history() { + val nav = + TestWebNavigation( + initialHistory = listOf(1), + childFactory = { config -> + when (config) { + 1 -> TestWebNavigation(initialHistory = listOf(11)) + 2 -> TestWebNavigation(initialHistory = listOf(111)) + else -> null + } + }, + ) + + enableWebHistory(nav, history) + nav.requireChild(config = 1).navigate(listOf(11, 22)) + nav.navigate(listOf(1, 2)) + nav.requireChild(config = 2).navigate(listOf(111, 222)) + nav.navigate(listOf(1)) + history.runPendingOperations() + nav.navigate(listOf(1, 2)) + + assertHistory(nav = nav, urls = listOf("/1/11", "/1/22", "/2/111")) + } + + @Test + fun WHEN_created_with_one_root_item_and_one_child_item_and_child_pushed_one_item_and_root_pushed_one_item_with_one_child_item_and_child_pushed_one_item_and_history_go_back_one_item_THEN_four_items_in_browser_history_and_third_item_active() { + val nav = + TestWebNavigation( + initialHistory = listOf(1), + childFactory = { config -> + when (config) { + 1 -> TestWebNavigation(initialHistory = listOf(11)) + 2 -> TestWebNavigation(initialHistory = listOf(111)) + else -> null + } + }, + ) + + enableWebHistory(nav, history) + nav.requireChild(config = 1).navigate(listOf(11, 22)) + nav.navigate(listOf(1, 2)) + nav.requireChild(config = 2).navigate(listOf(111, 222)) + history.navigate(delta = -1) + + assertHistory(nav = nav, urls = listOf("/1/11", "/1/22", "/2/111", "/2/222"), index = 2) + } + + @Test + fun WHEN_created_with_one_root_item_and_one_child_item_and_child_pushed_one_item_and_root_pushed_one_item_with_one_child_item_and_child_pushed_one_item_and_history_go_back_two_items_THEN_four_items_in_browser_history_and_second_item_active() { + val nav = + TestWebNavigation( + initialHistory = listOf(1), + childFactory = { config -> + when (config) { + 1 -> TestWebNavigation(initialHistory = listOf(11)) + 2 -> TestWebNavigation(initialHistory = listOf(111)) + else -> null + } + }, + ) + + enableWebHistory(nav, history) + nav.requireChild(config = 1).navigate(listOf(11, 22)) + nav.navigate(listOf(1, 2)) + nav.requireChild(config = 2).navigate(listOf(111, 222)) + history.navigate(delta = -2) + + assertHistory(nav = nav, urls = listOf("/1/11", "/1/22", "/2/111", "/2/222"), index = 1) + } + + @Test + fun WHEN_created_with_one_root_item_and_one_child_item_and_child_pushed_one_item_and_root_pushed_one_item_with_one_child_item_and_child_pushed_one_item_and_history_go_back_three_items_THEN_four_items_in_browser_history_and_first_item_active() { + val nav = + TestWebNavigation( + initialHistory = listOf(1), + childFactory = { config -> + when (config) { + 1 -> TestWebNavigation(initialHistory = listOf(11)) + 2 -> TestWebNavigation(initialHistory = listOf(111)) + else -> null + } + }, + ) + + enableWebHistory(nav, history) + nav.requireChild(config = 1).navigate(listOf(11, 22)) + nav.navigate(listOf(1, 2)) + nav.requireChild(config = 2).navigate(listOf(111, 222)) + history.navigate(delta = -3) + + assertHistory(nav = nav, urls = listOf("/1/11", "/1/22", "/2/111", "/2/222"), index = 0) + } + + @Test + fun WHEN_created_with_one_root_item_and_one_child_item_and_child_pushed_one_item_and_root_pushed_one_item_with_one_child_item_and_child_pushed_one_item_and_history_go_back_three_items_and_history_go_forward_one_item_THEN_four_items_in_browser_history_and_second_item_active() { + val nav = + TestWebNavigation( + initialHistory = listOf(1), + childFactory = { config -> + when (config) { + 1 -> TestWebNavigation(initialHistory = listOf(11)) + 2 -> TestWebNavigation(initialHistory = listOf(111)) + else -> null + } + }, + ) + + enableWebHistory(nav, history) + nav.requireChild(config = 1).navigate(listOf(11, 22)) + nav.navigate(listOf(1, 2)) + nav.requireChild(config = 2).navigate(listOf(111, 222)) + history.navigate(delta = -3) + history.navigate(delta = 1) + + assertHistory(nav = nav, urls = listOf("/1/11", "/1/22", "/2/111", "/2/222"), index = 1) + } + + @Test + fun WHEN_created_with_one_root_item_and_one_child_item_and_child_pushed_one_item_and_root_pushed_one_item_with_one_child_item_and_child_pushed_one_item_and_history_go_back_three_items_and_history_go_forward_two_items_THEN_four_items_in_browser_history_and_third_item_active() { + val nav = + TestWebNavigation( + initialHistory = listOf(1), + childFactory = { config -> + when (config) { + 1 -> TestWebNavigation(initialHistory = listOf(11)) + 2 -> TestWebNavigation(initialHistory = listOf(111)) + else -> null + } + }, + ) + + enableWebHistory(nav, history) + nav.requireChild(config = 1).navigate(listOf(11, 22)) + nav.navigate(listOf(1, 2)) + nav.requireChild(config = 2).navigate(listOf(111, 222)) + history.navigate(delta = -3) + history.navigate(delta = 2) + + assertHistory(nav = nav, urls = listOf("/1/11", "/1/22", "/2/111", "/2/222"), index = 2) + } + + @Test + fun WHEN_created_with_one_root_item_and_one_child_item_and_child_pushed_one_item_and_root_pushed_one_item_with_one_child_item_and_child_pushed_one_item_and_history_go_back_three_items_and_history_go_forward_three_items_THEN_four_items_in_browser_history() { + val nav = + TestWebNavigation( + initialHistory = listOf(1), + childFactory = { config -> + when (config) { + 1 -> TestWebNavigation(initialHistory = listOf(11)) + 2 -> TestWebNavigation(initialHistory = listOf(111)) + else -> null + } + }, + ) + + enableWebHistory(nav, history) + nav.requireChild(config = 1).navigate(listOf(11, 22)) + nav.navigate(listOf(1, 2)) + nav.requireChild(config = 2).navigate(listOf(111, 222)) + history.navigate(delta = -3) + + history.navigate(delta = 3) + + assertHistory(nav = nav, urls = listOf("/1/11", "/1/22", "/2/111", "/2/222")) + } + + @Test + fun WHEN_created_one_item_and_pushed_one_item_and_history_go_back_one_item_and_onBeforeNavigate_false_THEN_two_items_in_browser_history() { + val nav = TestWebNavigation(initialHistory = listOf(1), onBeforeNavigate = { false }) + enableWebHistory(nav, history) + nav.navigate(listOf(1, 2)) + history.navigate(delta = -1) + history.runPendingOperations() + + assertHistory(nav = nav, urls = listOf("/1", "/2")) + } + + @Test + fun WHEN_created_one_item_and_pushed_two_items_and_history_go_back_one_item_and_onBeforeNavigate_false_THEN_three_items_in_browser_history() { + val nav = TestWebNavigation(initialHistory = listOf(1), onBeforeNavigate = { false }) + enableWebHistory(nav, history) + nav.navigate(listOf(1, 2, 3)) + history.navigate(delta = -2) + history.runPendingOperations() + + assertHistory(nav = nav, urls = listOf("/1", "/2", "/3")) + } + + @Test + fun WHEN_created_one_item_and_pushed_one_item_and_and_popped_one_item_and_history_go_forward_one_item_and_onBeforeNavigate_false_THEN_two_items_in_browser_history_and_first_item_active() { + val nav = TestWebNavigation(initialHistory = listOf(1), onBeforeNavigate = { false }) + enableWebHistory(nav, history) + nav.navigate(listOf(1, 2)) + nav.navigate(listOf(1)) + history.runPendingOperations() + history.navigate(delta = 1) + history.runPendingOperations() + + assertHistory(nav = nav, urls = listOf("/1", "/2"), index = 0) + } + + @Test + fun WHEN_created_one_item_and_pushed_two_items_and_and_popped_two_items_and_history_go_forward_two_items_and_onBeforeNavigate_false_THEN_three_items_in_browser_history_and_first_item_active() { + val nav = TestWebNavigation(initialHistory = listOf(1), onBeforeNavigate = { false }) + enableWebHistory(nav, history) + nav.navigate(listOf(1, 2, 3)) + nav.navigate(listOf(1)) + history.runPendingOperations() + history.navigate(delta = 2) + history.runPendingOperations() + + assertHistory(nav = nav, urls = listOf("/1", "/2", "/3"), index = 0) + } + + @Test + fun WHEN_created_one_root_item_and_one_child_item_and_child_pushed_one_item_and_history_go_back_one_item_and_child_onBeforeNavigate_false_THEN_two_items_in_browser_history() { + val nav = + TestWebNavigation( + initialHistory = listOf(1), + childFactory = { config -> + when (config) { + 1 -> TestWebNavigation(initialHistory = listOf(11), onBeforeNavigate = { false }) + else -> null + } + }, + ) + + enableWebHistory(nav, history) + nav.requireChild(config = 1).navigate(listOf(11, 22)) + history.navigate(delta = -1) + history.runPendingOperations() + + assertHistory(nav = nav, urls = listOf("/1/11", "/1/22")) + } + + @Test + fun WHEN_created_one_root_item_and_one_child_item_and_child_pushed_two_items_and_history_go_back_two_items_and_child_onBeforeNavigate_false_THEN_three_items_in_browser_history() { + val nav = + TestWebNavigation( + initialHistory = listOf(1), + childFactory = { config -> + when (config) { + 1 -> TestWebNavigation(initialHistory = listOf(11), onBeforeNavigate = { false }) + else -> null + } + }, + ) + + enableWebHistory(nav, history) + nav.requireChild(config = 1).navigate(listOf(11, 22, 33)) + history.navigate(delta = -2) + history.runPendingOperations() + + assertHistory(nav = nav, urls = listOf("/1/11", "/1/22", "/1/33")) + } + + @Test + fun WHEN_created_one_root_item_and_one_child_item_and_child_pushed_one_item_and_child_popped_one_item_and_history_go_forward_one_item_and_child_onBeforeNavigate_false_THEN_two_items_in_browser_history_and_first_item_active() { + val nav = + TestWebNavigation( + initialHistory = listOf(1), + childFactory = { config -> + when (config) { + 1 -> TestWebNavigation(initialHistory = listOf(11), onBeforeNavigate = { false }) + else -> null + } + }, + ) + + enableWebHistory(nav, history) + nav.requireChild(config = 1).navigate(listOf(11, 22)) + nav.requireChild(config = 1).navigate(listOf(11)) + history.runPendingOperations() + history.navigate(delta = 1) + history.runPendingOperations() + + assertHistory(nav = nav, urls = listOf("/1/11", "/1/22"), index = 0) + } + + @Test + fun WHEN_created_one_root_item_and_one_child_item_and_child_pushed_two_items_and_child_popped_two_items_and_history_go_forward_two_items_and_child_onBeforeNavigate_false_THEN_three_items_in_browser_history_and_first_item_active() { + val nav = + TestWebNavigation( + initialHistory = listOf(1), + childFactory = { config -> + when (config) { + 1 -> TestWebNavigation(initialHistory = listOf(11), onBeforeNavigate = { false }) + else -> null + } + }, + ) + + enableWebHistory(nav, history) + nav.requireChild(config = 1).navigate(listOf(11, 22, 33)) + nav.requireChild(config = 1).navigate(listOf(11)) + history.runPendingOperations() + history.navigate(delta = 2) + history.runPendingOperations() + + assertHistory(nav = nav, urls = listOf("/1/11", "/1/22", "/1/33"), index = 0) + } + + private fun assertHistory(nav: TestWebNavigation, urls: List, index: Int = urls.lastIndex) { + history.assertStack(urls = urls, index = index) + nav.assertHistory(urls = urls.slice(0..index)) + } +} diff --git a/sample/app-ios-compose/app-ios-compose.xcodeproj/project.xcworkspace/xcuserdata/arkivanov.xcuserdatad/UserInterfaceState.xcuserstate b/sample/app-ios-compose/app-ios-compose.xcodeproj/project.xcworkspace/xcuserdata/arkivanov.xcuserdatad/UserInterfaceState.xcuserstate index 6f0bd201f..c46f8191b 100644 Binary files a/sample/app-ios-compose/app-ios-compose.xcodeproj/project.xcworkspace/xcuserdata/arkivanov.xcuserdatad/UserInterfaceState.xcuserstate and b/sample/app-ios-compose/app-ios-compose.xcodeproj/project.xcworkspace/xcuserdata/arkivanov.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/sample/app-ios-compose/app-ios-compose/iOSApp.swift b/sample/app-ios-compose/app-ios-compose/iOSApp.swift index 25d699b2f..8b8512cb2 100644 --- a/sample/app-ios-compose/app-ios-compose/iOSApp.swift +++ b/sample/app-ios-compose/app-ios-compose/iOSApp.swift @@ -26,8 +26,7 @@ class AppDelegate: NSObject, UIApplicationDelegate { backHandler: backDispatcher ), featureInstaller: DefaultFeatureInstaller.shared, - deepLink: DefaultRootComponentDeepLinkNone.shared, - webHistoryController: nil + deepLinkUrl: nil ) func application(_ application: UIApplication, shouldSaveSecureApplicationState coder: NSCoder) -> Bool { diff --git a/sample/app-ios/app-ios/app_iosApp.swift b/sample/app-ios/app-ios/app_iosApp.swift index ca882e164..3b505487e 100644 --- a/sample/app-ios/app-ios/app_iosApp.swift +++ b/sample/app-ios/app-ios/app_iosApp.swift @@ -31,8 +31,7 @@ class AppDelegate: NSObject, UIApplicationDelegate { backHandler: nil ), featureInstaller: DefaultFeatureInstaller.shared, - deepLink: DefaultRootComponentDeepLinkNone.shared, - webHistoryController: nil + deepLinkUrl: nil ) func application(_ application: UIApplication, shouldSaveSecureApplicationState coder: NSCoder) -> Bool { diff --git a/sample/app-js-compose/src/jsMain/kotlin/com/arkivanov/decompose/sample/app/Main.kt b/sample/app-js-compose/src/jsMain/kotlin/com/arkivanov/decompose/sample/app/Main.kt index 50168a24c..7758ef872 100644 --- a/sample/app-js-compose/src/jsMain/kotlin/com/arkivanov/decompose/sample/app/Main.kt +++ b/sample/app-js-compose/src/jsMain/kotlin/com/arkivanov/decompose/sample/app/Main.kt @@ -6,30 +6,31 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.window.CanvasBasedWindow import com.arkivanov.decompose.DefaultComponentContext import com.arkivanov.decompose.ExperimentalDecomposeApi -import com.arkivanov.decompose.router.stack.webhistory.DefaultWebHistoryController +import com.arkivanov.decompose.router.webhistory.withWebHistory import com.arkivanov.essenty.lifecycle.LifecycleRegistry import com.arkivanov.essenty.lifecycle.resume import com.arkivanov.essenty.lifecycle.stop +import com.arkivanov.sample.shared.Url import com.arkivanov.sample.shared.dynamicfeatures.dynamicfeature.DefaultFeatureInstaller import com.arkivanov.sample.shared.root.DefaultRootComponent import com.arkivanov.sample.shared.root.RootContent -import kotlinx.browser.window import org.jetbrains.skiko.wasm.onWasmReady import web.dom.DocumentVisibilityState import web.dom.document import web.events.EventType -@OptIn(ExperimentalDecomposeApi::class, ExperimentalComposeUiApi::class) +@OptIn(ExperimentalComposeUiApi::class, ExperimentalDecomposeApi::class) fun main() { val lifecycle = LifecycleRegistry() val root = - DefaultRootComponent( - componentContext = DefaultComponentContext(lifecycle = lifecycle), - featureInstaller = DefaultFeatureInstaller, - deepLink = DefaultRootComponent.DeepLink.Web(path = window.location.pathname), - webHistoryController = DefaultWebHistoryController(), - ) + withWebHistory { stateKeeper, deepLink -> + DefaultRootComponent( + componentContext = DefaultComponentContext(lifecycle = lifecycle, stateKeeper = stateKeeper), + featureInstaller = DefaultFeatureInstaller, + deepLinkUrl = deepLink?.let(::Url), + ) + } lifecycle.attachToDocument() @@ -53,4 +54,3 @@ private fun LifecycleRegistry.attachToDocument() { document.addEventListener(type = EventType("visibilitychange"), callback = { onVisibilityChanged() }) } - diff --git a/sample/app-js-compose/src/jsMain/resources/index.html b/sample/app-js-compose/src/jsMain/resources/index.html index 9de8a8680..85ee5afe9 100644 --- a/sample/app-js-compose/src/jsMain/resources/index.html +++ b/sample/app-js-compose/src/jsMain/resources/index.html @@ -2,6 +2,7 @@ + Title diff --git a/sample/app-js/src/main/kotlin/com/arkivanov/sample/app/Main.kt b/sample/app-js/src/main/kotlin/com/arkivanov/sample/app/Main.kt index 9e4586417..5e5f4a417 100644 --- a/sample/app-js/src/main/kotlin/com/arkivanov/sample/app/Main.kt +++ b/sample/app-js/src/main/kotlin/com/arkivanov/sample/app/Main.kt @@ -2,14 +2,14 @@ package com.arkivanov.sample.app import com.arkivanov.decompose.DefaultComponentContext import com.arkivanov.decompose.ExperimentalDecomposeApi -import com.arkivanov.decompose.router.stack.webhistory.DefaultWebHistoryController +import com.arkivanov.decompose.router.webhistory.withWebHistory import com.arkivanov.essenty.lifecycle.LifecycleRegistry import com.arkivanov.essenty.lifecycle.resume import com.arkivanov.essenty.lifecycle.stop +import com.arkivanov.sample.shared.Url import com.arkivanov.sample.shared.dynamicfeatures.dynamicfeature.DefaultFeatureInstaller import com.arkivanov.sample.shared.root.DefaultRootComponent import com.arkivanov.sample.shared.root.RootContent -import kotlinx.browser.window import react.create import react.dom.client.createRoot import web.dom.DocumentVisibilityState @@ -21,12 +21,15 @@ fun main() { val lifecycle = LifecycleRegistry() val root = - DefaultRootComponent( - componentContext = DefaultComponentContext(lifecycle = lifecycle), - featureInstaller = DefaultFeatureInstaller, - deepLink = DefaultRootComponent.DeepLink.Web(path = window.location.pathname), - webHistoryController = DefaultWebHistoryController(), - ) + withWebHistory { stateKeeper, deepLink -> + DefaultRootComponent( + componentContext = DefaultComponentContext(lifecycle = lifecycle, stateKeeper = stateKeeper), + featureInstaller = DefaultFeatureInstaller, + deepLinkUrl = deepLink?.let(::Url), + ) + } + + console.log(root.stack.value) lifecycle.attachToDocument() diff --git a/sample/app-js/src/main/resources/index.html b/sample/app-js/src/main/resources/index.html index 22dc914c1..fa7bee4af 100644 --- a/sample/app-js/src/main/resources/index.html +++ b/sample/app-js/src/main/resources/index.html @@ -2,6 +2,7 @@ + Decompose Counter sample diff --git a/sample/shared/compose/build.gradle.kts b/sample/shared/compose/build.gradle.kts index c6e2b930b..3cf4295e2 100644 --- a/sample/shared/compose/build.gradle.kts +++ b/sample/shared/compose/build.gradle.kts @@ -49,9 +49,13 @@ kotlin { } setupSourceSets { + val nonWeb by bundle() val jvm by bundle() val ios by bundle() + val js by bundle() + nonWeb dependsOn common + (allSet - js) dependsOn nonWeb ios dependsOn common iosSet dependsOn ios diff --git a/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/cards/CardsContent.kt b/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/cards/CardsContent.kt index 92a9500c6..4d4970d41 100644 --- a/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/cards/CardsContent.kt +++ b/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/cards/CardsContent.kt @@ -51,10 +51,13 @@ import com.arkivanov.decompose.extensions.compose.subscribeAsState import com.arkivanov.sample.shared.cards.card.CardComponent import com.arkivanov.sample.shared.cards.card.CardContent import com.arkivanov.sample.shared.utils.TopAppBar +import com.arkivanov.sample.shared.utils.WebDocumentTitle import com.arkivanov.sample.shared.utils.toPx @Composable internal fun CardsContent(component: CardsComponent, modifier: Modifier = Modifier) { + WebDocumentTitle(title = "Cards") + val stack by component.stack.subscribeAsState() Column(modifier = modifier) { diff --git a/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/counters/CountersContent.kt b/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/counters/CountersContent.kt index a6d23dc26..dcdd43fb9 100644 --- a/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/counters/CountersContent.kt +++ b/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/counters/CountersContent.kt @@ -19,10 +19,13 @@ import com.arkivanov.decompose.extensions.compose.stack.animation.scale import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation import com.arkivanov.sample.shared.counters.counter.CounterContent import com.arkivanov.sample.shared.utils.TopAppBar +import com.arkivanov.sample.shared.utils.WebDocumentTitle @OptIn(ExperimentalDecomposeApi::class) @Composable internal fun CountersContent(component: CountersComponent, modifier: Modifier = Modifier) { + WebDocumentTitle(title = "Counters") + Column(modifier = modifier) { TopAppBar(title = "Counters") diff --git a/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/menu/MenuContent.kt b/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/menu/MenuContent.kt index 90c7a4c5e..65c389baf 100644 --- a/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/menu/MenuContent.kt +++ b/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/menu/MenuContent.kt @@ -17,12 +17,15 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import com.arkivanov.sample.shared.utils.TopAppBar +import com.arkivanov.sample.shared.utils.WebDocumentTitle @Composable internal fun MenuContent( component: MenuComponent, modifier: Modifier = Modifier, ) { + WebDocumentTitle(title = "Menu") + Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) { TopAppBar(title = "Decompose Sample") diff --git a/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/multipane/MultiPaneContent.kt b/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/multipane/MultiPaneContent.kt index 4867aa2d8..2d5a490f4 100644 --- a/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/multipane/MultiPaneContent.kt +++ b/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/multipane/MultiPaneContent.kt @@ -29,10 +29,13 @@ import com.arkivanov.sample.shared.multipane.author.ArticleAuthorContent import com.arkivanov.sample.shared.multipane.details.ArticleDetailsContent import com.arkivanov.sample.shared.multipane.list.ArticleListContent import com.arkivanov.sample.shared.utils.TopAppBar +import com.arkivanov.sample.shared.utils.WebDocumentTitle @OptIn(ExperimentalDecomposeApi::class) @Composable internal fun MultiPaneContent(component: MultiPaneComponent, modifier: Modifier = Modifier) { + WebDocumentTitle(title = "Multi-Pane Layout") + val panels by component.panels.subscribeAsState() Column(modifier = modifier) { diff --git a/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/sharedtransitions/gallery/GalleryContent.kt b/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/sharedtransitions/gallery/GalleryContent.kt index b491cf1d3..16e5657de 100644 --- a/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/sharedtransitions/gallery/GalleryContent.kt +++ b/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/sharedtransitions/gallery/GalleryContent.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import com.arkivanov.sample.shared.painterResource import com.arkivanov.sample.shared.utils.TopAppBar +import com.arkivanov.sample.shared.utils.WebDocumentTitle @OptIn(ExperimentalSharedTransitionApi::class) @Composable @@ -29,6 +30,8 @@ internal fun SharedTransitionScope.GalleryContent( animatedVisibilityScope: AnimatedVisibilityScope, modifier: Modifier = Modifier, ) { + WebDocumentTitle(title = "Shared Transitions Gallery") + Column(modifier = modifier) { TopAppBar(title = "Photo Gallery", onCloseClick = component::onCloseClicked) diff --git a/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/sharedtransitions/photo/PhotoContent.kt b/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/sharedtransitions/photo/PhotoContent.kt index be8b7c18f..edcb778a6 100644 --- a/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/sharedtransitions/photo/PhotoContent.kt +++ b/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/sharedtransitions/photo/PhotoContent.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import com.arkivanov.sample.shared.painterResource import com.arkivanov.sample.shared.utils.TopAppBar +import com.arkivanov.sample.shared.utils.WebDocumentTitle @OptIn(ExperimentalSharedTransitionApi::class) @Composable @@ -25,6 +26,8 @@ internal fun SharedTransitionScope.PhotoContent( animatedVisibilityScope: AnimatedVisibilityScope, modifier: Modifier = Modifier, ) { + WebDocumentTitle(title = "Shared Transitions ${component.image.resourceId.name}") + Column(modifier = modifier) { TopAppBar(title = "Photo ${component.image.id}", onCloseClick = component::onCloseClicked) diff --git a/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/utils/Utils.kt b/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/utils/Utils.kt index c0812cb34..b64354e57 100644 --- a/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/utils/Utils.kt +++ b/sample/shared/compose/src/commonMain/kotlin/com/arkivanov/sample/shared/utils/Utils.kt @@ -7,3 +7,6 @@ import androidx.compose.ui.unit.Dp @Composable internal fun Dp.toPx(): Float = with(LocalDensity.current) { toPx() } + +@Composable +internal expect fun WebDocumentTitle(title: String) diff --git a/sample/shared/compose/src/jsMain/kotlin/com/arkivanov/sample/shared/utils/Utils.js.kt b/sample/shared/compose/src/jsMain/kotlin/com/arkivanov/sample/shared/utils/Utils.js.kt new file mode 100644 index 000000000..325433343 --- /dev/null +++ b/sample/shared/compose/src/jsMain/kotlin/com/arkivanov/sample/shared/utils/Utils.js.kt @@ -0,0 +1,13 @@ +package com.arkivanov.sample.shared.utils + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import kotlinx.browser.document + +@Composable +internal actual fun WebDocumentTitle(title: String) { + DisposableEffect(Unit) { + document.title = title + onDispose {} + } +} diff --git a/sample/shared/compose/src/nonWebMain/kotlin/com/arkivanov/sample/shared/utils/Utils.nonWeb.kt b/sample/shared/compose/src/nonWebMain/kotlin/com/arkivanov/sample/shared/utils/Utils.nonWeb.kt new file mode 100644 index 000000000..670d3bed7 --- /dev/null +++ b/sample/shared/compose/src/nonWebMain/kotlin/com/arkivanov/sample/shared/utils/Utils.nonWeb.kt @@ -0,0 +1,8 @@ +package com.arkivanov.sample.shared.utils + +import androidx.compose.runtime.Composable + +@Composable +internal actual fun WebDocumentTitle(title: String) { + // no-op +} diff --git a/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/Url.kt b/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/Url.kt new file mode 100644 index 000000000..6a061db0a --- /dev/null +++ b/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/Url.kt @@ -0,0 +1,36 @@ +package com.arkivanov.sample.shared + +import kotlinx.serialization.Serializable + +@Serializable +data class Url( + val pathSegments: List, + val parameters: Map, +) + +fun Url(url: String): Url { + var path: String = url.substringAfter(delimiter = "://").substringAfter(delimiter = "/") + var parameters: Map = emptyMap() + + if ('?' in path) { + parameters = + path.substringAfter(delimiter = "?") + .split("&") + .map { it.split("=") } + .associate { (key, value) -> key to value } + + path = path.substringBefore(delimiter = "?") + } + + return Url(pathSegments = path.split("/"), parameters = parameters) +} + +fun Url.consumePathSegment(): Pair = + pathSegments.firstOrNull() to copy(pathSegments = pathSegments.drop(1)) + + +internal fun Any.path(): String = + this::class.simpleName?.snakeCase() ?: "" + +internal inline fun pathSegmentOf(): String = + T::class.simpleName?.snakeCase() ?: "" diff --git a/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/Utils.kt b/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/Utils.kt index 912381836..ffeaa8e15 100644 --- a/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/Utils.kt +++ b/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/Utils.kt @@ -16,3 +16,14 @@ private fun Value.asObservableInternal(): Observable = val cancellation = subscribe(emitter::onNext) emitter.setCancellable(cancellation::cancel) } + +internal fun String.snakeCase(): String = + buildString { + for (c in this@snakeCase) { + if (c.isUpperCase() && isNotEmpty()) { + append('_') + } + + append(c.lowercaseChar()) + } + } diff --git a/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/multipane/DefaultMultiPaneComponent.kt b/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/multipane/DefaultMultiPaneComponent.kt index 2b18a0467..8403cafd3 100644 --- a/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/multipane/DefaultMultiPaneComponent.kt +++ b/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/multipane/DefaultMultiPaneComponent.kt @@ -9,12 +9,15 @@ import com.arkivanov.decompose.router.panels.Panels import com.arkivanov.decompose.router.panels.PanelsNavigation import com.arkivanov.decompose.router.panels.activateExtra import com.arkivanov.decompose.router.panels.childPanels +import com.arkivanov.decompose.router.panels.childPanelsWebNavigation import com.arkivanov.decompose.router.panels.isDual import com.arkivanov.decompose.router.panels.isSingle import com.arkivanov.decompose.router.panels.navigate import com.arkivanov.decompose.router.panels.pop +import com.arkivanov.decompose.router.webhistory.WebNavigation import com.arkivanov.decompose.value.Value import com.arkivanov.essenty.lifecycle.reaktive.disposableScope +import com.arkivanov.sample.shared.Url import com.arkivanov.sample.shared.multipane.author.ArticleAuthorComponent import com.arkivanov.sample.shared.multipane.author.DefaultArticleAuthorComponent import com.arkivanov.sample.shared.multipane.database.DefaultArticleDatabase @@ -32,6 +35,7 @@ import kotlinx.serialization.builtins.serializer @OptIn(ExperimentalDecomposeApi::class) internal class DefaultMultiPaneComponent( componentContext: ComponentContext, + deepLinkUrl: Url?, ) : MultiPaneComponent, ComponentContext by componentContext, DisposableScope by componentContext.disposableScope() { private val database = DefaultArticleDatabase() @@ -39,10 +43,10 @@ internal class DefaultMultiPaneComponent( private val _navState = BehaviorSubject?>(null) private val navState = _navState.notNull() - override val panels: Value> = + private val _panels = childPanels( source = navigation, - initialPanels = { Panels(main = Unit) }, + initialPanels = { getInitialPanels(deepLinkUrl) }, serializers = Triple(Unit.serializer(), Details.serializer(), Extra.serializer()), onStateChanged = { newState, _ -> _navState.onNext(newState) }, handleBackButton = true, @@ -51,6 +55,22 @@ internal class DefaultMultiPaneComponent( extraFactory = ::authorComponent, ) + override val panels: Value> = _panels + + override val webNavigation: WebNavigation<*> = + childPanelsWebNavigation( + navigator = navigation, + panels = _panels, + mainSerializer = Unit.serializer(), + detailsSerializer = Details.serializer(), + extraSerializer = Extra.serializer(), + parametersMapper = { panels -> + panels.details?.let { + mapOf(KEY_ARTICLE_ID to it.configuration.articleId.toString()) + } + }, + ) + private fun listComponent(componentContext: ComponentContext): ArticleListComponent = DefaultArticleListComponent( componentContext = componentContext, @@ -100,6 +120,19 @@ internal class DefaultMultiPaneComponent( navigation.pop() } + private fun getInitialPanels(deepLinkUrl: Url?): Panels { + val parameters = deepLinkUrl?.parameters ?: return Panels(main = Unit) + + return Panels( + main = Unit, + details = parameters[KEY_ARTICLE_ID]?.toLongOrNull()?.let { Details(articleId = it) }, + ) + } + + private companion object { + private const val KEY_ARTICLE_ID = "articleId" + } + @Serializable private data class Details(val articleId: Long) diff --git a/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/multipane/MultiPaneComponent.kt b/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/multipane/MultiPaneComponent.kt index 8442e41b7..6283b57f4 100644 --- a/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/multipane/MultiPaneComponent.kt +++ b/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/multipane/MultiPaneComponent.kt @@ -3,6 +3,7 @@ package com.arkivanov.sample.shared.multipane import com.arkivanov.decompose.ExperimentalDecomposeApi import com.arkivanov.decompose.router.panels.ChildPanels import com.arkivanov.decompose.router.panels.ChildPanelsMode +import com.arkivanov.decompose.router.webhistory.WebNavigationOwner import com.arkivanov.decompose.value.Value import com.arkivanov.essenty.backhandler.BackHandlerOwner import com.arkivanov.sample.shared.multipane.author.ArticleAuthorComponent @@ -10,7 +11,7 @@ import com.arkivanov.sample.shared.multipane.details.ArticleDetailsComponent import com.arkivanov.sample.shared.multipane.list.ArticleListComponent @OptIn(ExperimentalDecomposeApi::class) -interface MultiPaneComponent : BackHandlerOwner { +interface MultiPaneComponent : BackHandlerOwner, WebNavigationOwner { val panels: Value> diff --git a/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/multipane/PreviewMultiPaneComponent.kt b/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/multipane/PreviewMultiPaneComponent.kt index 049c47934..7dba960bd 100644 --- a/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/multipane/PreviewMultiPaneComponent.kt +++ b/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/multipane/PreviewMultiPaneComponent.kt @@ -5,6 +5,7 @@ import com.arkivanov.decompose.ComponentContext import com.arkivanov.decompose.ExperimentalDecomposeApi import com.arkivanov.decompose.router.panels.ChildPanels import com.arkivanov.decompose.router.panels.ChildPanelsMode +import com.arkivanov.decompose.router.webhistory.WebNavigationOwner import com.arkivanov.decompose.value.MutableValue import com.arkivanov.decompose.value.Value import com.arkivanov.sample.shared.PreviewComponentContext @@ -17,9 +18,10 @@ import com.arkivanov.sample.shared.multipane.list.PreviewArticleListComponent @OptIn(ExperimentalDecomposeApi::class) class PreviewMultiPaneComponent( isMultiPane: Boolean = false, -) : MultiPaneComponent, ComponentContext by PreviewComponentContext { +) : MultiPaneComponent, + ComponentContext by PreviewComponentContext, + WebNavigationOwner.NoOp { - @OptIn(ExperimentalDecomposeApi::class) override val panels: Value> = MutableValue( ChildPanels( diff --git a/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/pages/DefaultPagesComponent.kt b/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/pages/DefaultPagesComponent.kt index 7396eb98f..0b9c7fa4f 100644 --- a/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/pages/DefaultPagesComponent.kt +++ b/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/pages/DefaultPagesComponent.kt @@ -6,31 +6,45 @@ import com.arkivanov.decompose.router.pages.ChildPages import com.arkivanov.decompose.router.pages.Pages import com.arkivanov.decompose.router.pages.PagesNavigation import com.arkivanov.decompose.router.pages.childPages +import com.arkivanov.decompose.router.pages.childPagesWebNavigation import com.arkivanov.decompose.router.pages.select import com.arkivanov.decompose.router.pages.selectNext import com.arkivanov.decompose.router.pages.selectPrev +import com.arkivanov.decompose.router.webhistory.WebNavigation import com.arkivanov.decompose.value.Value import com.arkivanov.sample.shared.ImageResourceId +import com.arkivanov.sample.shared.Url +import com.arkivanov.sample.shared.consumePathSegment import com.arkivanov.sample.shared.customnavigation.DefaultKittenComponent import com.arkivanov.sample.shared.customnavigation.KittenComponent -import kotlinx.serialization.serializer -@OptIn(ExperimentalDecomposeApi::class) class DefaultPagesComponent( componentContext: ComponentContext, + deepLinkUrl: Url?, private val onFinished: () -> Unit, ) : PagesComponent, ComponentContext by componentContext { private val nav = PagesNavigation() - override val pages: Value> = + private val _pages: Value> = childPages( source = nav, - serializer = serializer(), - initialPages = { Pages(items = ImageResourceId.entries, selectedIndex = 0) }, + serializer = ImageResourceId.serializer(), + initialPages = { Pages(items = ImageResourceId.entries, selectedIndex = getInitialPageIndex(deepLinkUrl)) }, childFactory = { imageType, ctx -> DefaultKittenComponent(ctx, imageType) }, ) + override val pages: Value> = _pages + + @OptIn(ExperimentalDecomposeApi::class) + override val webNavigation: WebNavigation<*> = + childPagesWebNavigation( + navigator = nav, + pages = _pages, + serializer = ImageResourceId.serializer(), + pathMapper = { it.items.getOrNull(it.selectedIndex)?.configuration?.name }, + ) + override fun selectPage(index: Int) { nav.select(index = index) } @@ -46,4 +60,10 @@ class DefaultPagesComponent( override fun onCloseClicked() { onFinished() } + + private fun getInitialPageIndex(deepLinkUrl: Url?): Int { + val (path) = deepLinkUrl?.consumePathSegment() ?: return 0 + + return ImageResourceId.entries.indexOfFirst { it.name == path }.takeIf { it >= 0 } ?: 0 + } } diff --git a/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/pages/PagesComponent.kt b/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/pages/PagesComponent.kt index e0fdb5cca..2778fbe9f 100644 --- a/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/pages/PagesComponent.kt +++ b/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/pages/PagesComponent.kt @@ -1,13 +1,12 @@ package com.arkivanov.sample.shared.pages -import com.arkivanov.decompose.ExperimentalDecomposeApi import com.arkivanov.decompose.router.pages.ChildPages +import com.arkivanov.decompose.router.webhistory.WebNavigationOwner import com.arkivanov.decompose.value.Value import com.arkivanov.sample.shared.customnavigation.KittenComponent -interface PagesComponent { +interface PagesComponent : WebNavigationOwner { - @OptIn(ExperimentalDecomposeApi::class) val pages: Value> fun selectPage(index: Int) diff --git a/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/root/DefaultRootComponent.kt b/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/root/DefaultRootComponent.kt index a112bb5f6..176ba0a99 100644 --- a/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/root/DefaultRootComponent.kt +++ b/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/root/DefaultRootComponent.kt @@ -5,30 +5,34 @@ import com.arkivanov.decompose.ExperimentalDecomposeApi import com.arkivanov.decompose.router.stack.ChildStack import com.arkivanov.decompose.router.stack.StackNavigation import com.arkivanov.decompose.router.stack.childStack +import com.arkivanov.decompose.router.stack.childStackWebNavigation import com.arkivanov.decompose.router.stack.pop import com.arkivanov.decompose.router.stack.popTo import com.arkivanov.decompose.router.stack.pushNew -import com.arkivanov.decompose.router.stack.webhistory.WebHistoryController +import com.arkivanov.decompose.router.webhistory.WebNavigation import com.arkivanov.decompose.value.Value +import com.arkivanov.sample.shared.Url +import com.arkivanov.sample.shared.consumePathSegment import com.arkivanov.sample.shared.customnavigation.DefaultCustomNavigationComponent import com.arkivanov.sample.shared.dynamicfeatures.DefaultDynamicFeaturesComponent import com.arkivanov.sample.shared.dynamicfeatures.dynamicfeature.FeatureInstaller import com.arkivanov.sample.shared.pages.DefaultPagesComponent +import com.arkivanov.sample.shared.path +import com.arkivanov.sample.shared.pathSegmentOf import com.arkivanov.sample.shared.root.RootComponent.Child import com.arkivanov.sample.shared.root.RootComponent.Child.CustomNavigationChild import com.arkivanov.sample.shared.root.RootComponent.Child.DynamicFeaturesChild import com.arkivanov.sample.shared.root.RootComponent.Child.PagesChild +import com.arkivanov.sample.shared.root.RootComponent.Child.SharedTransitionsChild import com.arkivanov.sample.shared.root.RootComponent.Child.TabsChild import com.arkivanov.sample.shared.sharedtransitions.DefaultSharedTransitionsComponent import com.arkivanov.sample.shared.tabs.DefaultTabsComponent import kotlinx.serialization.Serializable -@OptIn(ExperimentalDecomposeApi::class) class DefaultRootComponent( componentContext: ComponentContext, private val featureInstaller: FeatureInstaller, - deepLink: DeepLink = DeepLink.None, - webHistoryController: WebHistoryController? = null, + deepLinkUrl: Url? = null, ) : RootComponent, ComponentContext by componentContext { private val nav = StackNavigation() @@ -37,21 +41,29 @@ class DefaultRootComponent( childStack( source = nav, serializer = Config.serializer(), - initialStack = { getInitialStack(webHistoryPaths = webHistoryController?.historyPaths, deepLink = deepLink) }, + initialStack = { getInitialStack(deepLinkUrl) }, childFactory = ::child, ) override val stack: Value> = _stack - init { - webHistoryController?.attach( + @OptIn(ExperimentalDecomposeApi::class) + override val webNavigation: WebNavigation<*> = + childStackWebNavigation( navigator = nav, - serializer = Config.serializer(), stack = _stack, - getPath = ::getPathForConfig, - getConfiguration = ::getConfigForPath, + serializer = Config.serializer(), + pathMapper = { it.configuration.path() }, + childSelector = { + when (val child = it.instance) { + is CustomNavigationChild -> null + is TabsChild -> child.component + is DynamicFeaturesChild -> null + is PagesChild -> child.component + is SharedTransitionsChild -> child.component + } + }, ) - } private fun child(config: Config, componentContext: ComponentContext): Child = when (config) { @@ -59,10 +71,11 @@ class DefaultRootComponent( TabsChild( DefaultTabsComponent( componentContext = componentContext, + deepLinkUrl = config.deepLinkUrl, onDynamicFeaturesItemSelected = { nav.pushNew(Config.DynamicFeatures) }, onCustomNavigationItemSelected = { nav.pushNew(Config.CustomNavigation) }, - onPagesItemSelected = { nav.pushNew(Config.Pages) }, - onSharedTransitionsItemSelected = { nav.pushNew(Config.SharedTransitions) }, + onPagesItemSelected = { nav.pushNew(Config.Pages()) }, + onSharedTransitionsItemSelected = { nav.pushNew(Config.SharedTransitions()) }, ) ) @@ -87,14 +100,16 @@ class DefaultRootComponent( PagesChild( DefaultPagesComponent( componentContext = componentContext, + deepLinkUrl = config.deepLinkUrl, onFinished = nav::pop, ) ) is Config.SharedTransitions -> - Child.SharedTransitionsChild( + SharedTransitionsChild( DefaultSharedTransitionsComponent( componentContext = componentContext, + deepLinkUrl = config.deepLinkUrl, onFinished = nav::pop, ) ) @@ -108,47 +123,22 @@ class DefaultRootComponent( nav.popTo(index = toIndex) } - private companion object { - private const val WEB_PATH_DYNAMIC_FEATURES = "dynamic-features" - private const val WEB_PATH_CUSTOM_NAVIGATION = "custom-navigation" - private const val WEB_PATH_PAGES = "pages" - private const val WEB_PATH_SHARED_TRANSITIONS = "shared-transitions" - - private fun getInitialStack(webHistoryPaths: List?, deepLink: DeepLink): List = - webHistoryPaths - ?.takeUnless(List<*>::isEmpty) - ?.map(::getConfigForPath) - ?: getInitialStack(deepLink) - - private fun getInitialStack(deepLink: DeepLink): List = - when (deepLink) { - is DeepLink.None -> listOf(Config.Tabs) - is DeepLink.Web -> listOf(Config.Tabs, getConfigForPath(deepLink.path)).distinct() - } - - private fun getPathForConfig(config: Config): String = - when (config) { - Config.Tabs -> "" - Config.DynamicFeatures -> "/$WEB_PATH_DYNAMIC_FEATURES" - Config.CustomNavigation -> "/$WEB_PATH_CUSTOM_NAVIGATION" - Config.Pages -> "/$WEB_PATH_PAGES" - Config.SharedTransitions -> "/$WEB_PATH_SHARED_TRANSITIONS" - } - - private fun getConfigForPath(path: String): Config = - when (path.removePrefix("/")) { - WEB_PATH_DYNAMIC_FEATURES -> Config.DynamicFeatures - WEB_PATH_CUSTOM_NAVIGATION -> Config.CustomNavigation - WEB_PATH_PAGES -> Config.Pages - WEB_PATH_SHARED_TRANSITIONS -> Config.SharedTransitions - else -> Config.Tabs - } + private fun getInitialStack(deepLinkUrl: Url?): List { + val (path, childUrl) = deepLinkUrl?.consumePathSegment() ?: return listOf(Config.Tabs()) + + return when (path) { + pathSegmentOf() -> listOf(Config.Tabs(), Config.DynamicFeatures) + pathSegmentOf() -> listOf(Config.Tabs(), Config.CustomNavigation) + pathSegmentOf() -> listOf(Config.Tabs(), Config.Pages(deepLinkUrl = childUrl)) + pathSegmentOf() -> listOf(Config.Tabs(), Config.SharedTransitions(deepLinkUrl = childUrl)) + else -> listOf(Config.Tabs(deepLinkUrl = childUrl)) + } } @Serializable private sealed interface Config { @Serializable - data object Tabs : Config + data class Tabs(val deepLinkUrl: Url? = null) : Config @Serializable data object DynamicFeatures : Config @@ -157,14 +147,9 @@ class DefaultRootComponent( data object CustomNavigation : Config @Serializable - data object Pages : Config + data class Pages(val deepLinkUrl: Url? = null) : Config @Serializable - data object SharedTransitions : Config - } - - sealed interface DeepLink { - data object None : DeepLink - class Web(val path: String) : DeepLink + data class SharedTransitions(val deepLinkUrl: Url? = null) : Config } } diff --git a/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/root/PreviewRootComponent.kt b/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/root/PreviewRootComponent.kt index c5716f9a1..89cfe9403 100644 --- a/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/root/PreviewRootComponent.kt +++ b/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/root/PreviewRootComponent.kt @@ -1,7 +1,9 @@ package com.arkivanov.sample.shared.root import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.ExperimentalDecomposeApi import com.arkivanov.decompose.router.stack.ChildStack +import com.arkivanov.decompose.router.webhistory.WebNavigationOwner import com.arkivanov.decompose.value.MutableValue import com.arkivanov.decompose.value.Value import com.arkivanov.sample.shared.PreviewComponentContext @@ -9,7 +11,11 @@ import com.arkivanov.sample.shared.root.RootComponent.Child import com.arkivanov.sample.shared.root.RootComponent.Child.TabsChild import com.arkivanov.sample.shared.tabs.PreviewTabsComponent -class PreviewRootComponent : RootComponent, ComponentContext by PreviewComponentContext { +@OptIn(ExperimentalDecomposeApi::class) +class PreviewRootComponent : + RootComponent, + ComponentContext by PreviewComponentContext, + WebNavigationOwner.NoOp { override val stack: Value> = MutableValue( diff --git a/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/root/RootComponent.kt b/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/root/RootComponent.kt index 022a55ac8..3905385bb 100644 --- a/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/root/RootComponent.kt +++ b/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/root/RootComponent.kt @@ -1,6 +1,7 @@ package com.arkivanov.sample.shared.root import com.arkivanov.decompose.router.stack.ChildStack +import com.arkivanov.decompose.router.webhistory.WebNavigationOwner import com.arkivanov.decompose.value.Value import com.arkivanov.essenty.backhandler.BackHandlerOwner import com.arkivanov.sample.shared.customnavigation.CustomNavigationComponent @@ -9,7 +10,7 @@ import com.arkivanov.sample.shared.pages.PagesComponent import com.arkivanov.sample.shared.sharedtransitions.SharedTransitionsComponent import com.arkivanov.sample.shared.tabs.TabsComponent -interface RootComponent : BackHandlerOwner { +interface RootComponent : BackHandlerOwner, WebNavigationOwner { val stack: Value> @@ -17,10 +18,10 @@ interface RootComponent : BackHandlerOwner { fun onBackClicked(toIndex: Int) sealed class Child { - class TabsChild(val component: TabsComponent) : Child() + class TabsChild(val component: TabsComponent) : Child() // /tabs class DynamicFeaturesChild(val component: DynamicFeaturesComponent) : Child() class CustomNavigationChild(val component: CustomNavigationComponent) : Child() class PagesChild(val component: PagesComponent) : Child() - class SharedTransitionsChild(val component: SharedTransitionsComponent) : Child() + class SharedTransitionsChild(val component: SharedTransitionsComponent) : Child() // /transitions } } diff --git a/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/sharedtransitions/DefaultSharedTransitionsComponent.kt b/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/sharedtransitions/DefaultSharedTransitionsComponent.kt index c712dfd6c..e2fd666e4 100644 --- a/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/sharedtransitions/DefaultSharedTransitionsComponent.kt +++ b/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/sharedtransitions/DefaultSharedTransitionsComponent.kt @@ -1,12 +1,20 @@ package com.arkivanov.sample.shared.sharedtransitions import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.ExperimentalDecomposeApi import com.arkivanov.decompose.router.stack.ChildStack import com.arkivanov.decompose.router.stack.StackNavigation import com.arkivanov.decompose.router.stack.childStack +import com.arkivanov.decompose.router.stack.childStackWebNavigation import com.arkivanov.decompose.router.stack.pop import com.arkivanov.decompose.router.stack.pushNew +import com.arkivanov.decompose.router.webhistory.WebNavigation import com.arkivanov.decompose.value.Value +import com.arkivanov.sample.shared.ImageResourceId +import com.arkivanov.sample.shared.Url +import com.arkivanov.sample.shared.consumePathSegment +import com.arkivanov.sample.shared.path +import com.arkivanov.sample.shared.pathSegmentOf import com.arkivanov.sample.shared.sharedtransitions.SharedTransitionsComponent.Child import com.arkivanov.sample.shared.sharedtransitions.SharedTransitionsComponent.Child.GalleryChild import com.arkivanov.sample.shared.sharedtransitions.SharedTransitionsComponent.Child.PhotoChild @@ -17,26 +25,54 @@ import kotlinx.serialization.Serializable class DefaultSharedTransitionsComponent( componentContext: ComponentContext, + deepLinkUrl: Url?, private val onFinished: () -> Unit, ) : SharedTransitionsComponent, ComponentContext by componentContext { + private val images = + List(100) { index -> + Image( + id = index, + resourceId = ImageResourceId.entries[index % ImageResourceId.entries.size], + ) + } + private val nav = StackNavigation() - override val stack: Value> = + private val _stack = childStack( source = nav, serializer = Config.serializer(), - initialConfiguration = Config.Gallery, + initialStack = { getInitialStack(deepLinkUrl) }, handleBackButton = true, childFactory = { config, _ -> child(config) }, ) + override val stack: Value> = _stack + + @OptIn(ExperimentalDecomposeApi::class) + override val webNavigation: WebNavigation<*> = + childStackWebNavigation( + navigator = nav, + stack = _stack, + serializer = Config.serializer(), + pathMapper = { it.configuration.path() }, + parametersMapper = { child -> + when (val config = child.configuration) { + is Config.Gallery -> emptyMap() + is Config.Photo -> mapOf("id" to config.id.toString()) + } + }, + onBeforeNavigate = { false }, + ) + private fun child(config: Config): Child = when (config) { is Config.Gallery -> GalleryChild( DefaultGalleryComponent( - onImageSelected = { nav.pushNew(Config.Photo(it)) }, + images = images, + onImageSelected = { nav.pushNew(Config.Photo(id = it)) }, onFinished = onFinished, ) ) @@ -44,7 +80,7 @@ class DefaultSharedTransitionsComponent( is Config.Photo -> PhotoChild( DefaultPhotoComponent( - image = config.image, + image = images.first { it.id == config.id }, onFinished = nav::pop, ) ) @@ -54,12 +90,30 @@ class DefaultSharedTransitionsComponent( nav.pop() } + private fun getInitialStack(deepLinkUrl: Url?): List { + if (deepLinkUrl == null) { + return listOf(Config.Gallery) + } + + val (path) = deepLinkUrl.consumePathSegment() + + return when (path) { + pathSegmentOf() -> + listOfNotNull( + Config.Gallery, + deepLinkUrl.parameters["id"]?.toIntOrNull()?.let { Config.Photo(id = it) }, + ) + + else -> listOf(Config.Gallery) + } + } + @Serializable private sealed interface Config { @Serializable data object Gallery : Config @Serializable - data class Photo(val image: Image) : Config + data class Photo(val id: Int) : Config } } diff --git a/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/sharedtransitions/SharedTransitionsComponent.kt b/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/sharedtransitions/SharedTransitionsComponent.kt index 34e806817..46b0a1a5b 100644 --- a/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/sharedtransitions/SharedTransitionsComponent.kt +++ b/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/sharedtransitions/SharedTransitionsComponent.kt @@ -1,12 +1,13 @@ package com.arkivanov.sample.shared.sharedtransitions import com.arkivanov.decompose.router.stack.ChildStack +import com.arkivanov.decompose.router.webhistory.WebNavigationOwner import com.arkivanov.decompose.value.Value import com.arkivanov.essenty.backhandler.BackHandlerOwner import com.arkivanov.sample.shared.sharedtransitions.gallery.GalleryComponent import com.arkivanov.sample.shared.sharedtransitions.photo.PhotoComponent -interface SharedTransitionsComponent : BackHandlerOwner { +interface SharedTransitionsComponent : BackHandlerOwner, WebNavigationOwner { val stack: Value> diff --git a/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/sharedtransitions/gallery/DefaultGalleryComponent.kt b/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/sharedtransitions/gallery/DefaultGalleryComponent.kt index c0f13e5bb..e1fee6bec 100644 --- a/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/sharedtransitions/gallery/DefaultGalleryComponent.kt +++ b/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/sharedtransitions/gallery/DefaultGalleryComponent.kt @@ -1,23 +1,15 @@ package com.arkivanov.sample.shared.sharedtransitions.gallery -import com.arkivanov.sample.shared.ImageResourceId import com.arkivanov.sample.shared.sharedtransitions.photo.Image class DefaultGalleryComponent( - private val onImageSelected: (Image) -> Unit, + override val images: List, + private val onImageSelected: (id: Int) -> Unit, private val onFinished: () -> Unit, ) : GalleryComponent { - override val images: List = - List(100) { index -> - Image( - id = index, - resourceId = ImageResourceId.entries[index % ImageResourceId.entries.size], - ) - } - - override fun onImageClicked(index: Int) { - onImageSelected(images[index]) + override fun onImageClicked(id: Int) { + onImageSelected(id) } override fun onCloseClicked() { diff --git a/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/sharedtransitions/photo/Image.kt b/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/sharedtransitions/photo/Image.kt index 646822184..4e4ab1e04 100644 --- a/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/sharedtransitions/photo/Image.kt +++ b/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/sharedtransitions/photo/Image.kt @@ -1,9 +1,7 @@ package com.arkivanov.sample.shared.sharedtransitions.photo import com.arkivanov.sample.shared.ImageResourceId -import kotlinx.serialization.Serializable -@Serializable data class Image( val id: Int, val resourceId: ImageResourceId, diff --git a/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/tabs/DefaultTabsComponent.kt b/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/tabs/DefaultTabsComponent.kt index 0cd2b1b14..9b92b2ca1 100644 --- a/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/tabs/DefaultTabsComponent.kt +++ b/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/tabs/DefaultTabsComponent.kt @@ -1,15 +1,22 @@ package com.arkivanov.sample.shared.tabs import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.ExperimentalDecomposeApi import com.arkivanov.decompose.router.stack.ChildStack import com.arkivanov.decompose.router.stack.StackNavigation import com.arkivanov.decompose.router.stack.bringToFront import com.arkivanov.decompose.router.stack.childStack +import com.arkivanov.decompose.router.stack.childStackWebNavigation +import com.arkivanov.decompose.router.webhistory.WebNavigation import com.arkivanov.decompose.value.Value +import com.arkivanov.sample.shared.Url import com.arkivanov.sample.shared.cards.DefaultCardsComponent +import com.arkivanov.sample.shared.consumePathSegment import com.arkivanov.sample.shared.counters.DefaultCountersComponent import com.arkivanov.sample.shared.menu.DefaultMenuComponent import com.arkivanov.sample.shared.multipane.DefaultMultiPaneComponent +import com.arkivanov.sample.shared.path +import com.arkivanov.sample.shared.pathSegmentOf import com.arkivanov.sample.shared.tabs.TabsComponent.Child import com.arkivanov.sample.shared.tabs.TabsComponent.Child.CardsChild import com.arkivanov.sample.shared.tabs.TabsComponent.Child.CountersChild @@ -19,6 +26,7 @@ import kotlinx.serialization.Serializable internal class DefaultTabsComponent( componentContext: ComponentContext, + deepLinkUrl: Url?, private val onDynamicFeaturesItemSelected: () -> Unit, private val onCustomNavigationItemSelected: () -> Unit, private val onPagesItemSelected: () -> Unit, @@ -27,14 +35,34 @@ internal class DefaultTabsComponent( private val nav = StackNavigation() - override val stack: Value> = + private val _stack: Value> = childStack( source = nav, serializer = Config.serializer(), - initialConfiguration = Config.Menu, + initialConfiguration = getInitialConfig(deepLinkUrl), childFactory = ::child, ) + override val stack: Value> = _stack + + @OptIn(ExperimentalDecomposeApi::class) + override val webNavigation: WebNavigation<*> = + childStackWebNavigation( + navigator = nav, + stack = _stack, + serializer = Config.serializer(), + enableHistory = false, + pathMapper = { it.configuration.path() }, + childSelector = { + when (val child = it.instance) { + is CardsChild -> null + is CountersChild -> null + is MenuChild -> null + is MultiPaneChild -> child.component + } + }, + ) + private fun child(config: Config, componentContext: ComponentContext): Child = when (config) { is Config.Menu -> @@ -48,7 +76,7 @@ internal class DefaultTabsComponent( ) is Config.Counters -> CountersChild(DefaultCountersComponent(componentContext)) - is Config.MultiPane -> MultiPaneChild(DefaultMultiPaneComponent(componentContext)) + is Config.MultiPane -> MultiPaneChild(DefaultMultiPaneComponent(componentContext, config.deepLinkUrl)) is Config.Cards -> CardsChild(DefaultCardsComponent(componentContext)) } @@ -65,7 +93,19 @@ internal class DefaultTabsComponent( } override fun onMultiPaneTabClicked() { - nav.bringToFront(Config.MultiPane) + nav.bringToFront(Config.MultiPane()) + } + + private fun getInitialConfig(deepLinkUrl: Url?): Config { + // TODO: path childUrl + val (path, childUrl) = deepLinkUrl?.consumePathSegment() ?: return Config.Menu + + return when (path) { + pathSegmentOf() -> Config.Counters + pathSegmentOf() -> Config.Cards + pathSegmentOf() -> Config.MultiPane(deepLinkUrl = childUrl) + else -> Config.Menu + } } @Serializable @@ -80,6 +120,6 @@ internal class DefaultTabsComponent( data object Cards : Config @Serializable - data object MultiPane : Config + data class MultiPane(val deepLinkUrl: Url? = null) : Config } } diff --git a/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/tabs/PreviewTabsComponent.kt b/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/tabs/PreviewTabsComponent.kt index ad21833c7..c0adc3537 100644 --- a/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/tabs/PreviewTabsComponent.kt +++ b/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/tabs/PreviewTabsComponent.kt @@ -1,12 +1,15 @@ package com.arkivanov.sample.shared.tabs +import com.arkivanov.decompose.ExperimentalDecomposeApi import com.arkivanov.decompose.router.stack.ChildStack +import com.arkivanov.decompose.router.webhistory.WebNavigationOwner import com.arkivanov.decompose.value.MutableValue import com.arkivanov.decompose.value.Value import com.arkivanov.sample.shared.counters.PreviewCountersComponent import com.arkivanov.sample.shared.tabs.TabsComponent.Child.CountersChild -class PreviewTabsComponent : TabsComponent { +@OptIn(ExperimentalDecomposeApi::class) +class PreviewTabsComponent : TabsComponent, WebNavigationOwner.NoOp { override val stack: Value> = MutableValue( diff --git a/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/tabs/TabsComponent.kt b/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/tabs/TabsComponent.kt index 845544783..d3f2e6130 100644 --- a/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/tabs/TabsComponent.kt +++ b/sample/shared/shared/src/commonMain/kotlin/com/arkivanov/sample/shared/tabs/TabsComponent.kt @@ -1,13 +1,14 @@ package com.arkivanov.sample.shared.tabs import com.arkivanov.decompose.router.stack.ChildStack +import com.arkivanov.decompose.router.webhistory.WebNavigationOwner import com.arkivanov.decompose.value.Value import com.arkivanov.sample.shared.cards.CardsComponent import com.arkivanov.sample.shared.counters.CountersComponent import com.arkivanov.sample.shared.menu.MenuComponent import com.arkivanov.sample.shared.multipane.MultiPaneComponent -interface TabsComponent { +interface TabsComponent : WebNavigationOwner { val stack: Value> diff --git a/sample/shared/shared/src/commonTest/kotlin/com/arkivanov/sample/shared/root/RootComponentIntegrationTest.kt b/sample/shared/shared/src/commonTest/kotlin/com/arkivanov/sample/shared/root/RootComponentIntegrationTest.kt index a1e2613a3..baa603925 100644 --- a/sample/shared/shared/src/commonTest/kotlin/com/arkivanov/sample/shared/root/RootComponentIntegrationTest.kt +++ b/sample/shared/shared/src/commonTest/kotlin/com/arkivanov/sample/shared/root/RootComponentIntegrationTest.kt @@ -1,10 +1,9 @@ package com.arkivanov.sample.shared.root -import com.arkivanov.decompose.ExperimentalDecomposeApi +import com.arkivanov.sample.shared.Url import com.arkivanov.sample.shared.assertActiveInstance import com.arkivanov.sample.shared.createComponent import com.arkivanov.sample.shared.dynamicfeatures.dynamicfeature.TestFeatureInstaller -import com.arkivanov.sample.shared.root.DefaultRootComponent.DeepLink import com.arkivanov.sample.shared.root.RootComponent.Child.CustomNavigationChild import com.arkivanov.sample.shared.root.RootComponent.Child.DynamicFeaturesChild import com.arkivanov.sample.shared.root.RootComponent.Child.PagesChild @@ -15,49 +14,48 @@ import kotlin.test.Test class RootComponentIntegrationTest { @Test - fun WHEN_created_with_deeplink_Web_empty_THEN_TabsChild_active() { - val component = createComponent(deepLink = DeepLink.Web(path = "")) + fun WHEN_created_with_deeplink_empty_THEN_TabsChild_active() { + val component = createComponent(deepLink = Url(url = "https://example.com")) component.stack.assertActiveInstance() } @Test - fun WHEN_created_with_deeplink_Web_unrecognized_THEN_TabsChild_active() { - val component = createComponent(deepLink = DeepLink.Web(path = "/xyz")) + fun WHEN_created_with_deeplink_unrecognized_THEN_TabsChild_active() { + val component = createComponent(deepLink = Url(url = "https://example.com/xyz")) component.stack.assertActiveInstance() } @Test - fun WHEN_created_with_deeplink_Web_DynamicFeatures_THEN_DynamicFeaturesChild_active() { - val component = createComponent(deepLink = DeepLink.Web(path = "/dynamic-features")) + fun WHEN_created_with_deeplink_DynamicFeatures_THEN_DynamicFeaturesChild_active() { + val component = createComponent(deepLink = Url(url = "https://example.com/dynamic_features")) component.stack.assertActiveInstance() } @Test - fun WHEN_created_with_deeplink_Web_CustomNavigationChild_THEN_CustomNavigationChild_active() { - val component = createComponent(deepLink = DeepLink.Web(path = "/custom-navigation")) + fun WHEN_created_with_deeplink_CustomNavigationChild_THEN_CustomNavigationChild_active() { + val component = createComponent(deepLink = Url(url = "https://example.com/custom_navigation")) component.stack.assertActiveInstance() } @Test - fun WHEN_created_with_deeplink_Web_PagesChild_THEN_PagesChild_active() { - val component = createComponent(deepLink = DeepLink.Web(path = "/pages")) + fun WHEN_created_with_deeplink_PagesChild_THEN_PagesChild_active() { + val component = createComponent(deepLink = Url(url = "https://example.com/pages")) component.stack.assertActiveInstance() } - @OptIn(ExperimentalDecomposeApi::class) private fun createComponent( - deepLink: DeepLink = DeepLink.None, + deepLink: Url? = null, ): RootComponent = createComponent { componentContext -> DefaultRootComponent( componentContext = componentContext, featureInstaller = TestFeatureInstaller(), - deepLink = deepLink, + deepLinkUrl = deepLink, ) } } diff --git a/sample/shared/shared/src/commonTest/kotlin/com/arkivanov/sample/shared/tabs/TabsComponentIntegrationTest.kt b/sample/shared/shared/src/commonTest/kotlin/com/arkivanov/sample/shared/tabs/TabsComponentIntegrationTest.kt index 31bbf4feb..44e204972 100644 --- a/sample/shared/shared/src/commonTest/kotlin/com/arkivanov/sample/shared/tabs/TabsComponentIntegrationTest.kt +++ b/sample/shared/shared/src/commonTest/kotlin/com/arkivanov/sample/shared/tabs/TabsComponentIntegrationTest.kt @@ -109,6 +109,7 @@ class TabsComponentIntegrationTest { createComponent { componentContext -> DefaultTabsComponent( componentContext = componentContext, + deepLinkUrl = null, onDynamicFeaturesItemSelected = onDynamicFeaturesItemSelected, onCustomNavigationItemSelected = onCustomNavigationItemSelected, onPagesItemSelected = onPagesItemSelected,