diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 596441e..2ef6541 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -17,7 +17,7 @@ jobs: - name: Set up JDK 1.8 uses: actions/setup-java@v2 with: - java-version: '11' + java-version: '17' distribution: 'adopt' - name: Validate Gradle wrapper uses: gradle/wrapper-validation-action@e6e38bacfdf1a337459f332974bb2327a31aaf4b diff --git a/buildSrc/build.gradle.kts b/build-logic/convention/build.gradle.kts similarity index 71% rename from buildSrc/build.gradle.kts rename to build-logic/convention/build.gradle.kts index b19d173..feaeba6 100644 --- a/buildSrc/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -14,18 +14,21 @@ * limitations under the License. */ -import org.gradle.kotlin.dsl.`kotlin-dsl` - -repositories { - mavenCentral() - gradlePluginPortal() -} - plugins { `kotlin-dsl` } +group = "com.tunjid.treenav.buildlogic" + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + dependencies { + implementation(libs.jetbrains.compose.gradlePlugin) implementation(libs.kotlin.gradlePlugin) + implementation(libs.android.gradlePlugin) + implementation(libs.compose.compiler.plugin) implementation(libs.dokka.gradlePlugin) } diff --git a/build-logic/convention/src/main/kotlin/android-app-library-convention.kt b/build-logic/convention/src/main/kotlin/android-app-library-convention.kt new file mode 100644 index 0000000..a8a42d8 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/android-app-library-convention.kt @@ -0,0 +1,73 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import com.android.build.api.dsl.CommonExtension +import org.gradle.api.JavaVersion +import org.gradle.api.artifacts.Configuration +import org.gradle.api.artifacts.VersionCatalogsExtension + +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Sets common values for Android Applications and Libraries + */ +fun org.gradle.api.Project.androidConfiguration( + extension: CommonExtension<*, *, *, *, *, *> +) = extension.apply { + namespace = "com.tunjid.composables.${project.name}" + compileSdk = 34 + + defaultConfig { + minSdk = 23 + } + + buildFeatures { + compose = true + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + configureKotlinJvm() +} + +fun org.gradle.api.Project.coerceComposeVersion(configuration: Configuration) { + val independentGroups = setOf("compiler", "material3") + configuration.resolutionStrategy.eachDependency { + if (requested.group.startsWith("androidx.compose") && independentGroups.none(requested.group::contains)) { + useVersion(versionCatalog.findVersion("androidxCompose").get().requiredVersion) + because("I need the changes in lazyGrid") + } + } +} + +val org.gradle.api.Project.versionCatalog + get() = extensions.getByType(VersionCatalogsExtension::class.java) + .named("libs") \ No newline at end of file diff --git a/buildSrc/settings.gradle.kts b/build-logic/convention/src/main/kotlin/android-application-convention.gradle.kts similarity index 75% rename from buildSrc/settings.gradle.kts rename to build-logic/convention/src/main/kotlin/android-application-convention.gradle.kts index c69866d..4a86a79 100644 --- a/buildSrc/settings.gradle.kts +++ b/build-logic/convention/src/main/kotlin/android-application-convention.gradle.kts @@ -14,10 +14,17 @@ * limitations under the License. */ -dependencyResolutionManagement { - versionCatalogs { - create("libs") { - from(files("../gradle/libs.versions.toml")) - } - } +plugins { + id("com.android.application") } + +android { + androidConfiguration(this) + + defaultConfig { + targetSdk = 33 + } + configurations.all { + coerceComposeVersion(this) + } +} \ No newline at end of file diff --git a/build-logic/convention/src/main/kotlin/android-library-convention.gradle.kts b/build-logic/convention/src/main/kotlin/android-library-convention.gradle.kts new file mode 100644 index 0000000..00494ba --- /dev/null +++ b/build-logic/convention/src/main/kotlin/android-library-convention.gradle.kts @@ -0,0 +1,36 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id("com.android.library") +} + +android { + androidConfiguration(this) + + sourceSets { + named("main") { + // Pull Android manifest from src/androidMain in multiplatform dirs + if (file("src/androidMain").exists()) { + manifest.srcFile("src/androidMain/AndroidManifest.xml") + res.srcDirs("src/androidMain/res") + } + } + } + configurations.all { + coerceComposeVersion(this) + } +} \ No newline at end of file diff --git a/build-logic/convention/src/main/kotlin/kotlin-jvm-convention.gradle.kts b/build-logic/convention/src/main/kotlin/kotlin-jvm-convention.gradle.kts new file mode 100644 index 0000000..2a64a4d --- /dev/null +++ b/build-logic/convention/src/main/kotlin/kotlin-jvm-convention.gradle.kts @@ -0,0 +1,40 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + kotlin("multiplatform") +} + +kotlin { + configureKotlinJvm() + jvmToolchain(17) +} \ No newline at end of file diff --git a/build-logic/convention/src/main/kotlin/kotlin-jvm-convention.kt b/build-logic/convention/src/main/kotlin/kotlin-jvm-convention.kt new file mode 100644 index 0000000..74096e8 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/kotlin-jvm-convention.kt @@ -0,0 +1,85 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import org.gradle.api.Action +import org.gradle.api.JavaVersion +import org.gradle.api.plugins.JavaPluginExtension +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.withType +import org.jetbrains.kotlin.gradle.dsl.KotlinJvmOptions +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Configure base Kotlin options for JVM (non-Android) + */ +internal fun org.gradle.api.Project.configureKotlinJvm() { + extensions.configure { + // Up to Java 11 APIs are available through desugaring + // https://developer.android.com/studio/write/java11-minimal-support-table + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + configureKotlin() +} + +/** + * Configure base Kotlin options + */ +private fun org.gradle.api.Project.configureKotlin() { + // Use withType to workaround https://youtrack.jetbrains.com/issue/KT-55947 + tasks.withType().configureEach { + kotlinOptions { + // Set JVM target to 11 + jvmTarget = JavaVersion.VERSION_11.toString() + freeCompilerArgs = freeCompilerArgs + listOf( + "-Xuse-experimental=androidx.compose.animation.ExperimentalAnimationApi", + "-Xuse-experimental=androidx.compose.material.ExperimentalMaterialApi", + "-Xuse-experimental=kotlinx.serialization.ExperimentalSerializationApi", + "-Xuse-experimental=kotlinx.coroutines.ExperimentalCoroutinesApi", + "-Xuse-experimental=kotlinx.coroutines.FlowPreview" + ) + } + } + val kotlinOptions = "kotlinOptions" + if (extensions.findByName(kotlinOptions) != null) { + extensions.configure(kotlinOptions, Action { + jvmTarget = JavaVersion.VERSION_11.toString() + freeCompilerArgs = freeCompilerArgs + listOf( + "-Xuse-experimental=androidx.compose.animation.ExperimentalAnimationApi", + "-Xuse-experimental=androidx.compose.material.ExperimentalMaterialApi", + "-Xuse-experimental=kotlinx.serialization.ExperimentalSerializationApi", + "-Xuse-experimental=kotlinx.coroutines.ExperimentalCoroutinesApi", + "-Xuse-experimental=kotlinx.coroutines.FlowPreview" + ) + }) + } +} \ No newline at end of file diff --git a/build-logic/convention/src/main/kotlin/kotlin-library-convention.gradle.kts b/build-logic/convention/src/main/kotlin/kotlin-library-convention.gradle.kts new file mode 100644 index 0000000..f1ac78d --- /dev/null +++ b/build-logic/convention/src/main/kotlin/kotlin-library-convention.gradle.kts @@ -0,0 +1,88 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget +import org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsRootExtension + +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + kotlin("multiplatform") +} + +kotlin { + android() + jvm("desktop") +// js(IR) { +// browser() +// nodejs() +// binaries.executable() +// } + sourceSets { + all { + languageSettings.apply { + optIn("androidx.compose.animation.ExperimentalAnimationApi") + optIn("androidx.compose.foundation.ExperimentalFoundationApi") + optIn("androidx.compose.material.ExperimentalMaterialApi") + optIn("androidx.compose.ui.ExperimentalComposeUiApi") + optIn("kotlinx.serialization.ExperimentalSerializationApi") + optIn("kotlinx.coroutines.ExperimentalCoroutinesApi") + optIn("kotlinx.coroutines.FlowPreview") + } + } + } + + targets.withType(KotlinNativeTarget::class.java) { + binaries.all { + binaryOptions["memoryModel"] = "experimental" + } + } + configureKotlinJvm() + configurations.all { + coerceComposeVersion(this) + } +} + +// a temporary workaround for a bug in jsRun invocation - see https://youtrack.jetbrains.com/issue/KT-48273 +afterEvaluate { + rootProject.extensions.configure { + versions.webpackDevServer.version = "4.0.0" + versions.webpackCli.version = "4.10.0" + nodeVersion = "16.0.0" + } +} + + +// TODO: remove when https://youtrack.jetbrains.com/issue/KT-50778 fixed +project.tasks.withType(org.jetbrains.kotlin.gradle.dsl.KotlinJsCompile::class.java).configureEach { + kotlinOptions.freeCompilerArgs += listOf( + "-Xir-dce-runtime-diagnostic=log" + ) +} diff --git a/build-logic/convention/src/main/kotlin/publishing-library-convention.gradle.kts b/build-logic/convention/src/main/kotlin/publishing-library-convention.gradle.kts new file mode 100644 index 0000000..60fc96c --- /dev/null +++ b/build-logic/convention/src/main/kotlin/publishing-library-convention.gradle.kts @@ -0,0 +1,118 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + `maven-publish` + signing + id("org.jetbrains.dokka") +} + +allprojects { + val versionKey = project.name + "_version" + val libProps = rootProject.ext.get("libProps") as? java.util.Properties + ?: return@allprojects + group = libProps["groupId"] as String + version = libProps[versionKey] as String + + task("printProjectVersion") { + doLast { + println(">> " + project.name + " version is " + version) + } + } +} + +val dokkaHtml by tasks.getting(org.jetbrains.dokka.gradle.DokkaTask::class) { + dokkaSourceSets { + try { + named("iosTest") { + suppress.set(true) + } + } catch (e: Exception) { + } + } +} + +val javadocJar: TaskProvider by tasks.registering(Jar::class) { + dependsOn(dokkaHtml) + archiveClassifier.set("javadoc") + from(dokkaHtml.outputDirectory) +} + +publishing { + publications { + withType { + artifact(javadocJar) + pom { + name.set(project.name) + description.set("A collection of utility composable functions") + url.set("https://github.com/tunjid/composables") + licenses { + license { + name.set("Apache License 2.0") + url.set("https://github.com/tunjid/composables/blob/main/LICENSE") + } + } + developers { + developer { + id.set("tunjid") + name.set("Adetunji Dahunsi") + email.set("tjdah100@gmail.com") + } + } + scm { + connection.set("scm:git:github.com/tunjid/composables.git") + developerConnection.set("scm:git:ssh://github.com/tunjid/composables.git") + url.set("https://github.com/tunjid/composables/tree/main") + } + } + } + } + repositories { + val localProperties = rootProject.ext.get("localProps") as? java.util.Properties + ?: return@repositories + + val publishUrl = localProperties.getProperty("publishUrl") + if (publishUrl != null) { + maven { + name = localProperties.getProperty("repoName") + url = uri(localProperties.getProperty("publishUrl")) + credentials { + username = localProperties.getProperty("username") + password = localProperties.getProperty("password") + } + } + } + } +} + + +signing { + val localProperties = rootProject.ext.get("localProps") as? java.util.Properties + ?: return@signing + + val signingKey = localProperties.getProperty("signingKey") + val signingPassword = localProperties.getProperty("signingPassword") + + if (signingKey != null && signingPassword != null) { + useInMemoryPgpKeys(signingKey, signingPassword) + sign(publishing.publications) + } +} + +val signingTasks = tasks.withType() +tasks.withType().configureEach { + dependsOn(signingTasks) +} \ No newline at end of file diff --git a/treenav/build.gradle.kts b/build-logic/settings.gradle.kts similarity index 53% rename from treenav/build.gradle.kts rename to build-logic/settings.gradle.kts index 23f9377..38f9470 100644 --- a/treenav/build.gradle.kts +++ b/build-logic/settings.gradle.kts @@ -5,7 +5,7 @@ * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * - * http://www.apache.org/licenses/LICENSE-2.0 + * https://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, @@ -14,6 +14,21 @@ * limitations under the License. */ -plugins { - `kotlin-library-convention` -} \ No newline at end of file +enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") + +dependencyResolutionManagement { + repositories { + gradlePluginPortal() + maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") + mavenCentral() + google() + } + versionCatalogs { + create("libs") { + from(files("../gradle/libs.versions.toml")) + } + } +} + +rootProject.name = "build-logic" +include(":convention") diff --git a/build-logic/wrapper/gradle-wrapper.jar b/build-logic/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..f6b961f Binary files /dev/null and b/build-logic/wrapper/gradle-wrapper.jar differ diff --git a/build-logic/wrapper/gradle-wrapper.properties b/build-logic/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..1a48bad --- /dev/null +++ b/build-logic/wrapper/gradle-wrapper.properties @@ -0,0 +1,22 @@ +# +# Copyright 2021 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +#Mon Jul 05 07:23:39 EDT 2021 +distributionBase=GRADLE_USER_HOME +distributionUrl=https\://services.gradle.org/distributions/gradle-8.1-bin.zip +distributionPath=wrapper/dists +zipStorePath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME diff --git a/build.gradle.kts b/build.gradle.kts index 078f951..58cae47 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -32,20 +32,17 @@ buildscript { repositories { google() mavenCentral() - } - - dependencies { - classpath(libs.kotlin.gradlePlugin) - classpath(libs.dokka.gradlePlugin) - - // NOTE: Do not place your application dependencies here; they belong - // in the individual module build.gradle files + maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") + maven("https://plugins.gradle.org/m2/") } } -allprojects { - repositories { - google() - mavenCentral() - } +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.compose.compiler) apply false + alias(libs.plugins.jetbrains.compose) apply false + alias(libs.plugins.jetbrains.dokka) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.multiplatform) apply false } diff --git a/buildSrc/src/main/kotlin/kotlin-library-convention.gradle.kts b/buildSrc/src/main/kotlin/kotlin-library-convention.gradle.kts deleted file mode 100644 index 7992432..0000000 --- a/buildSrc/src/main/kotlin/kotlin-library-convention.gradle.kts +++ /dev/null @@ -1,155 +0,0 @@ -/* - * Copyright 2021 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - - -plugins { - kotlin("multiplatform") - `maven-publish` - signing - id("org.jetbrains.dokka") -} - -kotlin { - js(IR) { - nodejs() - browser() - } - - jvm { - compilations.all { - kotlinOptions.jvmTarget = "1.8" - } - withJava() - testRuns["test"].executionTask.configure { - useJUnit() - } - } - - iosX64() - iosArm64() - iosSimulatorArm64() - linuxX64() - macosX64() - macosArm64() - mingwX64() - tvosArm64() - tvosX64() - tvosSimulatorArm64() - tvosSimulatorArm64() - watchosArm64() - watchosX64() - watchosSimulatorArm64() - - sourceSets { - @Suppress("UnusedPrivateMember") - val commonMain by getting - val commonTest by getting { - dependencies { - implementation(kotlin("test")) - } - } - } -} - -allprojects { - val versionKey = project.name + "_version" - val libProps = parent?.ext?.get("libProps") as? java.util.Properties - ?: return@allprojects - group = libProps["groupId"] as String - version = libProps[versionKey] as String - - task("printProjectVersion") { - doLast { - println(">> " + project.name + " version is " + version) - } - } -} - -val dokkaHtml by tasks.getting(org.jetbrains.dokka.gradle.DokkaTask::class) { - dokkaSourceSets { -// named("iosTest") { -// suppress.set(true) -// } - } -} - -val javadocJar: TaskProvider by tasks.registering(Jar::class) { - dependsOn(dokkaHtml) - archiveClassifier.set("javadoc") - from(dokkaHtml.outputDirectory) -} - - publishing { - publications { - withType { - artifact(javadocJar) - pom { - name.set(project.name) - description.set("A tiny library for representing mutable states and the types that drive said mutations") - url.set("https://github.com/tunjid/Mutator") - licenses { - license { - name.set("Apache License 2.0") - url.set("https://github.com/tunjid/Mutator/blob/main/LICENSE") - } - } - developers { - developer { - id.set("tunjid") - name.set("Adetunji Dahunsi") - email.set("tjdah100@gmail.com") - } - } - scm { - connection.set("scm:git:github.com/tunjid/Mutator.git") - developerConnection.set("scm:git:ssh://github.com/tunjid/Mutator.git") - url.set("https://github.com/tunjid/Mutator/tree/main") - } - } - - } - } - repositories { - val localProperties = parent?.ext?.get("localProps") as? java.util.Properties - ?: return@repositories - - val publishUrl = localProperties.getProperty("publishUrl") - if (publishUrl != null) { - maven { - name = localProperties.getProperty("repoName") - url = uri(localProperties.getProperty("publishUrl")) - credentials { - username = localProperties.getProperty("username") - password = localProperties.getProperty("password") - } - } - } - } - } - - -signing { - val localProperties = parent?.ext?.get("localProps") as? java.util.Properties - ?: return@signing - - val signingKey = localProperties.getProperty("signingKey") - val signingPassword = localProperties.getProperty("signingPassword") - - if (signingKey != null && signingPassword != null) { - useInMemoryPgpKeys(signingKey, signingPassword) - sign(publishing.publications) - } -} diff --git a/gradle.properties b/gradle.properties index e5a6b2f..e0c8261 100644 --- a/gradle.properties +++ b/gradle.properties @@ -13,6 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # - +android.useAndroidX=true kotlin.code.style=official kotlin.js.generate.executable.default=false +org.jetbrains.compose.experimental.jscanvas.enabled=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cecda9e..cd44b37 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,101 @@ [versions] -dokka = "1.8.10" -kotlin = "1.9.22" +androidGradlePlugin = "8.5.2" +androidxActivity = "1.9.2" +activity-compose = "1.9.2" +androidxAppCompat = "1.7.0" +androidxBenchmark = "1.3.0" +androidxCore = "1.13.1" +androidxCompose = "1.7.0" +androidxPaging = "3.3.2" +androidxTestCore = "1.6.1" +androidxTestExt = "1.2.1" +androidxTestRunner = "1.6.2" +androidxTestRules = "1.6.1" +dokka = "1.8.20" +jetbrainsCompose = "1.7.0-rc01" +jetbrainsLifecycle = "2.8.2" +jetbrainsMaterial3Adaptive = "1.0.0-rc01" +junit4 = "4.13.2" +kotlin = "2.0.20" +kotlinxCoroutines = "1.9.0" +kotlinxDatetime = "0.6.1" +lifecycle-runtime = "2.8.6" +tunjidStateHolder = "1.1.0" +tunjidComposables = "0.0.4" +junit = "4.13.2" +runner = "1.0.2" +espressoCore = "3.0.2" +appcompatV7 = "28.0.0" +googleMaterial = "1.12.0" [libraries] +android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" } +compose-compiler-plugin = { group = "org.jetbrains.kotlin", name = "compose-compiler-gradle-plugin", version.ref = "kotlin" } +androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidxAppCompat" } +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidxCore" } +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" } +androidx-compose-animation = { group = "androidx.compose.animation", name = "animation" } +androidx-compose-foundation-foundation = { group = "androidx.compose.foundation", name = "foundation"} +androidx-compose-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout" } +androidx-compose-material-core = { group = "androidx.compose.material", name = "material" } +androidx-compose-material-iconsExtended = { group = "androidx.compose.material", name = "material-icons-extended" } +androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime" } +androidx-compose-ui-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } +androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } +androidx-compose-material3-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-compose-material3-adaptive = { group = "androidx.compose.material3.adaptive", name = "adaptive" } +androidx-compose-material3-adaptive-layout = { group = "androidx.compose.material3.adaptive", name = "adaptive-layout" } +androidx-compose-material3-adaptive-navigation = { group = "androidx.compose.material3.adaptive", name = "adaptive-navigation" } +androidx-compose-material3-windowSizeClass = { group = "androidx.compose.material3", name = "material3-window-size-class" } + +jetbrains-compose-animation = { group = "org.jetbrains.compose.animation", name = "animation", version.ref = "jetbrainsCompose" } +jetbrains-compose-foundation = { group = "org.jetbrains.compose.foundation", name = "foundation", version.ref = "jetbrainsCompose" } +jetbrains-compose-foundation-layout = { group = "org.jetbrains.compose.foundation", name = "foundation-layout", version.ref = "jetbrainsCompose" } +jetbrains-compose-gradlePlugin = { group = "org.jetbrains.compose", name = "compose-gradle-plugin", version.ref = "jetbrainsCompose" } +jetbrains-compose-runtime = { group = "org.jetbrains.compose.runtime", name = "runtime", version.ref = "jetbrainsCompose" } +jetbrains-compose-ui-test = { group = "org.jetbrains.compose.ui", name = "ui-test-junit4", version.ref = "jetbrainsCompose" } +jetbrains-compose-ui-testManifest = { group = "org.jetbrains.compose.ui", name = "ui-test-manifest", version.ref = "jetbrainsCompose" } +#jetbrains-compose-ui-tooling = { group = "org.jetbrains.compose.ui", name = "ui-tooling", version.ref = "jetbrainsCompose" } +jetbrains-compose-ui-tooling = { group = "org.jetbrains.compose.ui", name = "ui-tooling-preview-desktop", version.ref = "jetbrainsCompose" } +jetbrains-compose-ui-ui = { group = "org.jetbrains.compose.ui", name = "ui", version.ref = "jetbrainsCompose" } +jetbrains-compose-ui-util = { group = "org.jetbrains.compose.ui", name = "ui-util", version.ref = "jetbrainsCompose" } +jetbrains-compose-material3 = { group = "org.jetbrains.compose.material3", name = "material3", version.ref = "jetbrainsCompose" } +jetbrains-compose-material3-adaptive-navigation-suite = { group = "org.jetbrains.compose.material3", name = "material3-adaptive-navigation-suite", version.ref = "jetbrainsCompose" } +jetbrains-compose-material3-adaptive = { group = "org.jetbrains.compose.material3.adaptive", name = "adaptive", version.ref = "jetbrainsMaterial3Adaptive" } +jetbrains-compose-material3-adaptive-layout = { group = "org.jetbrains.compose.material3.adaptive", name = "adaptive-layout", version.ref = "jetbrainsMaterial3Adaptive" } +jetbrains-lifecycle-runtime = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime", version.ref = "jetbrainsLifecycle" } +jetbrains-lifecycle-runtime-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "jetbrainsLifecycle" } +jetbrains-lifecycle-viewmodel = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel", version.ref = "jetbrainsLifecycle" } +jetbrains-lifecycle-viewmodel-compose = { group = "org.jetbrains.androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "jetbrainsLifecycle" } + +kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinxCoroutines" } +kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-swing", version.ref = "kotlinxCoroutines" } +kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version.ref = "kotlinxDatetime" } + +tunjid-mutator-core-common = { group = "com.tunjid.mutator", name = "core", version.ref = "tunjidStateHolder" } +tunjid-mutator-coroutines-common = { group = "com.tunjid.mutator", name = "coroutines", version.ref = "tunjidStateHolder" } +tunjid-composables = { group = "com.tunjid.composables", name = "composables", version.ref = "tunjidComposables" } + +google-material = { group = "com.google.android.material", name = "material", version.ref = "googleMaterial" } + dokka-gradlePlugin = { group = "org.jetbrains.dokka", name = "dokka-gradle-plugin", version.ref = "dokka" } -kotlin-gradlePlugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } \ No newline at end of file +kotlin-gradlePlugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +runner = { group = "com.android.support.test", name = "runner", version.ref = "runner" } +espresso-core = { group = "com.android.support.test.espresso", name = "espresso-core", version.ref = "espressoCore" } +appcompat-v7 = { group = "com.android.support", name = "appcompat-v7", version.ref = "appcompatV7" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } +android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" } +androidx-benchmark = { id = "androidx.benchmark", version.ref = "androidxBenchmark" } +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +jetbrains-compose = { id = "org.jetbrains.compose", version.ref = "jetbrainsCompose" } +jetbrains-dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" } \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 7454180..f6b961f 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index c049b62..d990e30 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -5,7 +5,7 @@ # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # -# http://www.apache.org/licenses/LICENSE-2.0 +# https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, @@ -14,8 +14,9 @@ # limitations under the License. # +#Mon Jul 05 07:23:39 EDT 2021 distributionBase=GRADLE_USER_HOME +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.1-bin.zip -zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME diff --git a/library/adaptive/.gitignore b/library/adaptive/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/library/adaptive/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/library/adaptive/build.gradle.kts b/library/adaptive/build.gradle.kts new file mode 100644 index 0000000..13135af --- /dev/null +++ b/library/adaptive/build.gradle.kts @@ -0,0 +1,57 @@ +plugins { + kotlin("multiplatform") + id("publishing-library-convention") + id("kotlin-jvm-convention") + id("maven-publish") + signing + id("org.jetbrains.dokka") + id("org.jetbrains.compose") + alias(libs.plugins.compose.compiler) +} + +kotlin { + applyDefaultHierarchyTemplate() + jvm { + withJava() + testRuns["test"].executionTask.configure { + useJUnit() + } + } + listOf( + iosX64(), + iosArm64(), + iosSimulatorArm64(), + ).forEach { iosTarget -> + iosTarget.binaries.framework { + baseName = "treenav-adaptive" + isStatic = true + } + } + + sourceSets { + commonMain { + dependencies { + implementation(project(":library:treenav")) + + implementation(libs.jetbrains.compose.runtime) + implementation(libs.jetbrains.compose.foundation) + implementation(libs.jetbrains.compose.foundation.layout) +// implementation(libs.jetbrains.compose.material3.adaptive) + + implementation(libs.jetbrains.lifecycle.runtime) + implementation(libs.jetbrains.lifecycle.runtime.compose) + implementation(libs.jetbrains.lifecycle.viewmodel) + implementation(libs.jetbrains.lifecycle.viewmodel.compose) + } + } + val jvmMain by getting + val jvmTest by getting + + all { + languageSettings.apply { + optIn("kotlinx.coroutines.ExperimentalCoroutinesApi") + optIn("kotlinx.coroutines.FlowPreview") + } + } + } +} diff --git a/library/adaptive/proguard-rules.pro b/library/adaptive/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/library/adaptive/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/AdaptiveNavHost.kt b/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/AdaptiveNavHost.kt new file mode 100644 index 0000000..bd43da9 --- /dev/null +++ b/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/AdaptiveNavHost.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.treenav.adaptive + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.tunjid.treenav.Node + +/** + * Creates a host for adaptive navigation for panes [Pane] and destinations [Destination]. + * + * @param state the [AdaptiveNavHostState] producing the [AdaptiveNavHostScope] that provides + * context about the panes in [AdaptiveNavHost]. + * @param modifier The modifier to be applied to the layout. + * @param content [AdaptiveNavHostScope] receiving lambda allowing for placing each pane in its + * appropriate slot. + * + */ +@Composable +fun AdaptiveNavHost( + state: AdaptiveNavHostState, + modifier: Modifier = Modifier, + content: @Composable AdaptiveNavHostScope.() -> Unit +) { + Box( + modifier = modifier + ) { + state.scope().content() + } +} \ No newline at end of file diff --git a/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/AdaptiveNavHostConfiguration.kt b/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/AdaptiveNavHostConfiguration.kt new file mode 100644 index 0000000..30956dd --- /dev/null +++ b/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/AdaptiveNavHostConfiguration.kt @@ -0,0 +1,123 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.treenav.adaptive + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import com.tunjid.treenav.Node + +/** + * Class for configuring an [AdaptiveNavHost] for adapting different navigation + * destinations into different panes from an arbitrary [navigationState]. + * + * @param navigationState the navigation state to be adapted into various panes. + * @param destinationTransform a transform of the [navigationState] to its current destination. + * @param strategyTransform provides the strategy used to adapt the current destination to the + * panes available. + */ +@Stable +class AdaptiveNavHostConfiguration internal constructor( + val navigationState: State, + val destinationTransform: (NavigationState) -> Destination, + val strategyTransform: (destination: Destination) -> AdaptivePaneStrategy +) { + internal val currentDestination: State = derivedStateOf { + destinationTransform(navigationState.value) + } +} + +/** + * Provides an [AdaptiveNavHostConfiguration] for configuring an [AdaptiveNavHost] for + * adapting different navigation destinations into different panes from an arbitrary + * [navigationState]. + * + * @param navigationState the navigation state to be adapted into various panes. + * @param destinationTransform a transform of the [navigationState] to its current destination. + * It is read inside a [derivedStateOf] block, so reads of snapshot + * state objects will be observed. + * @param strategyTransform provides the strategy used to adapt the current destination to the + * panes available. + */ +fun adaptiveNavHostConfiguration( + navigationState: State, + destinationTransform: (NavigationState) -> Destination, + strategyTransform: (destination: Destination) -> AdaptivePaneStrategy +) = AdaptiveNavHostConfiguration( + navigationState = navigationState, + destinationTransform = destinationTransform, + strategyTransform = strategyTransform, +) + +/** + * Creates a new [AdaptiveNavHost] by delegating to [this] and rendering destinations into different panes. + * + * @param destinationTransform a transform of [AdaptiveNavHostConfiguration.navigationState] + * to its current destination. It is read inside a [derivedStateOf] block, so reads of snapshot + * state objects will be observed. + * @param strategyTransform provides the strategy used to adapt the current destination to the + * panes available. + */ +fun AdaptiveNavHostConfiguration.delegated( + destinationTransform: (NavigationState) -> Destination = this@delegated.destinationTransform, + strategyTransform: (destination: Destination) -> AdaptivePaneStrategy +) = adaptiveNavHostConfiguration( + navigationState = this@delegated.navigationState, + destinationTransform = destinationTransform, + strategyTransform = strategyTransform, +) + +/** + * The current destination in a given [paneScope]. + */ +@Composable +internal fun AdaptiveNavHostConfiguration.Destination( + paneScope: AdaptivePaneScope +) { + val current = remember(paneScope.paneState.currentDestination) { + paneScope.paneState.currentDestination + } ?: return + with(strategyTransform(current)) { + val enterAndExit = transitions(paneScope) + with(paneScope) { + Box( + modifier = Modifier.animateEnterExit( + enter = enterAndExit.enter, + exit = enterAndExit.exit + ) + ) { + paneScope.render(current) + } + } + } +} + +/** + * THe current pane mapping to use in the [AdaptiveNavHost]. + */ +@Composable +internal fun AdaptiveNavHostConfiguration.paneMapping(): Map { + val current by currentDestination + return current.let { + strategyTransform(it).paneMapper(it) + } +} \ No newline at end of file diff --git a/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/AdaptiveNavigationState.kt b/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/AdaptiveNavigationState.kt new file mode 100644 index 0000000..dbb3824 --- /dev/null +++ b/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/AdaptiveNavigationState.kt @@ -0,0 +1,41 @@ +package com.tunjid.treenav.adaptive + +import com.tunjid.treenav.Node + +/** + * State providing details about data in each pane [Pane] it hosts. + */ +interface AdaptiveNavigationState { + + fun destinationFor( + pane: Pane, + ): Destination? + + fun adaptationIn( + pane: Pane, + ): Adaptation? +} + +/** + * A description of the process that the layout undertook to adapt to its new configuration. + */ +sealed class Adaptation { + /** + * Destinations were changed in panes + */ + data object Change : Adaptation() + + /** + * Destinations were swapped in between panes + */ + data class Swap( + val from: Pane, + val to: Pane?, + ) : Adaptation() + + /** + * Checks if a [Swap] [Adaptation] involved [pane]. + */ + operator fun Swap.contains(pane: Pane?): Boolean = pane == from || pane == to + +} diff --git a/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/AdaptivePaneScope.kt b/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/AdaptivePaneScope.kt new file mode 100644 index 0000000..6d941e2 --- /dev/null +++ b/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/AdaptivePaneScope.kt @@ -0,0 +1,97 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.treenav.adaptive + +import androidx.compose.animation.AnimatedContentScope +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.tunjid.treenav.Node +import kotlin.jvm.JvmInline + +/** + * Scope for adaptive content that can show up in an arbitrary pane. + */ +@Stable +interface AdaptivePaneScope : AnimatedVisibilityScope { + + /** + * Provides information about the adaptive context that created this [AdaptivePaneScope]. + */ + val paneState: AdaptivePaneState + + /** + * Whether or not this [AdaptivePaneScope] is active in its current pane. It is inactive when + * it is animating out of its [AnimatedVisibilityScope]. + */ + val isActive: Boolean + + /** + * Describes how a destination transitions after an adaptive change + */ + data class Transitions( + val enter: EnterTransition, + val exit: ExitTransition, + ) +} + +/** + * An implementation of [AdaptivePaneScope] that supports animations and shared elements + */ +@Stable +internal class AnimatedAdaptivePaneScope( + paneState: AdaptivePaneState, + activeState: State, + val animatedContentScope: AnimatedContentScope +) : AdaptivePaneScope, AnimatedVisibilityScope by animatedContentScope { + + override var paneState by mutableStateOf(paneState) + + override val isActive: Boolean by activeState +} + +/** + * Information about content in a pane + */ +@Stable +sealed interface AdaptivePaneState { + val currentDestination: Destination? + val pane: Pane? + val adaptation: Adaptation +} + +/** + * [Slot] based implementation of [AdaptivePaneState] + */ +internal data class SlotPaneState( + val slot: Slot?, + val previousDestination: Destination?, + override val currentDestination: Destination?, + override val pane: Pane?, + override val adaptation: Adaptation, +) : AdaptivePaneState + +/** + * A spot taken by an [AdaptivePaneStrategy] that may be moved in from pane to pane. + */ +@JvmInline +internal value class Slot internal constructor(val index: Int) diff --git a/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/AdaptivePaneStrategy.kt b/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/AdaptivePaneStrategy.kt new file mode 100644 index 0000000..c53f882 --- /dev/null +++ b/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/AdaptivePaneStrategy.kt @@ -0,0 +1,43 @@ +package com.tunjid.treenav.adaptive + +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import com.tunjid.treenav.Node + +/** + * Provides adaptive strategy in panes [Pane] for a given navigation destination [Destination]. + */ +@Stable +class AdaptivePaneStrategy internal constructor( + val transitions: AdaptivePaneScope.() -> AdaptivePaneScope.Transitions, + /** + * Defines what route to show in the secondary panel alongside this route + */ + val paneMapper: @Composable (Destination) -> Map, + val render: @Composable AdaptivePaneScope.(Destination) -> Unit +) + +/** + * Allows for defining the adaptation strategy in panes [Pane] for a given navigation destination [Destination]. + * + * @param transitions the transitions to run within each [AdaptivePaneScope]. + * @param paneMapping provides the mapping of panes to destinations for a given destination [Destination]. + * @param render defines the Composable rendered for each destination + * in a given [AdaptivePaneScope]. + */ +fun adaptivePaneStrategy( + transitions: AdaptivePaneScope.() -> AdaptivePaneScope.Transitions = { NoTransition }, + paneMapping: @Composable (Destination) -> Map = { emptyMap() }, + render: @Composable AdaptivePaneScope.(Destination) -> Unit +) = AdaptivePaneStrategy( + paneMapper = paneMapping, + transitions = transitions, + render = render +) + +private val NoTransition = AdaptivePaneScope.Transitions( + enter = EnterTransition.None, + exit = ExitTransition.None, +) \ No newline at end of file diff --git a/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/SavedStateAdaptiveNavHostState.kt b/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/SavedStateAdaptiveNavHostState.kt new file mode 100644 index 0000000..6797ba2 --- /dev/null +++ b/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/SavedStateAdaptiveNavHostState.kt @@ -0,0 +1,288 @@ +package com.tunjid.treenav.adaptive + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.EnterExitState +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.updateTransition +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.movableContentOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.SaveableStateHolder +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.runtime.setValue +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.currentStateAsState +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import com.tunjid.treenav.Node +import com.tunjid.treenav.Order +import com.tunjid.treenav.adaptive.lifecycle.DestinationViewModelStoreCreator +import com.tunjid.treenav.adaptive.lifecycle.rememberDestinationLifecycleOwner +import com.tunjid.treenav.traverse + + +/** + * A host for adaptive navigation for panes [Pane] and destinations [Destination]. + */ +@Stable +interface AdaptiveNavHostState { + + /** + * Creates the scope that provides context about individual panes [Pane] in an [AdaptiveNavHost]. + */ + @Composable + fun scope(): AdaptiveNavHostScope +} + +/** + * Scope that provides context about individual panes [Pane] in an [AdaptiveNavHost]. + */ +@Stable +interface AdaptiveNavHostScope { + + @Composable + fun Destination( + pane: Pane + ) + + fun adaptationIn( + pane: Pane, + ): Adaptation? + + fun nodeFor( + pane: Pane, + ): Destination? +} + +/** + * An implementation of an [AdaptiveNavHostState] that provides a [SaveableStateHolder] for each + * navigation destination that shows up in its panes. + * + * @param panes a list of panes that is possible to show in the [AdaptiveNavHost] in all + * possible configurations. The panes should consist of enum class instances, or a sealed class + * hierarchy of kotlin objects. + * @param configuration the [AdaptiveNavHostConfiguration] that applies adaptive semantics and + * strategies for each navigation destination shown in the [AdaptiveNavHost]. + */ +@Stable +class SavedStateAdaptiveNavHostState( + private val panes: List, + private val configuration: AdaptiveNavHostConfiguration, +) : AdaptiveNavHostState { + + @Composable + override fun scope(): AdaptiveNavHostScope { + val navigationState by configuration.navigationState + val panesToNodes = configuration.paneMapping() + val saveableStateHolder = rememberSaveableStateHolder() + + val adaptiveContentScope = remember { + SavedStateAdaptiveNavHostScope( + panes = panes, + navHostConfiguration = configuration, + initialPanesToNodes = panesToNodes, + saveableStateHolder = saveableStateHolder, + ) + } + + LaunchedEffect(navigationState, panesToNodes) { + adaptiveContentScope.onNewNavigationState( + navigationState = navigationState, + panesToNodes = panesToNodes + ) + } + + return adaptiveContentScope + } + + companion object { + @Stable + private class SavedStateAdaptiveNavHostScope( + panes: List, + initialPanesToNodes: Map, + saveableStateHolder: SaveableStateHolder, + val navHostConfiguration: AdaptiveNavHostConfiguration, + ) : AdaptiveNavHostScope, SaveableStateHolder by saveableStateHolder { + + private val destinationViewModelStoreCreator = DestinationViewModelStoreCreator( + rootNodeProvider = navHostConfiguration.navigationState::value + ) + + val slots = List( + size = panes.size, + init = ::Slot + ).toSet() + + var adaptiveNavigationState by mutableStateOf( + value = SlotBasedAdaptiveNavigationState.initial(slots = slots) + .adaptTo( + slots = slots, + panesToNodes = initialPanesToNodes, + backStackIds = navHostConfiguration.navigationState.value.backStackIds(), + ) + ) + + private val slotsToRoutes = + mutableStateMapOf Unit>().also { map -> + map[null] = {} + slots.forEach { slot -> + map[slot] = movableContentOf { Render(slot) } + } + } + + @Composable + override fun Destination(pane: Pane) { + val slot = adaptiveNavigationState.slotFor(pane) + slotsToRoutes[slot]?.invoke() + } + + override fun adaptationIn( + pane: Pane + ): Adaptation? = adaptiveNavigationState.adaptationIn(pane) + + override fun nodeFor( + pane: Pane + ): Destination? = adaptiveNavigationState.destinationFor(pane) + + fun onNewNavigationState( + navigationState: Node, + panesToNodes: Map, + ) { + updateAdaptiveNavigationState { + adaptTo( + slots = slots.toSet(), + panesToNodes = panesToNodes, + backStackIds = navigationState.backStackIds() + ) + } + } + + /** + * Renders [slot] into its pane with scopes that allow for animations + * and shared elements. + */ + @Composable + private fun Render( + slot: Slot, + ) { + val paneTransition = updateTransition( + targetState = adaptiveNavigationState.paneStateFor(slot), + label = "$slot-PaneTransition", + ) + paneTransition.AnimatedContent( + contentKey = { it.currentDestination?.id }, + transitionSpec = { + ContentTransform( + targetContentEnter = EnterTransition.None, + initialContentExit = ExitTransition.None, + sizeTransform = null, + ) + } + ) { targetPaneState -> + val scope = remember { + AnimatedAdaptivePaneScope( + paneState = targetPaneState, + activeState = derivedStateOf { + val activePaneState = adaptiveNavigationState.paneStateFor(slot) + activePaneState.currentDestination?.id == targetPaneState.currentDestination?.id + }, + animatedContentScope = this@AnimatedContent, + ) + } + + // While technically a backwards write, it stabilizes and ensures the values are + // correct at first composition + scope.paneState = targetPaneState + + val destination = targetPaneState.currentDestination + if (destination != null) { + val destinationLifecycleOwner = rememberDestinationLifecycleOwner( + destination + ) + val destinationViewModelOwner = destinationViewModelStoreCreator + .viewModelStoreOwnerFor(destination) + + CompositionLocalProvider( + LocalLifecycleOwner provides destinationLifecycleOwner, + LocalViewModelStoreOwner provides destinationViewModelOwner, + ) { + SaveableStateProvider(destination.id) { + navHostConfiguration.Destination(paneScope = scope) + + DisposableEffect(Unit) { + onDispose { + val backstackIds = adaptiveNavigationState.backStackIds + if (!backstackIds.contains(destination.id)) removeState( + destination.id + ) + } + } + + val hostLifecycleState by destinationLifecycleOwner.hostLifecycleState.currentStateAsState() + DisposableEffect( + hostLifecycleState, + scope.isActive, + adaptiveNavigationState, + ) { + destinationLifecycleOwner.update( + hostLifecycleState = hostLifecycleState, + adaptivePaneScope = scope, + adaptiveNavigationState = adaptiveNavigationState + ) + onDispose { + destinationLifecycleOwner.update( + hostLifecycleState = hostLifecycleState, + adaptivePaneScope = scope, + adaptiveNavigationState = adaptiveNavigationState + ) + } + } + } + } + } + + // Add destination ids that are animating out + LaunchedEffect(transition.isRunning) { + if (transition.targetState == EnterExitState.PostExit) { + val destinationId = targetPaneState.currentDestination?.id + ?: return@LaunchedEffect + updateAdaptiveNavigationState { + copy(destinationIdsAnimatingOut = destinationIdsAnimatingOut + destinationId) + } + } + } + // Remove route ids that have animated out + DisposableEffect(Unit) { + onDispose { + val routeId = targetPaneState.currentDestination?.id ?: return@onDispose + updateAdaptiveNavigationState { + copy(destinationIdsAnimatingOut = destinationIdsAnimatingOut - routeId).prune() + } + targetPaneState.currentDestination?.let(destinationViewModelStoreCreator::clearStoreFor) + } + } + } + } + + private inline fun updateAdaptiveNavigationState( + block: SlotBasedAdaptiveNavigationState.() -> SlotBasedAdaptiveNavigationState + ) { + adaptiveNavigationState = adaptiveNavigationState.block() + } + } + + private fun Node.backStackIds() = + mutableSetOf().apply { + traverse(Order.DepthFirst) { add(it.id) } + } + } +} diff --git a/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/SlotBasedAdaptiveNavigationState.kt b/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/SlotBasedAdaptiveNavigationState.kt new file mode 100644 index 0000000..cdb1367 --- /dev/null +++ b/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/SlotBasedAdaptiveNavigationState.kt @@ -0,0 +1,219 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.treenav.adaptive + +import androidx.compose.runtime.Immutable +import com.tunjid.treenav.Node +import com.tunjid.treenav.adaptive.Adaptation.Change.contains + +/** + * Data structure for managing navigation as it adapts to various layout configurations + */ +@Immutable +internal data class SlotBasedAdaptiveNavigationState( + /** + * Moves between panes within a navigation sequence. + */ + val swapAdaptations: Set>, + /** + * A mapping of [Pane] to the nodes in them + */ + val panesToDestinations: Map, + /** + * A mapping of adaptive pane to the nodes that were last in them. + */ + val previousPanesToDestinations: Map, + /** + * A mapping of node ids to the adaptive slots they are currently in. + */ + val destinationIdsToAdaptiveSlots: Map, + /** + * A set of node ids that may be returned to. + */ + val backStackIds: Set, + /** + * A set of node ids that are animating out. + */ + val destinationIdsAnimatingOut: Set, +) : AdaptiveNavigationState { + companion object { + internal fun initial( + slots: Collection, + ): SlotBasedAdaptiveNavigationState = SlotBasedAdaptiveNavigationState( + swapAdaptations = emptySet(), + panesToDestinations = emptyMap(), + destinationIdsToAdaptiveSlots = slots.associateBy( + keySelector = Slot::toString + ), + backStackIds = emptySet(), + destinationIdsAnimatingOut = emptySet(), + previousPanesToDestinations = emptyMap(), + ) + } + + internal fun paneStateFor( + slot: Slot + ): AdaptivePaneState { + val node = destinationFor(slot) + val pane = node?.let(::paneFor) + return SlotPaneState( + slot = slot, + currentDestination = node, + previousDestination = previousPanesToDestinations[pane], + pane = pane, + adaptation = swapAdaptations.firstOrNull { pane in it } + ?: Adaptation.Change, + ) + } + + internal fun slotFor( + pane: Pane + ): Slot? = destinationIdsToAdaptiveSlots[ + panesToDestinations[pane]?.id + ] + + private fun paneFor( + node: Node + ): Pane? = panesToDestinations.firstNotNullOfOrNull { (pane, paneRoute) -> + if (paneRoute?.id == node.id) pane else null + } + + private fun destinationFor( + slot: Slot + ): Destination? = destinationIdsToAdaptiveSlots.firstNotNullOfOrNull { (nodeId, nodeSlot) -> + if (nodeSlot == slot) panesToDestinations.firstNotNullOfOrNull { (_, node) -> + if (node?.id == nodeId) node + else null + } + else null + } + + override fun destinationFor( + pane: Pane + ): Destination? = panesToDestinations[pane] + + override fun adaptationIn( + pane: Pane + ): Adaptation? = swapAdaptations.firstOrNull { pane in it } +} + +/** + * A method that adapts changes in navigation to different panes while allowing for them + * to be animated easily. + */ +internal fun SlotBasedAdaptiveNavigationState.adaptTo( + slots: Set, + panesToNodes: Map, + backStackIds: Set, +): SlotBasedAdaptiveNavigationState { + val previous = this + + val previouslyUsedSlots = previous.destinationIdsToAdaptiveSlots + .filter { it.key != null } + .values + .toSet() + + // Sort by most recently used to makes sure most recently used slots + // are reused so animations run. + val availableSlots = slots + .sortedByDescending(previouslyUsedSlots::contains) + .toMutableSet() + + val unplacedNodeIds = panesToNodes.values.mapNotNull { it?.id }.toMutableSet() + + val nodeIdsToAdaptiveSlots = mutableMapOf() + val swapAdaptations = mutableSetOf>() + + // Process nodes that swapped panes from old to new + for ((toPane, toNode) in panesToNodes.entries) { + if (toNode == null) continue + for ((fromPane, fromNode) in previous.panesToDestinations.entries) { + // Find a previous node from the last state + if (toNode.id != fromNode?.id) continue + val swap = Adaptation.Swap( + from = fromPane, + to = toPane + ) + // The panes are different, a swap occurred + if (toPane != fromPane) { + swapAdaptations.add(swap) + } + + // Since this node was swapped, preserve its existing slot + val fromNodeId = checkNotNull(fromNode.id) + check(unplacedNodeIds.remove(fromNodeId)) { + "A swap cannot have occurred if the node did not exist in the previous state" + } + val reusedSlot = previous.destinationIdsToAdaptiveSlots.getValue(fromNodeId) + check(availableSlots.remove(reusedSlot)) { + "A swap cannot have occurred if the node did not exist in the previous state" + } + nodeIdsToAdaptiveSlots[fromNodeId] = reusedSlot + break + } + } + + // All swaps have been processed; place remaining changes nodes in slots available. + unplacedNodeIds.forEach { nodeId -> + nodeIdsToAdaptiveSlots[nodeId] = availableSlots.first().also(availableSlots::remove) + } + + return SlotBasedAdaptiveNavigationState( + // If the values of the nodes to panes are the same, no swaps occurred. + swapAdaptations = when (previous.panesToDestinations.mapValues { it.value?.id }) { + panesToNodes.mapValues { it.value?.id } -> previous.swapAdaptations + else -> swapAdaptations + }, + previousPanesToDestinations = previous.previousPanesToDestinations.keys.associateWith( + valueSelector = previous::destinationFor + ), + destinationIdsToAdaptiveSlots = nodeIdsToAdaptiveSlots, + backStackIds = backStackIds, + panesToDestinations = panesToNodes, + destinationIdsAnimatingOut = previous.destinationIdsAnimatingOut, + ) + +} + +/** + * Checks if any of the new routes coming in has any conflicts with those animating out. + */ +internal fun SlotBasedAdaptiveNavigationState.hasConflictingRoutes(): Boolean = + panesToDestinations.keys + .map(::destinationFor) + .any { + it?.id?.let(destinationIdsAnimatingOut::contains) == true + } + +/** + * Trims unneeded metadata from the [AdaptiveNavigationState] + */ +internal fun SlotBasedAdaptiveNavigationState.prune(): SlotBasedAdaptiveNavigationState = + copy( + destinationIdsToAdaptiveSlots = destinationIdsToAdaptiveSlots.filter { (routeId) -> + if (routeId == null) return@filter false + backStackIds.contains(routeId) + || destinationIdsAnimatingOut.contains(routeId) + || previousPanesToDestinations.values.map { it?.id }.toSet().contains(routeId) + }, + previousPanesToDestinations = previousPanesToDestinations.filter { (_, route) -> + if (route == null) return@filter false + backStackIds.contains(route.id) + || destinationIdsAnimatingOut.contains(route.id) + || previousPanesToDestinations.values.map { it?.id }.toSet().contains(route.id) + } + ) \ No newline at end of file diff --git a/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/lifecycle/DestinationLifecycleOwner.kt b/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/lifecycle/DestinationLifecycleOwner.kt new file mode 100644 index 0000000..a506e0d --- /dev/null +++ b/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/lifecycle/DestinationLifecycleOwner.kt @@ -0,0 +1,60 @@ +package com.tunjid.treenav.adaptive.lifecycle + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.Lifecycle.State +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.tunjid.treenav.Node +import com.tunjid.treenav.adaptive.AdaptivePaneScope +import com.tunjid.treenav.adaptive.SlotBasedAdaptiveNavigationState + +@Composable +internal fun rememberDestinationLifecycleOwner( + destination: Node, +): DestinationLifecycleOwner { + val hostLifecycleOwner = LocalLifecycleOwner.current + val destinationLifecycleOwner = remember(hostLifecycleOwner) { + DestinationLifecycleOwner( + destination = destination, + host = hostLifecycleOwner + ) + } + return destinationLifecycleOwner +} + +@Stable +internal class DestinationLifecycleOwner( + private val destination: Node, + private val host: LifecycleOwner +) : LifecycleOwner { + + private val lifecycleRegistry = LifecycleRegistry(this) + + override val lifecycle: Lifecycle + get() = lifecycleRegistry + + val hostLifecycleState = host.lifecycle + + fun update( + hostLifecycleState: State, + adaptivePaneScope: AdaptivePaneScope<*, *>, + adaptiveNavigationState: SlotBasedAdaptiveNavigationState<*, *>, + ) { + val active = adaptivePaneScope.isActive + val exists = adaptiveNavigationState.backStackIds.contains( + destination.id + ) + val derivedLifecycleState = when { + !exists -> State.DESTROYED + !active -> State.STARTED + else -> hostLifecycleState + } + lifecycleRegistry.currentState = + if (host.lifecycle.currentState.ordinal < derivedLifecycleState.ordinal) hostLifecycleState + else derivedLifecycleState + } +} \ No newline at end of file diff --git a/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/lifecycle/DestinationViewModelStoreCreator.kt b/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/lifecycle/DestinationViewModelStoreCreator.kt new file mode 100644 index 0000000..596073a --- /dev/null +++ b/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/lifecycle/DestinationViewModelStoreCreator.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.treenav.adaptive.lifecycle + +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import com.tunjid.treenav.Node +import com.tunjid.treenav.Order +import com.tunjid.treenav.flatten + +@Stable +internal class DestinationViewModelStoreCreator( + private val rootNodeProvider: () -> Node +) { + private val nodeIdsToViewModelStoreOwner = mutableMapOf() + + /** + * Creates a [ViewModelStoreOwner] for a given [Node] + */ + fun viewModelStoreOwnerFor( + node: Node + ): ViewModelStoreOwner = nodeIdsToViewModelStoreOwner.getOrPut( + node.id + ) { + object : ViewModelStoreOwner { + override val viewModelStore: ViewModelStore = ViewModelStore() + } + } + + fun clearStoreFor(childNode: Node) { + val rootNode = rootNodeProvider() + val existingNodeIds = rootNode.flatten(Order.BreadthFirst).mapTo( + destination = mutableSetOf(), + transform = Node::id + ) + if (existingNodeIds.contains(childNode.id)) { + return + } + nodeIdsToViewModelStoreOwner.remove(childNode.id) + ?.viewModelStore + ?.clear() + } +} \ No newline at end of file diff --git a/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/moveablesharedelement/BoundsTransformDeferredAnimation.kt b/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/moveablesharedelement/BoundsTransformDeferredAnimation.kt new file mode 100644 index 0000000..0902923 --- /dev/null +++ b/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/moveablesharedelement/BoundsTransformDeferredAnimation.kt @@ -0,0 +1,180 @@ +package com.tunjid.scaffold.treenav.adaptive.moveablesharedelement + + +import androidx.compose.animation.BoundsTransform +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector4D +import androidx.compose.animation.core.VectorConverter +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.isSpecified +import androidx.compose.ui.geometry.isUnspecified +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.LookaheadScope +import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.layout.positionInParent +import androidx.compose.ui.unit.round +import androidx.compose.ui.unit.roundToIntSize +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.launch + +@OptIn(ExperimentalSharedTransitionApi::class) +internal class BoundsTransformDeferredAnimation { + private var animatable: Animatable? = null + + private var targetSize: Size = Size.Unspecified + private var targetOffset: Offset = Offset.Unspecified + + private var isPending = false + + /** + * Captures lookahead size, updates current size for the first pass and marks the animation as + * pending. + */ + fun updateTargetSize(size: Size) { + if (targetSize.isSpecified && size.roundToIntSize() != targetSize.roundToIntSize()) { + // Change in target, animation is pending + isPending = true + } + targetSize = size + + if (currentSize.isUnspecified) { + currentSize = size + } + } + + /** + * Captures lookahead position, updates current position for the first pass and marks the + * animation as pending. + */ + private fun updateTargetOffset(offset: Offset) { + if (targetOffset.isSpecified && offset.round() != targetOffset.round()) { + isPending = true + } + targetOffset = offset + + if (currentPosition.isUnspecified) { + currentPosition = offset + } + } + + // We capture the current bounds parameters individually to avoid unnecessary Rect allocations + private var currentPosition: Offset = Offset.Unspecified + var currentSize: Size = Size.Unspecified + + val currentBounds: Rect? + get() { + val size = currentSize + val position = currentPosition + return if (position.isSpecified && size.isSpecified) { + Rect(position, size) + } else { + null + } + } + + fun updateCurrentBounds(position: Offset, size: Size) { + currentPosition = position + currentSize = size + } + + val isIdle: Boolean + get() = !isPending && animatable?.isRunning != true + + var animatedValue: Rect? by mutableStateOf(null) + private set + +// val value: Rect? +// get() = if (isIdle) null else animatedValue + + private var directManipulationParents: MutableList? = null + private var additionalOffset: Offset = Offset.Zero + + fun updateTargetOffsetAndAnimate( + lookaheadScope: LookaheadScope, + placementScope: Placeable.PlacementScope, + coroutineScope: CoroutineScope, + includeMotionFrameOfReference: Boolean, + boundsTransform: BoundsTransform, + ) { + placementScope.coordinates?.let { coordinates -> + with(lookaheadScope) { + val lookaheadScopeCoordinates = placementScope.lookaheadScopeCoordinates + + var delta = Offset.Zero + if (!includeMotionFrameOfReference) { + // As the Layout changes, we need to keep track of the accumulated offset up + // the hierarchy tree, to get the proper Offset accounting for scrolling. + val parents = directManipulationParents ?: mutableListOf() + var currentCoords = coordinates + var index = 0 + + // Find the given lookahead coordinates by traversing up the tree + while (currentCoords.toLookaheadCoordinates() != lookaheadScopeCoordinates) { + if (currentCoords.introducesMotionFrameOfReference) { + if (parents.size == index) { + parents.add(currentCoords) + delta += currentCoords.positionInParent() + } else if (parents[index] != currentCoords) { + delta -= parents[index].positionInParent() + parents[index] = currentCoords + delta += currentCoords.positionInParent() + } + index++ + } + currentCoords = currentCoords.parentCoordinates ?: break + } + + for (i in parents.size - 1 downTo index) { + delta -= parents[i].positionInParent() + parents.removeAt(parents.size - 1) + } + directManipulationParents = parents + } + additionalOffset += delta + + val targetOffset = + lookaheadScopeCoordinates.localLookaheadPositionOf( + sourceCoordinates = coordinates, + includeMotionFrameOfReference = includeMotionFrameOfReference + ) + updateTargetOffset(targetOffset + additionalOffset) + + animatedValue = + animate(coroutineScope = coroutineScope, boundsTransform = boundsTransform) + .translate(-(additionalOffset)) + } + } + } + + private fun animate( + coroutineScope: CoroutineScope, + boundsTransform: BoundsTransform, + ): Rect { + if (targetOffset.isSpecified && targetSize.isSpecified) { + // Initialize Animatable when possible, we might not use it but we need to have it + // instantiated since at the first pass the lookahead information will become the + // initial bounds when we actually need an animation. + val target = Rect(targetOffset, targetSize) + val anim = animatable ?: Animatable(target, Rect.VectorConverter) + animatable = anim + + // This check should avoid triggering an animation on the first pass, as there would not + // be enough information to have a distinct current and target bounds. + if (isPending) { + isPending = false + coroutineScope.launch(start = CoroutineStart.UNDISPATCHED) { + // Dispatch right away to make sure approach callbacks are accurate on `isIdle` + anim.animateTo(target, boundsTransform.transform(currentBounds!!, target)) + } + } + } + return animatable?.value ?: Rect.Zero + } +} diff --git a/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/moveablesharedelement/MovableSharedElementState.kt b/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/moveablesharedelement/MovableSharedElementState.kt new file mode 100644 index 0000000..65a8aa3 --- /dev/null +++ b/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/moveablesharedelement/MovableSharedElementState.kt @@ -0,0 +1,234 @@ +package com.tunjid.scaffold.treenav.adaptive.moveablesharedelement + +import androidx.compose.animation.BoundsTransform +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.movableContentOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.isUnspecified +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.graphics.drawscope.translate +import androidx.compose.ui.graphics.layer.GraphicsLayer +import androidx.compose.ui.graphics.layer.drawLayer +import androidx.compose.ui.graphics.rememberGraphicsLayer +import androidx.compose.ui.layout.approachLayout +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.round +import androidx.compose.ui.unit.roundToIntSize +import androidx.compose.ui.unit.toOffset +import androidx.compose.ui.unit.toSize +import androidx.compose.ui.util.fastRoundToInt +import com.tunjid.treenav.Node +import com.tunjid.treenav.adaptive.AdaptivePaneScope +import com.tunjid.treenav.adaptive.AdaptivePaneState +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.first + +@Stable +@OptIn(ExperimentalSharedTransitionApi::class) +internal class MovableSharedElementState( + paneScope: AdaptivePaneScope, + sharedTransitionScope: SharedTransitionScope, + sharedElement: @Composable (State, Modifier) -> Unit, + onRemoved: () -> Unit, + private val boundsTransform: BoundsTransform, + private val canAnimateOnStartingFrames: AdaptivePaneState.() -> Boolean +) : SharedElementOverlay, SharedTransitionScope by sharedTransitionScope { + + var paneScope by mutableStateOf(paneScope) + + private var inCount by mutableIntStateOf(0) + + private var layer: GraphicsLayer? = null + private var targetOffset by mutableStateOf(IntOffset.Zero) + private var boundsAnimInProgress by mutableStateOf(false) + + private val canDrawInOverlay get() = boundsAnimInProgress + private val panesKeysToSeenCount = mutableStateMapOf() + + private val animatedBounds: Rect? + get() = if (boundsAnimInProgress) boundsAnimation.animatedValue else null + + val boundsAnimation = BoundsTransformDeferredAnimation() + + val moveableSharedElement: @Composable (Any?, Modifier) -> Unit = + movableContentOf { state, modifier -> + @Suppress("UNCHECKED_CAST") + sharedElement( + // The shared element composable will be created by the first screen and reused by + // subsequent screens. This updates the state from other screens so changes are seen. + state as State, + Modifier + .movableSharedElement( + state = this, + ) then modifier, + ) + + DisposableEffect(Unit) { + ++inCount + onDispose { + if (--inCount <= 0) onRemoved() + } + } + } + + override fun ContentDrawScope.drawInOverlay() { + if (!canDrawInOverlay) return + val overlayLayer = layer ?: return + val (x, y) = targetOffset.toOffset() + translate(x, y) { + drawLayer(overlayLayer) + } + } + + private fun updatePaneStateSeen( + paneState: AdaptivePaneState<*, *> + ) { + panesKeysToSeenCount[paneState.key] = Unit + } + + private val hasBeenShared get() = panesKeysToSeenCount.size > 1 + + companion object { + /** + * Allows a custom modifier to animate the local position and size of the layout within the + * LookaheadLayout, whenever there's a change in the layout. + */ + @OptIn( + ExperimentalSharedTransitionApi::class + ) + @Composable + internal fun Modifier.movableSharedElement( + state: MovableSharedElementState<*, Pane, Destination>, + ): Modifier { + val coroutineScope = rememberCoroutineScope() + state.isInProgress().also { state.boundsAnimInProgress = it } + val layer = rememberGraphicsLayer().also { + state.layer = it + } + return approachLayout( + isMeasurementApproachInProgress = { lookaheadSize -> + // Update target size, it will serve to know if we expect an approach in progress + state.boundsAnimation.updateTargetSize(lookaheadSize.toSize()) + state.boundsAnimInProgress + }, + isPlacementApproachInProgress = { + state.boundsAnimation.updateTargetOffsetAndAnimate( + lookaheadScope = state, + placementScope = this, + coroutineScope = coroutineScope, + includeMotionFrameOfReference = true, + boundsTransform = state.boundsTransform, + ) + state.boundsAnimInProgress + }, + approachMeasure = { measurable, _ -> + // The animated value is null on the first frame as we don't get the full bounds + // information until placement, so we can safely use the current Size. + val fallbackSize = + // When using Intrinsics, we may get measured before getting the approach check + if (state.boundsAnimation.currentSize.isUnspecified) lookaheadSize.toSize() + else state.boundsAnimation.currentSize + + val (animatedWidth, animatedHeight) = + (state.animatedBounds?.size ?: fallbackSize).roundToIntSize() + + // For the target Layout, pass the animated size as Constraints. + val placeable = measurable.measure( + Constraints.fixed( + width = animatedWidth, + height = animatedHeight, + ) + ) + layout(animatedWidth, animatedHeight) { + val animatedBounds = state.animatedBounds + val currentCoordinates = coordinates ?: return@layout placeable.place( + x = 0, + y = 0 + ) + val positionInScope = with(state) { + lookaheadScopeCoordinates.localPositionOf( + sourceCoordinates = currentCoordinates, + relativeToSource = Offset.Zero, + includeMotionFrameOfReference = true, + ) + } + + val topLeft = + if (animatedBounds != null) { + state.boundsAnimation.updateCurrentBounds( + animatedBounds.topLeft, + animatedBounds.size + ) + animatedBounds.topLeft + } else { + state.boundsAnimation.currentBounds?.topLeft ?: Offset.Zero + } + state.targetOffset = topLeft.round() + + val (x, y) = topLeft - positionInScope + placeable.place(x.fastRoundToInt(), y.fastRoundToInt()) + } + } + ) + .drawWithContent { + layer.record { + this@drawWithContent.drawContent() + } + if (!state.canDrawInOverlay) { + drawLayer(layer) + } + } + } + + + @Composable + private fun MovableSharedElementState<*, Pane, Destination>.isInProgress(): Boolean { + val paneState = paneScope.paneState.also(::updatePaneStateSeen) + + val (laggingScopeKey, animationInProgressTillFirstIdle) = produceState( + initialValue = Pair( + paneState.key, + paneState.canAnimateOnStartingFrames() + ), + key1 = paneState.key + ) { + value = Pair( + paneState.key, + paneState.canAnimateOnStartingFrames() + ) + value = snapshotFlow { boundsAnimation.isIdle } + .debounce { if (it) 10 else 0 } + .first(true::equals) + .let { value.first to false } + }.value + + + if (!hasBeenShared) return false + + val isLagging = laggingScopeKey != paneScope.paneState.key + val canAnimateOnStartingFrames = paneScope.paneState.canAnimateOnStartingFrames() + + if (isLagging) return canAnimateOnStartingFrames + + return animationInProgressTillFirstIdle + } + } +} + +private val AdaptivePaneState<*, *>.key get() = "${currentDestination?.id}-$pane" diff --git a/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/moveablesharedelement/MovableSharedElements.kt b/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/moveablesharedelement/MovableSharedElements.kt new file mode 100644 index 0000000..7d2b5ff --- /dev/null +++ b/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/moveablesharedelement/MovableSharedElements.kt @@ -0,0 +1,160 @@ +package com.tunjid.scaffold.treenav.adaptive.moveablesharedelement + +import androidx.compose.animation.BoundsTransform +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.VisibilityThreshold +import androidx.compose.animation.core.spring +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import com.tunjid.treenav.Node +import com.tunjid.treenav.adaptive.AdaptiveNavHost +import com.tunjid.treenav.adaptive.AdaptivePaneScope +import com.tunjid.treenav.adaptive.AdaptivePaneState + +internal interface SharedElementOverlay { + fun ContentDrawScope.drawInOverlay() +} + +/** + * Creates movable shared elements that may be shared amongst different [AdaptivePaneScope] + * instances. + */ +interface MovableSharedElementScope { + + /** + * Creates a movable shared element that accepts a single argument [T] and a [Modifier]. + * + * NOTE: It is an error to compose the movable shared element in different locations + * simultaneously, and the behavior of the shared element is undefined in this case. + * + * @param key the shared element key to identify the movable shared element. + * @param sharedElement a factory function to create the shared element if it does not + * currently exist. + */ + @OptIn(ExperimentalSharedTransitionApi::class) + fun movableSharedElementOf( + key: Any, + boundsTransform: BoundsTransform = DefaultBoundsTransform, + sharedElement: @Composable (T, Modifier) -> Unit + ): @Composable (T, Modifier) -> Unit +} + +/** + * State for managing movable shared elements within a single [AdaptiveNavHost]. + */ +@OptIn(ExperimentalSharedTransitionApi::class) +@Stable +class MovableSharedElementHostState( + private val sharedTransitionScope: SharedTransitionScope, + private val canAnimateOnStartingFrames: (AdaptivePaneState) -> Boolean, +) { + + // TODO: This should be unnecessary. Figure out a way to participate arbitrarily in the + // overlays already implemented in [SharedTransitionScope]. + /** + * A [Modifier] for drawing the movable shared element in overlays over existing content. + */ + val modifier = Modifier.drawWithContent { + drawContent() + overlays.forEach { overlay -> + with(overlay) { + drawInOverlay() + } + } + } + + private val overlays: Collection + get() = keysToMovableSharedElements.values + + private val keysToMovableSharedElements = + mutableStateMapOf>() + + /** + * Returns true is a given shared element under a given key is currently being shared. + */ + fun isCurrentlyShared(key: Any): Boolean = + keysToMovableSharedElements.contains(key) + + /** + * Provides a movable shared element that can be rendered in a given [AdaptivePaneScope]. + * It is the callers responsibility to perform other verifications on the ability + * of the calling [AdaptivePaneScope] to render the movable shared element. + */ + fun AdaptivePaneScope.createOrUpdateSharedElement( + key: Any, + boundsTransform: BoundsTransform, + sharedElement: @Composable (S, Modifier) -> Unit, + ): @Composable (S, Modifier) -> Unit { + val movableSharedElementState = keysToMovableSharedElements.getOrPut(key) { + MovableSharedElementState( + paneScope = this, + sharedTransitionScope = sharedTransitionScope, + sharedElement = sharedElement, + boundsTransform = boundsTransform, + canAnimateOnStartingFrames = canAnimateOnStartingFrames, + onRemoved = { keysToMovableSharedElements.remove(key) } + ) + }.also { it.paneScope = this } + + // Can't really guarantee that the caller will use the same key for the right type + return movableSharedElementState.moveableSharedElement + } +} + +/** + * An implementation of [MovableSharedElementScope] that ensures shared elements are only rendered + * in an [AdaptivePaneScope] when it is active. + * + * Other implementations of [MovableSharedElementScope] may delegate to this for their own + * movable shared element implementations. + */ +@Stable +internal class AdaptiveMovableSharedElementScope( + paneScope: AdaptivePaneScope, + private val movableSharedElementHostState: MovableSharedElementHostState, +) : MovableSharedElementScope { + + var paneScope by mutableStateOf(paneScope) + + @OptIn(ExperimentalSharedTransitionApi::class) + override fun movableSharedElementOf( + key: Any, + boundsTransform: BoundsTransform, + sharedElement: @Composable (T, Modifier) -> Unit + ): @Composable (T, Modifier) -> Unit { + // This pane state may be animating out. Look up the actual current route + // Do not use the shared element if this content is being animated out + if (!paneScope.isActive) return emptyComposable() + + return with(movableSharedElementHostState) { + paneScope.createOrUpdateSharedElement( + key = key, + boundsTransform = boundsTransform, + sharedElement = sharedElement + ) + } + } +} + +private fun emptyComposable(): @Composable (T, Modifier) -> Unit = EMPTY_COMPOSABLE + +private val EMPTY_COMPOSABLE: @Composable (Any?, Modifier) -> Unit = { _, _ -> } + +@OptIn(ExperimentalSharedTransitionApi::class) +private val DefaultBoundsTransform = BoundsTransform { _, _ -> + spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + visibilityThreshold = Rect.VisibilityThreshold + ) +} \ No newline at end of file diff --git a/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/threepane/ThreePane.kt b/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/threepane/ThreePane.kt new file mode 100644 index 0000000..508f0f8 --- /dev/null +++ b/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/threepane/ThreePane.kt @@ -0,0 +1,110 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.treenav.adaptive.threepane + +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.runtime.Composable +import com.tunjid.treenav.Node +import com.tunjid.treenav.adaptive.Adaptation.Swap +import com.tunjid.treenav.adaptive.AdaptivePaneScope +import com.tunjid.treenav.adaptive.AdaptivePaneStrategy +import com.tunjid.treenav.adaptive.adaptivePaneStrategy + +/** + * A layout in the hierarchy that hosts an [AdaptivePaneStrategy] + */ +enum class ThreePane { + Primary, + TransientPrimary, + Secondary, + Tertiary, + Overlay; + + companion object { + val PrimaryToSecondary = Swap( + from = Primary, + to = Secondary + ) + + val SecondaryToPrimary = Swap( + from = Secondary, + to = Primary + ) + + val PrimaryToTransient = Swap( + from = Primary, + to = TransientPrimary + ) + } +} + +fun threePaneAdaptiveNodeConfiguration( + transitions: AdaptivePaneScope.() -> AdaptivePaneScope.Transitions = { + val state = paneState + when (state.pane) { + ThreePane.Primary, + ThreePane.Secondary -> when (state.adaptation) { + ThreePane.PrimaryToSecondary, + ThreePane.SecondaryToPrimary -> NoTransition + + else -> DefaultTransition + } + + ThreePane.TransientPrimary -> when (state.adaptation) { + ThreePane.PrimaryToTransient -> when (state.pane) { + ThreePane.Secondary -> DefaultTransition + else -> NoTransition + } + + else -> DefaultTransition + } + + else -> NoTransition + } + }, + paneMapping: @Composable (R) -> Map = { + mapOf(ThreePane.Primary to it) + }, + render: @Composable AdaptivePaneScope.(R) -> Unit +) = adaptivePaneStrategy( + paneMapping = paneMapping, + transitions = transitions, + render = render +) + +private val RouteTransitionAnimationSpec: FiniteAnimationSpec = tween( + durationMillis = 700 +) + +private val DefaultTransition = AdaptivePaneScope.Transitions( + enter = fadeIn( + animationSpec = RouteTransitionAnimationSpec, + ), + exit = fadeOut( + animationSpec = RouteTransitionAnimationSpec + ) +) + +private val NoTransition = AdaptivePaneScope.Transitions( + enter = EnterTransition.None, + exit = ExitTransition.None, +) \ No newline at end of file diff --git a/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/threepane/configurations/MovableSharedElementConfiguration.kt b/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/threepane/configurations/MovableSharedElementConfiguration.kt new file mode 100644 index 0000000..7b3a5c4 --- /dev/null +++ b/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/threepane/configurations/MovableSharedElementConfiguration.kt @@ -0,0 +1,123 @@ +package com.tunjid.treenav.adaptive.threepane.configurations + +import androidx.compose.animation.BoundsTransform +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import com.tunjid.scaffold.treenav.adaptive.moveablesharedelement.AdaptiveMovableSharedElementScope +import com.tunjid.scaffold.treenav.adaptive.moveablesharedelement.MovableSharedElementHostState +import com.tunjid.scaffold.treenav.adaptive.moveablesharedelement.MovableSharedElementScope +import com.tunjid.treenav.Node +import com.tunjid.treenav.adaptive.AdaptiveNavHost +import com.tunjid.treenav.adaptive.AdaptiveNavHostConfiguration +import com.tunjid.treenav.adaptive.AdaptivePaneScope +import com.tunjid.treenav.adaptive.AdaptivePaneState +import com.tunjid.treenav.adaptive.AdaptivePaneStrategy +import com.tunjid.treenav.adaptive.delegated +import com.tunjid.treenav.adaptive.threepane.ThreePane + + +/** + * An [AdaptiveNavHostConfiguration] that applies semantics of movable shared elements to + * [ThreePane] layouts. + * + * @param movableSharedElementHostState the host state for coordinating movable shared elements. + * There should be one instance of this per [AdaptiveNavHost]. + */ +fun AdaptiveNavHostConfiguration.movableSharedElementConfiguration( + movableSharedElementHostState: MovableSharedElementHostState, +): AdaptiveNavHostConfiguration = + delegated { destination -> + val originalStrategy = this@movableSharedElementConfiguration.strategyTransform(destination) + AdaptivePaneStrategy( + transitions = originalStrategy.transitions, + paneMapper = originalStrategy.paneMapper, + render = { paneDestination -> + val delegate = remember { + AdaptiveMovableSharedElementScope( + paneScope = this, + movableSharedElementHostState = movableSharedElementHostState, + ) + } + delegate.paneScope = this + + val movableSharedElementScope = remember { + ThreePaneMovableSharedElementScope( + hostState = movableSharedElementHostState, + delegate = delegate, + ) + } + + originalStrategy.render(movableSharedElementScope, paneDestination) + }, + ) + } + +fun AdaptivePaneScope.movableSharedElementScope(): MovableSharedElementScope { + check(this is ThreePaneMovableSharedElementScope) { + """ + The current AdaptivePaneScope (${this::class.qualifiedName}) is not an instance of + a ThreePaneMovableSharedElementScope. You must configure your ThreePane AdaptiveNavHost with + AdaptiveNavHostConfiguration.movableSharedElementConfiguration(movableSharedElementHostState). + + """.trimIndent() + } + return this +} + +@Stable +private class ThreePaneMovableSharedElementScope( + private val hostState: MovableSharedElementHostState, + private val delegate: AdaptiveMovableSharedElementScope, +) : MovableSharedElementScope, + AdaptivePaneScope by delegate.paneScope { + @OptIn(ExperimentalSharedTransitionApi::class) + override fun movableSharedElementOf( + key: Any, + boundsTransform: BoundsTransform, + sharedElement: @Composable (T, Modifier) -> Unit + ): @Composable (T, Modifier) -> Unit { + val paneScope = delegate.paneScope + return when (paneScope.paneState.pane) { + null -> throw IllegalArgumentException( + "Shared elements may only be used in non null panes" + ) + // Allow shared elements in the primary or transient primary content only + ThreePane.Primary -> when { + // Show a blank space for shared elements between the destinations + paneScope.isPreviewingBack && hostState.isCurrentlyShared(key) -> { _, modifier -> + Box(modifier) + } + // If previewing and it won't be shared, show the item as is + paneScope.isPreviewingBack -> sharedElement + // Share the element + else -> delegate.movableSharedElementOf( + key = key, + boundsTransform = boundsTransform, + sharedElement = sharedElement + ) + } + // Share the element when in the transient pane + ThreePane.TransientPrimary -> delegate.movableSharedElementOf( + key = key, + boundsTransform = boundsTransform, + sharedElement = sharedElement + ) + + // In the other panes use the element as is + ThreePane.Secondary, + ThreePane.Tertiary, + ThreePane.Overlay -> sharedElement + } + } +} + +fun AdaptivePaneState?.canAnimateOnStartingFrames() = + this?.pane != ThreePane.TransientPrimary + +private val AdaptivePaneScope.isPreviewingBack: Boolean + get() = paneState.pane == ThreePane.Primary + && paneState.adaptation == ThreePane.PrimaryToTransient \ No newline at end of file diff --git a/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/threepane/configurations/ThreePaneAdaptiveConfiguration.kt b/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/threepane/configurations/ThreePaneAdaptiveConfiguration.kt new file mode 100644 index 0000000..0a7e883 --- /dev/null +++ b/library/adaptive/src/commonMain/kotlin/com/tunjid/treenav/adaptive/threepane/configurations/ThreePaneAdaptiveConfiguration.kt @@ -0,0 +1,48 @@ +package com.tunjid.treenav.adaptive.threepane.configurations + +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import com.tunjid.treenav.Node +import com.tunjid.treenav.adaptive.AdaptiveNavHostConfiguration +import com.tunjid.treenav.adaptive.adaptivePaneStrategy +import com.tunjid.treenav.adaptive.delegated +import com.tunjid.treenav.adaptive.threepane.ThreePane + +/** + * An [AdaptiveNavHostConfiguration] that selectively displays panes for a [ThreePane] layout + * based on the space available determined by the [windowWidthDpState]. + * + * @param windowWidthDpState provides the current width of the display in Dp. + */ +fun AdaptiveNavHostConfiguration.threePaneAdaptiveConfiguration( + windowWidthDpState: State, + secondaryPaneBreakPoint: State = mutableStateOf(SECONDARY_PANE_MIN_WIDTH_BREAKPOINT_DP), + tertiaryPaneBreakPoint: State = mutableStateOf(TERTIARY_PANE_MIN_WIDTH_BREAKPOINT_DP), +): AdaptiveNavHostConfiguration = delegated { node -> + val originalStrategy = this@threePaneAdaptiveConfiguration.strategyTransform(node) + adaptivePaneStrategy( + render = originalStrategy.render, + transitions = originalStrategy.transitions, + paneMapping = { inner -> + // Consider navigation state different if window size class changes + val windowWidthDp by windowWidthDpState + val originalMapping = originalStrategy.paneMapper(inner) + val primaryNode = originalMapping[ThreePane.Primary] + mapOf( + ThreePane.Primary to primaryNode, + ThreePane.Secondary to originalMapping[ThreePane.Secondary].takeIf { secondaryDestination -> + secondaryDestination?.id != primaryNode?.id + && windowWidthDp >= secondaryPaneBreakPoint.value + }, + ThreePane.Tertiary to originalMapping[ThreePane.Tertiary].takeIf { tertiaryDestination -> + tertiaryDestination?.id != primaryNode?.id + && windowWidthDp >= tertiaryPaneBreakPoint.value + }, + ) + } + ) +} + +private const val SECONDARY_PANE_MIN_WIDTH_BREAKPOINT_DP = 600 +private const val TERTIARY_PANE_MIN_WIDTH_BREAKPOINT_DP = 1200 \ No newline at end of file diff --git a/library/strings/build.gradle.kts b/library/strings/build.gradle.kts new file mode 100644 index 0000000..f51d4d1 --- /dev/null +++ b/library/strings/build.gradle.kts @@ -0,0 +1,72 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + kotlin("multiplatform") + id("publishing-library-convention") + id("kotlin-jvm-convention") + id("maven-publish") + signing + id("org.jetbrains.dokka") +} + +kotlin { + applyDefaultHierarchyTemplate() + js(IR) { + nodejs() + browser() + } + jvm { + withJava() + testRuns["test"].executionTask.configure { + useJUnit() + } + } + listOf( + iosX64(), + iosArm64(), + iosSimulatorArm64(), + ).forEach { iosTarget -> + iosTarget.binaries.framework { + baseName = "treenav-strings" + isStatic = true + } + } + linuxX64() + macosX64() + macosArm64() + mingwX64() + tvosSimulatorArm64() + watchosSimulatorArm64() + + sourceSets { + val commonMain by getting { + dependencies { + implementation(project(":library:treenav")) + } + } + commonTest { + dependencies { + implementation(kotlin("test")) + } + } + val jvmMain by getting + val jvmTest by getting + + val jsMain by getting + val jsTest by getting + } +} diff --git a/strings/src/commonMain/kotlin/com/tunjid/treenav/strings/Paths.kt b/library/strings/src/commonMain/kotlin/com/tunjid/treenav/strings/Paths.kt similarity index 100% rename from strings/src/commonMain/kotlin/com/tunjid/treenav/strings/Paths.kt rename to library/strings/src/commonMain/kotlin/com/tunjid/treenav/strings/Paths.kt diff --git a/strings/src/commonMain/kotlin/com/tunjid/treenav/strings/Route.kt b/library/strings/src/commonMain/kotlin/com/tunjid/treenav/strings/Route.kt similarity index 100% rename from strings/src/commonMain/kotlin/com/tunjid/treenav/strings/Route.kt rename to library/strings/src/commonMain/kotlin/com/tunjid/treenav/strings/Route.kt diff --git a/strings/src/commonMain/kotlin/com/tunjid/treenav/strings/RouteMatcher.kt b/library/strings/src/commonMain/kotlin/com/tunjid/treenav/strings/RouteMatcher.kt similarity index 100% rename from strings/src/commonMain/kotlin/com/tunjid/treenav/strings/RouteMatcher.kt rename to library/strings/src/commonMain/kotlin/com/tunjid/treenav/strings/RouteMatcher.kt diff --git a/strings/src/commonMain/kotlin/com/tunjid/treenav/strings/RouteTrie.kt b/library/strings/src/commonMain/kotlin/com/tunjid/treenav/strings/RouteTrie.kt similarity index 100% rename from strings/src/commonMain/kotlin/com/tunjid/treenav/strings/RouteTrie.kt rename to library/strings/src/commonMain/kotlin/com/tunjid/treenav/strings/RouteTrie.kt diff --git a/strings/src/commonMain/kotlin/com/tunjid/treenav/strings/TrieNode.kt b/library/strings/src/commonMain/kotlin/com/tunjid/treenav/strings/TrieNode.kt similarity index 100% rename from strings/src/commonMain/kotlin/com/tunjid/treenav/strings/TrieNode.kt rename to library/strings/src/commonMain/kotlin/com/tunjid/treenav/strings/TrieNode.kt diff --git a/strings/src/commonTest/kotlin/RouteMatcherTest.kt b/library/strings/src/commonTest/kotlin/RouteMatcherTest.kt similarity index 100% rename from strings/src/commonTest/kotlin/RouteMatcherTest.kt rename to library/strings/src/commonTest/kotlin/RouteMatcherTest.kt diff --git a/strings/src/commonTest/kotlin/TrieNodeTest.kt b/library/strings/src/commonTest/kotlin/TrieNodeTest.kt similarity index 100% rename from strings/src/commonTest/kotlin/TrieNodeTest.kt rename to library/strings/src/commonTest/kotlin/TrieNodeTest.kt diff --git a/library/treenav/build.gradle.kts b/library/treenav/build.gradle.kts new file mode 100644 index 0000000..9d9d148 --- /dev/null +++ b/library/treenav/build.gradle.kts @@ -0,0 +1,67 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + kotlin("multiplatform") + id("publishing-library-convention") + id("kotlin-jvm-convention") + id("maven-publish") + signing + id("org.jetbrains.dokka") +} + +kotlin { + applyDefaultHierarchyTemplate() + js(IR) { + nodejs() + browser() + } + jvm { + withJava() + testRuns["test"].executionTask.configure { + useJUnit() + } + } + listOf( + iosX64(), + iosArm64(), + iosSimulatorArm64(), + ).forEach { iosTarget -> + iosTarget.binaries.framework { + baseName = "treenav" + isStatic = true + } + } + linuxX64() + macosX64() + macosArm64() + mingwX64() + tvosSimulatorArm64() + watchosSimulatorArm64() + + sourceSets { + commonTest { + dependencies { + implementation(kotlin("test")) + } + } + val jvmMain by getting + val jvmTest by getting + + val jsMain by getting + val jsTest by getting + } +} \ No newline at end of file diff --git a/treenav/src/commonMain/kotlin/com/tunjid/treenav/MultiStackNav.kt b/library/treenav/src/commonMain/kotlin/com/tunjid/treenav/MultiStackNav.kt similarity index 100% rename from treenav/src/commonMain/kotlin/com/tunjid/treenav/MultiStackNav.kt rename to library/treenav/src/commonMain/kotlin/com/tunjid/treenav/MultiStackNav.kt diff --git a/treenav/src/commonMain/kotlin/com/tunjid/treenav/Node.kt b/library/treenav/src/commonMain/kotlin/com/tunjid/treenav/Node.kt similarity index 100% rename from treenav/src/commonMain/kotlin/com/tunjid/treenav/Node.kt rename to library/treenav/src/commonMain/kotlin/com/tunjid/treenav/Node.kt diff --git a/treenav/src/commonMain/kotlin/com/tunjid/treenav/StackNav.kt b/library/treenav/src/commonMain/kotlin/com/tunjid/treenav/StackNav.kt similarity index 100% rename from treenav/src/commonMain/kotlin/com/tunjid/treenav/StackNav.kt rename to library/treenav/src/commonMain/kotlin/com/tunjid/treenav/StackNav.kt diff --git a/treenav/src/commonTest/kotlin/MultiStackNavTest.kt b/library/treenav/src/commonTest/kotlin/MultiStackNavTest.kt similarity index 100% rename from treenav/src/commonTest/kotlin/MultiStackNavTest.kt rename to library/treenav/src/commonTest/kotlin/MultiStackNavTest.kt diff --git a/treenav/src/commonTest/kotlin/StackNavTest.kt b/library/treenav/src/commonTest/kotlin/StackNavTest.kt similarity index 100% rename from treenav/src/commonTest/kotlin/StackNavTest.kt rename to library/treenav/src/commonTest/kotlin/StackNavTest.kt diff --git a/libraryVersion.properties b/libraryVersion.properties index 6f151cf..8c7d675 100644 --- a/libraryVersion.properties +++ b/libraryVersion.properties @@ -16,4 +16,5 @@ groupId=com.tunjid.treenav treenav_version=0.0.7 -strings_version=0.0.7 \ No newline at end of file +strings_version=0.0.7 +adaptive_version=0.0.7 \ No newline at end of file diff --git a/sample/android/.gitignore b/sample/android/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/sample/android/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/sample/android/build.gradle.kts b/sample/android/build.gradle.kts new file mode 100644 index 0000000..7d7c093 --- /dev/null +++ b/sample/android/build.gradle.kts @@ -0,0 +1,55 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id("android-application-convention") + id("kotlin-android") + alias(libs.plugins.compose.compiler) +} + +android { + defaultConfig { + applicationId = "com.tunjid.treenav" + targetSdk = 35 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } +} + +dependencies { + implementation(project(":sample:common")) + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + + implementation(libs.androidx.activity.compose) + implementation(libs.jetbrains.compose.material3) + implementation(libs.jetbrains.compose.animation) + + implementation(libs.google.material) +} diff --git a/sample/android/proguard-rules.pro b/sample/android/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/sample/android/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/sample/android/src/main/AndroidManifest.xml b/sample/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..025cb65 --- /dev/null +++ b/sample/android/src/main/AndroidManifest.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + diff --git a/sample/android/src/main/java/com/tunjid/tyler/MainActivity.kt b/sample/android/src/main/java/com/tunjid/tyler/MainActivity.kt new file mode 100644 index 0000000..807c1b6 --- /dev/null +++ b/sample/android/src/main/java/com/tunjid/tyler/MainActivity.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.tyler + +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.appcompat.app.AppCompatActivity +import com.tunjid.demo.common.ui.AppTheme +import com.tunjid.demo.common.ui.App + +class MainActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + AppTheme { + App() + } + } + } +} diff --git a/sample/android/src/main/res/drawable-v24/ic_launcher_foreground.xml b/sample/android/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..dc64705 --- /dev/null +++ b/sample/android/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + diff --git a/sample/android/src/main/res/drawable/ic_launcher_background.xml b/sample/android/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..7d63be4 --- /dev/null +++ b/sample/android/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,186 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sample/android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/sample/android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..ca4be48 --- /dev/null +++ b/sample/android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/sample/android/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/sample/android/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..ca4be48 --- /dev/null +++ b/sample/android/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/sample/android/src/main/res/mipmap-hdpi/ic_launcher.png b/sample/android/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..a571e60 Binary files /dev/null and b/sample/android/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/sample/android/src/main/res/mipmap-hdpi/ic_launcher_round.png b/sample/android/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..61da551 Binary files /dev/null and b/sample/android/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/sample/android/src/main/res/mipmap-mdpi/ic_launcher.png b/sample/android/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..c41dd28 Binary files /dev/null and b/sample/android/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/sample/android/src/main/res/mipmap-mdpi/ic_launcher_round.png b/sample/android/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..db5080a Binary files /dev/null and b/sample/android/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/sample/android/src/main/res/mipmap-xhdpi/ic_launcher.png b/sample/android/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..6dba46d Binary files /dev/null and b/sample/android/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/sample/android/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/sample/android/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..da31a87 Binary files /dev/null and b/sample/android/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/sample/android/src/main/res/mipmap-xxhdpi/ic_launcher.png b/sample/android/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..15ac681 Binary files /dev/null and b/sample/android/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/sample/android/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/sample/android/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..b216f2d Binary files /dev/null and b/sample/android/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/sample/android/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/sample/android/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..f25a419 Binary files /dev/null and b/sample/android/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/sample/android/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/sample/android/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..e96783c Binary files /dev/null and b/sample/android/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/sample/android/src/main/res/values-night/themes.xml b/sample/android/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..6ffe198 --- /dev/null +++ b/sample/android/src/main/res/values-night/themes.xml @@ -0,0 +1,32 @@ + + + + + + diff --git a/sample/android/src/main/res/values/colors.xml b/sample/android/src/main/res/values/colors.xml new file mode 100644 index 0000000..5d48e2b --- /dev/null +++ b/sample/android/src/main/res/values/colors.xml @@ -0,0 +1,26 @@ + + + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + diff --git a/sample/android/src/main/res/values/strings.xml b/sample/android/src/main/res/values/strings.xml new file mode 100644 index 0000000..456e135 --- /dev/null +++ b/sample/android/src/main/res/values/strings.xml @@ -0,0 +1,19 @@ + + + + Tyler + diff --git a/sample/android/src/main/res/values/themes.xml b/sample/android/src/main/res/values/themes.xml new file mode 100644 index 0000000..6cc68c7 --- /dev/null +++ b/sample/android/src/main/res/values/themes.xml @@ -0,0 +1,32 @@ + + + + + + diff --git a/sample/common/build.gradle.kts b/sample/common/build.gradle.kts new file mode 100755 index 0000000..6f294e9 --- /dev/null +++ b/sample/common/build.gradle.kts @@ -0,0 +1,81 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id("android-library-convention") + id("kotlin-library-convention") + id("org.jetbrains.compose") + alias(libs.plugins.compose.compiler) +} + +kotlin { + listOf( + iosX64(), + iosArm64(), + iosSimulatorArm64(), + ).forEach { iosTarget -> + iosTarget.binaries.framework { + baseName = "common" + isStatic = true + } + } + sourceSets { + named("commonMain") { + dependencies { + implementation(project(":library:treenav")) + implementation(project(":library:strings")) + implementation(project(":library:adaptive")) + + implementation(compose.components.resources) + + implementation(libs.jetbrains.compose.runtime) + implementation(libs.jetbrains.compose.animation) + implementation(libs.jetbrains.compose.material3) + implementation(libs.jetbrains.compose.foundation.layout) + + implementation(libs.jetbrains.lifecycle.runtime) + implementation(libs.jetbrains.lifecycle.runtime.compose) + implementation(libs.jetbrains.lifecycle.viewmodel) + implementation(libs.jetbrains.lifecycle.viewmodel.compose) + + implementation(libs.jetbrains.compose.material3.adaptive) + implementation(libs.jetbrains.compose.material3.adaptive.layout) + implementation(libs.jetbrains.compose.material3.adaptive.navigation.suite) + + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.datetime) + + implementation(libs.tunjid.mutator.core.common) + implementation(libs.tunjid.mutator.coroutines.common) + implementation(libs.tunjid.composables) + } + } + named("androidMain") { + dependencies { + implementation(libs.androidx.compose.foundation.layout) + } + } + val iosX64Main by getting + val iosArm64Main by getting + val iosSimulatorArm64Main by getting + val iosMain by creating { + dependsOn(named("commonMain").get()) + iosX64Main.dependsOn(this) + iosArm64Main.dependsOn(this) + iosSimulatorArm64Main.dependsOn(this) + } + } +} diff --git a/sample/common/src/androidMain/AndroidManifest.xml b/sample/common/src/androidMain/AndroidManifest.xml new file mode 100755 index 0000000..d2988f8 --- /dev/null +++ b/sample/common/src/androidMain/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + diff --git a/sample/common/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml b/sample/common/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..12e4b44 --- /dev/null +++ b/sample/common/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + diff --git a/sample/common/src/androidMain/res/drawable/ic_launcher_background.xml b/sample/common/src/androidMain/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..6013ddc --- /dev/null +++ b/sample/common/src/androidMain/res/drawable/ic_launcher_background.xml @@ -0,0 +1,186 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sample/common/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml b/sample/common/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..ca4be48 --- /dev/null +++ b/sample/common/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/sample/common/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml b/sample/common/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..ca4be48 --- /dev/null +++ b/sample/common/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/sample/common/src/androidMain/res/mipmap-hdpi/ic_launcher.png b/sample/common/src/androidMain/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..898f3ed Binary files /dev/null and b/sample/common/src/androidMain/res/mipmap-hdpi/ic_launcher.png differ diff --git a/sample/common/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png b/sample/common/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..dffca36 Binary files /dev/null and b/sample/common/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/sample/common/src/androidMain/res/mipmap-mdpi/ic_launcher.png b/sample/common/src/androidMain/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..64ba76f Binary files /dev/null and b/sample/common/src/androidMain/res/mipmap-mdpi/ic_launcher.png differ diff --git a/sample/common/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png b/sample/common/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..dae5e08 Binary files /dev/null and b/sample/common/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/sample/common/src/androidMain/res/mipmap-xhdpi/ic_launcher.png b/sample/common/src/androidMain/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..e5ed465 Binary files /dev/null and b/sample/common/src/androidMain/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/sample/common/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png b/sample/common/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..14ed0af Binary files /dev/null and b/sample/common/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/sample/common/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png b/sample/common/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..b0907ca Binary files /dev/null and b/sample/common/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/sample/common/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png b/sample/common/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..d8ae031 Binary files /dev/null and b/sample/common/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/sample/common/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png b/sample/common/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..2c18de9 Binary files /dev/null and b/sample/common/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/sample/common/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png b/sample/common/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..beed3cd Binary files /dev/null and b/sample/common/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/sample/common/src/androidMain/res/values/strings.xml b/sample/common/src/androidMain/res/values/strings.xml new file mode 100644 index 0000000..c8ae8d5 --- /dev/null +++ b/sample/common/src/androidMain/res/values/strings.xml @@ -0,0 +1,20 @@ + + + + + Tiler Demo + diff --git a/sample/common/src/commonMain/composeResources/drawable/aisha-1.jpg b/sample/common/src/commonMain/composeResources/drawable/aisha-1.jpg new file mode 100644 index 0000000..5dcbb08 Binary files /dev/null and b/sample/common/src/commonMain/composeResources/drawable/aisha-1.jpg differ diff --git a/sample/common/src/commonMain/composeResources/drawable/aisha-2.jpg b/sample/common/src/commonMain/composeResources/drawable/aisha-2.jpg new file mode 100644 index 0000000..44ad557 Binary files /dev/null and b/sample/common/src/commonMain/composeResources/drawable/aisha-2.jpg differ diff --git a/sample/common/src/commonMain/composeResources/drawable/aisha-3.jpg b/sample/common/src/commonMain/composeResources/drawable/aisha-3.jpg new file mode 100644 index 0000000..f9dbc08 Binary files /dev/null and b/sample/common/src/commonMain/composeResources/drawable/aisha-3.jpg differ diff --git a/sample/common/src/commonMain/composeResources/drawable/aisha-4.jpg b/sample/common/src/commonMain/composeResources/drawable/aisha-4.jpg new file mode 100644 index 0000000..fa846ea Binary files /dev/null and b/sample/common/src/commonMain/composeResources/drawable/aisha-4.jpg differ diff --git a/sample/common/src/commonMain/composeResources/drawable/bjorn-1.jpg b/sample/common/src/commonMain/composeResources/drawable/bjorn-1.jpg new file mode 100644 index 0000000..239ebb4 Binary files /dev/null and b/sample/common/src/commonMain/composeResources/drawable/bjorn-1.jpg differ diff --git a/sample/common/src/commonMain/composeResources/drawable/bjorn-2.jpg b/sample/common/src/commonMain/composeResources/drawable/bjorn-2.jpg new file mode 100644 index 0000000..2d3e48f Binary files /dev/null and b/sample/common/src/commonMain/composeResources/drawable/bjorn-2.jpg differ diff --git a/sample/common/src/commonMain/composeResources/drawable/bjorn-3.jpg b/sample/common/src/commonMain/composeResources/drawable/bjorn-3.jpg new file mode 100644 index 0000000..d612772 Binary files /dev/null and b/sample/common/src/commonMain/composeResources/drawable/bjorn-3.jpg differ diff --git a/sample/common/src/commonMain/composeResources/drawable/bjorn-4.jpg b/sample/common/src/commonMain/composeResources/drawable/bjorn-4.jpg new file mode 100644 index 0000000..166b920 Binary files /dev/null and b/sample/common/src/commonMain/composeResources/drawable/bjorn-4.jpg differ diff --git a/sample/common/src/commonMain/composeResources/drawable/diego-1.jpg b/sample/common/src/commonMain/composeResources/drawable/diego-1.jpg new file mode 100644 index 0000000..57284b5 Binary files /dev/null and b/sample/common/src/commonMain/composeResources/drawable/diego-1.jpg differ diff --git a/sample/common/src/commonMain/composeResources/drawable/diego-2.jpg b/sample/common/src/commonMain/composeResources/drawable/diego-2.jpg new file mode 100644 index 0000000..7659afa Binary files /dev/null and b/sample/common/src/commonMain/composeResources/drawable/diego-2.jpg differ diff --git a/sample/common/src/commonMain/composeResources/drawable/diego-3.jpg b/sample/common/src/commonMain/composeResources/drawable/diego-3.jpg new file mode 100644 index 0000000..e1811d2 Binary files /dev/null and b/sample/common/src/commonMain/composeResources/drawable/diego-3.jpg differ diff --git a/sample/common/src/commonMain/composeResources/drawable/diego-4.jpg b/sample/common/src/commonMain/composeResources/drawable/diego-4.jpg new file mode 100644 index 0000000..332ce23 Binary files /dev/null and b/sample/common/src/commonMain/composeResources/drawable/diego-4.jpg differ diff --git a/sample/common/src/commonMain/composeResources/drawable/kenji-1.jpg b/sample/common/src/commonMain/composeResources/drawable/kenji-1.jpg new file mode 100644 index 0000000..e99c338 Binary files /dev/null and b/sample/common/src/commonMain/composeResources/drawable/kenji-1.jpg differ diff --git a/sample/common/src/commonMain/composeResources/drawable/kenji-2.jpg b/sample/common/src/commonMain/composeResources/drawable/kenji-2.jpg new file mode 100644 index 0000000..7176e1a Binary files /dev/null and b/sample/common/src/commonMain/composeResources/drawable/kenji-2.jpg differ diff --git a/sample/common/src/commonMain/composeResources/drawable/kenji-3.jpg b/sample/common/src/commonMain/composeResources/drawable/kenji-3.jpg new file mode 100644 index 0000000..08c36d0 Binary files /dev/null and b/sample/common/src/commonMain/composeResources/drawable/kenji-3.jpg differ diff --git a/sample/common/src/commonMain/composeResources/drawable/kenji-4.jpg b/sample/common/src/commonMain/composeResources/drawable/kenji-4.jpg new file mode 100644 index 0000000..14837b9 Binary files /dev/null and b/sample/common/src/commonMain/composeResources/drawable/kenji-4.jpg differ diff --git a/sample/common/src/commonMain/composeResources/drawable/lin-1.jpg b/sample/common/src/commonMain/composeResources/drawable/lin-1.jpg new file mode 100644 index 0000000..0493eee Binary files /dev/null and b/sample/common/src/commonMain/composeResources/drawable/lin-1.jpg differ diff --git a/sample/common/src/commonMain/composeResources/drawable/lin-2.jpg b/sample/common/src/commonMain/composeResources/drawable/lin-2.jpg new file mode 100644 index 0000000..d9e9470 Binary files /dev/null and b/sample/common/src/commonMain/composeResources/drawable/lin-2.jpg differ diff --git a/sample/common/src/commonMain/composeResources/drawable/lin-3.jpg b/sample/common/src/commonMain/composeResources/drawable/lin-3.jpg new file mode 100644 index 0000000..3a2a584 Binary files /dev/null and b/sample/common/src/commonMain/composeResources/drawable/lin-3.jpg differ diff --git a/sample/common/src/commonMain/composeResources/drawable/lin-4.jpg b/sample/common/src/commonMain/composeResources/drawable/lin-4.jpg new file mode 100644 index 0000000..d2c9b03 Binary files /dev/null and b/sample/common/src/commonMain/composeResources/drawable/lin-4.jpg differ diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/App.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/App.kt new file mode 100644 index 0000000..057f3f3 --- /dev/null +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/App.kt @@ -0,0 +1,203 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.demo.common.ui + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold +import androidx.compose.material3.adaptive.layout.PaneAdaptedValue +import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldValue +import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective +import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import com.tunjid.demo.common.ui.SampleAppState.Companion.rememberAdaptiveNavHostState +import com.tunjid.demo.common.ui.chat.chatAdaptiveConfiguration +import com.tunjid.demo.common.ui.chatrooms.chatRoomPaneConfiguration +import com.tunjid.demo.common.ui.data.NavigationRepository +import com.tunjid.demo.common.ui.data.SampleDestination +import com.tunjid.demo.common.ui.profile.profileAdaptiveConfiguration +import com.tunjid.demo.common.ui.settings.settingsPaneConfiguration +import com.tunjid.scaffold.treenav.adaptive.moveablesharedelement.MovableSharedElementHostState +import com.tunjid.treenav.MultiStackNav +import com.tunjid.treenav.adaptive.AdaptiveNavHost +import com.tunjid.treenav.adaptive.AdaptiveNavHostConfiguration +import com.tunjid.treenav.adaptive.AdaptivePaneState +import com.tunjid.treenav.adaptive.SavedStateAdaptiveNavHostState +import com.tunjid.treenav.adaptive.adaptiveNavHostConfiguration +import com.tunjid.treenav.adaptive.threepane.ThreePane +import com.tunjid.treenav.adaptive.threepane.configurations.canAnimateOnStartingFrames +import com.tunjid.treenav.adaptive.threepane.configurations.movableSharedElementConfiguration +import com.tunjid.treenav.adaptive.threepane.configurations.threePaneAdaptiveConfiguration +import com.tunjid.treenav.current +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalMaterial3AdaptiveApi::class) +@Composable +fun App( + appState: SampleAppState = remember { SampleAppState() }, +) { + NavigationSuiteScaffold( + navigationSuiteItems = { + SampleDestination.NavTabs.entries.forEach { + item( + icon = { + Icon( + it.icon, + contentDescription = it.title + ) + }, + label = { Text(it.title) }, + selected = it == appState.currentNavigation.current, + onClick = { } + ) + } + } + ) { + SharedTransitionScope { sharedTransitionModifier -> + val windowWidthDp = remember { mutableIntStateOf(0) } + val density = LocalDensity.current + val movableSharedElementHostState = remember { + MovableSharedElementHostState( + sharedTransitionScope = this, + canAnimateOnStartingFrames = AdaptivePaneState::canAnimateOnStartingFrames + ) + } + AdaptiveNavHost( + state = appState.rememberAdaptiveNavHostState { + this + .threePaneAdaptiveConfiguration( + windowWidthDpState = windowWidthDp + ) + .movableSharedElementConfiguration( + movableSharedElementHostState = movableSharedElementHostState + ) + }, + modifier = Modifier + .fillMaxSize() + .onSizeChanged { + windowWidthDp.value = (it.width / density.density).roundToInt() + } + then sharedTransitionModifier + ) { + ListDetailPaneScaffold( + modifier = Modifier + .fillMaxSize() + then movableSharedElementHostState.modifier + then sharedTransitionModifier, + directive = calculatePaneScaffoldDirective(currentWindowAdaptiveInfo()), + value = ThreePaneScaffoldValue( + primary = if (nodeFor(ThreePane.Primary) == null) PaneAdaptedValue.Hidden else PaneAdaptedValue.Expanded, + secondary = if (nodeFor(ThreePane.Secondary) == null) PaneAdaptedValue.Hidden else PaneAdaptedValue.Expanded, + tertiary = if (nodeFor(ThreePane.Tertiary) == null) PaneAdaptedValue.Hidden else PaneAdaptedValue.Expanded, + ), + listPane = { + Destination(ThreePane.Secondary) + }, + detailPane = { + Destination(ThreePane.Primary) + }, + extraPane = { + Destination(ThreePane.Tertiary) + } + ) + } + } + } +} + + +@Stable +class SampleAppState( + private val navigationRepository: NavigationRepository = NavigationRepository +) { + + private val navigationState = mutableStateOf( + navigationRepository.navigationStateFlow.value + ) + val currentNavigation by navigationState + + private val adaptiveNavHostConfiguration = sampleAppAdaptiveConfiguration( + navigationState + ) + + companion object { + @Composable + fun SampleAppState.rememberAdaptiveNavHostState( + configurationBlock: AdaptiveNavHostConfiguration< + ThreePane, + MultiStackNav, + SampleDestination + >.() -> AdaptiveNavHostConfiguration + ): SavedStateAdaptiveNavHostState { + val adaptiveNavHostState = remember { + SavedStateAdaptiveNavHostState( + panes = ThreePane.entries.toList(), + configuration = adaptiveNavHostConfiguration.configurationBlock(), + ) + } + DisposableEffect(Unit) { + val job = CoroutineScope(Dispatchers.Main.immediate).launch { + navigationRepository.navigationStateFlow.collect { multiStackNav -> + navigationState.value = multiStackNav + } + } + onDispose { job.cancel() } + } + return adaptiveNavHostState + } + } +} + +private fun sampleAppAdaptiveConfiguration( + multiStackNavState: State +) = adaptiveNavHostConfiguration( + navigationState = multiStackNavState, + destinationTransform = { multiStackNav -> + multiStackNav.current as? SampleDestination ?: throw IllegalArgumentException( + "MultiStackNav leaf node ${multiStackNav.current} must be an AppDestination" + ) + }, + strategyTransform = { destination -> + when (destination) { + SampleDestination.NavTabs.ChatRooms -> chatRoomPaneConfiguration() + + is SampleDestination.Chat -> chatAdaptiveConfiguration(destination) + + SampleDestination.NavTabs.Settings -> settingsPaneConfiguration() + + is SampleDestination.Profile -> profileAdaptiveConfiguration(destination) + } + } +) diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/CollapsingHeader.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/CollapsingHeader.kt new file mode 100644 index 0000000..8366040 --- /dev/null +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/CollapsingHeader.kt @@ -0,0 +1,132 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.demo.common.ui + + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.splineBasedDecay +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import com.tunjid.composables.collapsingheader.CollapsingHeader +import com.tunjid.composables.collapsingheader.CollapsingHeaderState +import kotlin.math.max +import kotlin.math.roundToInt + +@Composable +internal fun SampleCollapsingHeader( + title: String, + headerColor: Color, + onBackPressed: () -> Unit, + body: @Composable (collapsedHeight: Float) -> Unit, +) { + val density = LocalDensity.current + val collapsedHeight = with(density) { 56.dp.toPx() } + + WindowInsets.statusBars.getTop(density).toFloat() + + WindowInsets.statusBars.getBottom(density).toFloat() + val headerState = remember { + CollapsingHeaderState( + collapsedHeight = collapsedHeight, + initialExpandedHeight = with(density) { 400.dp.toPx() }, + decayAnimationSpec = splineBasedDecay(density) + ) + } + val animatedColor by animateColorAsState( + headerColor.copy(alpha = max(1f - headerState.progress, 0.6f)) + ) + CollapsingHeader( + state = headerState, + headerContent = { + Box { + Column( + modifier = Modifier.fillMaxWidth() + .offset { + IntOffset( + x = 0, + y = -headerState.translation.roundToInt() + ) + } + .background(animatedColor) + ) { + Spacer(Modifier.windowInsetsPadding(WindowInsets.statusBars)) + Spacer(Modifier.height(200.dp)) + } + SampleTopAppBar( + title = title, + onBackPressed = onBackPressed, + modifier = Modifier.onSizeChanged { + headerState.collapsedHeight = it.height.toFloat() + } + ) + } + }, + body = { + body(collapsedHeight) + } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SampleTopAppBar( + title: String, + onBackPressed: (() -> Unit)? = null, + modifier: Modifier = Modifier, +) { + TopAppBar( + title = { + Text(text = title) + }, + navigationIcon = { + if (onBackPressed != null) IconButton( + onClick = onBackPressed, + content = { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null, + ) + } + ) + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent), + modifier = modifier, + ) +} \ No newline at end of file diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/ProfilePhoto.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/ProfilePhoto.kt new file mode 100644 index 0000000..ce724ec --- /dev/null +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/ProfilePhoto.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.demo.common.ui + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.Image +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.tunjid.composables.ui.interpolate +import org.jetbrains.compose.resources.ExperimentalResourceApi +import org.jetbrains.compose.resources.painterResource +import treenavigation.sample.common.generated.resources.Res +import treenavigation.sample.common.generated.resources.allDrawableResources + +@Composable +fun ProfilePhoto( + args: ProfilePhotoArgs, + modifier: Modifier = Modifier, +) { + Image( + modifier = modifier + .clip(RoundedCornerShape(animateDpAsState(args.cornerRadius).value)), + contentScale = args.contentScale.interpolate(), + contentDescription = args.contentDescription, + painter = painterResource(args.profilePhotoResource()) + ) +} + +data class ProfilePhotoArgs( + val profileName: String, + val contentScale: ContentScale, + val cornerRadius: Dp = 0.dp, + val contentDescription: String? = null, +) + +@OptIn(ExperimentalResourceApi::class) +private fun ProfilePhotoArgs.profilePhotoResource() = + Res.allDrawableResources.getValue("${profileName.lowercase()}_1") diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/Theme.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/Theme.kt new file mode 100644 index 0000000..fd6d72b --- /dev/null +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/Theme.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.demo.common.ui + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme + +import androidx.compose.runtime.Composable + +private val DarkColorPalette = darkColorScheme() + +private val LightColorPalette = lightColorScheme() + +@Composable +fun AppTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit +) { + val colors = if (darkTheme) { + DarkColorPalette + } else { + LightColorPalette + } + + MaterialTheme( + colorScheme = colors, + content = content + ) +} diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatScreen.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatScreen.kt new file mode 100644 index 0000000..39cfef4 --- /dev/null +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatScreen.kt @@ -0,0 +1,289 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.demo.common.ui.chat + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.paddingFrom +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.LastBaseline +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.tunjid.demo.common.ui.ProfilePhoto +import com.tunjid.demo.common.ui.ProfilePhotoArgs +import com.tunjid.demo.common.ui.SampleTopAppBar +import com.tunjid.demo.common.ui.data.Message +import com.tunjid.demo.common.ui.data.Profile +import com.tunjid.scaffold.treenav.adaptive.moveablesharedelement.MovableSharedElementScope +import kotlinx.datetime.Instant +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime + +@Composable +fun ChatScreen( + movableSharedElementScope: MovableSharedElementScope, + state: State, + onAction: (Action) -> Unit, +) { + val scrollState = rememberLazyListState() + Column( + Modifier.fillMaxSize() + ) { + SampleTopAppBar( + title = state.room?.name ?: "", + onBackPressed = { onAction(Action.Navigation.Pop) }, + ) + Messages( + me = state.me, + roomName = state.room?.name, + messages = state.chats, + navigateToProfile = onAction, + modifier = Modifier.weight(1f), + scrollState = scrollState, + movableSharedElementScope = movableSharedElementScope, + ) + } +} + + +@Composable +fun Messages( + me: Profile?, + roomName: String?, + messages: List, + navigateToProfile: (Action.Navigation.GoToProfile) -> Unit, + scrollState: LazyListState, + modifier: Modifier = Modifier, + movableSharedElementScope: MovableSharedElementScope, +) { + Box(modifier = modifier) { + LazyColumn( + state = scrollState, + modifier = Modifier + .fillMaxSize() + ) { + items( + count = messages.size + ) { index -> + val prevAuthor = messages.getOrNull(index - 1)?.sender + val nextAuthor = messages.getOrNull(index + 1)?.sender + val content = messages[index] + val isFirstMessageByAuthor = prevAuthor != content.sender + val isLastMessageByAuthor = nextAuthor != content.sender + + Message( + onAuthorClick = navigateToProfile, + roomName = roomName, + item = content, + isUserMe = content.sender.name == me?.name, + isFirstMessageByAuthor = isFirstMessageByAuthor, + isLastMessageByAuthor = isLastMessageByAuthor, + movableSharedElementScope = movableSharedElementScope, + ) + } + } + } +} + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun Message( + onAuthorClick: (Action.Navigation.GoToProfile) -> Unit, + item: MessageItem, + roomName: String?, + isUserMe: Boolean, + isFirstMessageByAuthor: Boolean, + isLastMessageByAuthor: Boolean, + movableSharedElementScope: MovableSharedElementScope +) { + val borderColor = if (isUserMe) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.tertiary + } + + val spaceBetweenAuthors = if (isLastMessageByAuthor) Modifier.padding(top = 8.dp) else Modifier + + Row(modifier = spaceBetweenAuthors) { + if (isLastMessageByAuthor) { + val sharedImage = movableSharedElementScope.movableSharedElementOf( + key = item.sender.name, + sharedElement = { args: ProfilePhotoArgs, innerModifier: Modifier -> + ProfilePhoto(args, innerModifier) + } + ) + // Avatar + Box( + modifier = Modifier + .padding(horizontal = 16.dp) + .size(42.dp) + .border(1.5.dp, borderColor, CircleShape) + .border(3.dp, MaterialTheme.colorScheme.surface, CircleShape) + .clip(CircleShape) + .align(Alignment.Top) + .clickable { + roomName?.let { + onAuthorClick( + Action.Navigation.GoToProfile( + profileName = item.sender.name, + roomName = it + ) + ) + } + }, + ) { + sharedImage( + ProfilePhotoArgs( + profileName = item.sender.name, + contentScale = ContentScale.Crop, + cornerRadius = 42.dp, + contentDescription = null, + ), + Modifier.matchParentSize(), + ) + } + } else { + // Space under avatar + Spacer(modifier = Modifier.width(74.dp)) + } + AuthorAndTextMessage( + item = item, + isUserMe = isUserMe, + isFirstMessageByAuthor = isFirstMessageByAuthor, + isLastMessageByAuthor = isLastMessageByAuthor, + modifier = Modifier + .padding(end = 16.dp) + .weight(1f) + ) + } +} + +@Composable +fun AuthorAndTextMessage( + item: MessageItem, + isUserMe: Boolean, + isFirstMessageByAuthor: Boolean, + isLastMessageByAuthor: Boolean, + modifier: Modifier = Modifier +) { + Column(modifier = modifier) { + if (isLastMessageByAuthor) { + AuthorNameTimestamp(item) + } + ChatItemBubble(item, isUserMe) + if (isFirstMessageByAuthor) { + // Last bubble before next author + Spacer(modifier = Modifier.height(8.dp)) + } else { + // Between bubbles + Spacer(modifier = Modifier.height(4.dp)) + } + } +} + +@Composable +private fun AuthorNameTimestamp( + item: MessageItem +) { + // Combine author and timestamp for a11y. + Row(modifier = Modifier.semantics(mergeDescendants = true) {}) { + Text( + text = item.sender.name, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier + .alignBy(LastBaseline) + .paddingFrom(LastBaseline, after = 8.dp) // Space to 1st bubble + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = item.message.timestamp.toTimestamp(), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.alignBy(LastBaseline), + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +fun ChatItemBubble( + item: MessageItem, + isUserMe: Boolean +) { + val backgroundBubbleColor = if (isUserMe) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.surfaceVariant + } + + Column { + Surface( + color = backgroundBubbleColor, + shape = ChatBubbleShape + ) { + ChatMessage( + message = item.message, + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + } +} + +@Composable +fun ChatMessage( + message: Message, +) { + Text( + text = message.content, + style = MaterialTheme.typography.bodyLarge.copy(color = LocalContentColor.current), + modifier = Modifier.padding(16.dp), + ) +} + +fun Instant.toTimestamp(): String { + // Convert Instant to LocalDateTime in the system's default time zone + val localDateTime = this.toLocalDateTime(TimeZone.currentSystemDefault()) + + val minute = if (localDateTime.minute < 10) "0${localDateTime.minute}" else localDateTime.minute + val amOrPm = if (localDateTime.hour > 12) "PM" else "AM" + return "${localDateTime.hour}.$minute $amOrPm" +} + +private val ChatBubbleShape = RoundedCornerShape(4.dp, 20.dp, 20.dp, 20.dp) diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatViewModel.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatViewModel.kt new file mode 100644 index 0000000..cf2527b --- /dev/null +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/ChatViewModel.kt @@ -0,0 +1,142 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.demo.common.ui.chat + +import androidx.lifecycle.LifecycleCoroutineScope +import androidx.lifecycle.ViewModel +import com.tunjid.demo.common.ui.data.ChatRoom +import com.tunjid.demo.common.ui.data.ChatsRepository +import com.tunjid.demo.common.ui.data.Message +import com.tunjid.demo.common.ui.data.NavigationAction +import com.tunjid.demo.common.ui.data.NavigationRepository +import com.tunjid.demo.common.ui.data.Profile +import com.tunjid.demo.common.ui.data.ProfileRepository +import com.tunjid.demo.common.ui.data.SampleDestination +import com.tunjid.demo.common.ui.data.navigationAction +import com.tunjid.demo.common.ui.data.navigationMutations +import com.tunjid.mutator.Mutation +import com.tunjid.mutator.coroutines.actionStateFlowMutator +import com.tunjid.mutator.coroutines.mapToMutation +import com.tunjid.mutator.coroutines.toMutationStream +import com.tunjid.treenav.MultiStackNav +import com.tunjid.treenav.pop +import com.tunjid.treenav.push +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest + +class ChatViewModel( + coroutineScope: LifecycleCoroutineScope, + chatsRepository: ChatsRepository, + profileRepository: ProfileRepository, + navigationRepository: NavigationRepository = NavigationRepository, + chat: SampleDestination.Chat, +) : ViewModel() { + private val mutator = coroutineScope.actionStateFlowMutator( + initialState = State(), + inputs = listOf( + profileRepository.meMutations(), + chatsRepository.chatRoomMutations(chat), + chatLoadMutations( + chat = chat, + chatsRepository = chatsRepository, + profileRepository = profileRepository + ) + ), + actionTransform = { actions -> + actions.toMutationStream( + keySelector = Action::key + ) { + when (val type = type()) { + is Action.Navigation -> navigationRepository.navigationMutations( + type.flow + ) + } + } + } + ) + + val state = mutator.state + + val accept = mutator.accept +} + +private fun ProfileRepository.meMutations(): Flow> = + me.mapToMutation { copy(me = it) } + +private fun ChatsRepository.chatRoomMutations( + chat: SampleDestination.Chat +): Flow> = + room(roomName = chat.roomName) + .mapToMutation { copy(room = it) } + +private fun chatLoadMutations( + chat: SampleDestination.Chat, + chatsRepository: ChatsRepository, + profileRepository: ProfileRepository, +): Flow> = + chatsRepository.chatsFor(chat.roomName).flatMapLatest { chats -> + combine( + flows = chats.map { message -> profileRepository.profileFor(message.sender) } + ) { profiles -> + val namesToProfiles = profiles.associateBy(Profile::name) + chats.map { message -> + MessageItem( + message = message, + sender = namesToProfiles.getValue(message.sender) + ) + } + } + } + .mapToMutation { + copy(chats = it) + } + +data class State( + val me: Profile? = null, + val room: ChatRoom? = null, + val chats: List = emptyList() +) + +data class MessageItem( + val message: Message, + val sender: Profile, +) + +sealed class Action( + val key: String +) { + sealed class Navigation : Action("Navigation"), NavigationAction { + data object Pop : Navigation(), NavigationAction by navigationAction( + MultiStackNav::pop + ) + + data class GoToProfile( + val profileName: String, + val roomName: String, + ) : Navigation(), NavigationAction by navigationAction( + { + push( + SampleDestination.Profile( + profileName = profileName, + roomName = roomName, + ) + ) + } + ) + } +} \ No newline at end of file diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/Configuration.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/Configuration.kt new file mode 100644 index 0000000..e58153e --- /dev/null +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chat/Configuration.kt @@ -0,0 +1,56 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.demo.common.ui.chat + +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.coroutineScope +import androidx.lifecycle.viewmodel.compose.viewModel +import com.tunjid.demo.common.ui.data.ChatsRepository +import com.tunjid.demo.common.ui.data.ProfileRepository +import com.tunjid.demo.common.ui.data.SampleDestination +import com.tunjid.demo.common.ui.data.SampleDestination.NavTabs +import com.tunjid.treenav.adaptive.threepane.ThreePane +import com.tunjid.treenav.adaptive.threepane.configurations.movableSharedElementScope +import com.tunjid.treenav.adaptive.threepane.threePaneAdaptiveNodeConfiguration + +fun chatAdaptiveConfiguration( + destination: SampleDestination.Chat +) = threePaneAdaptiveNodeConfiguration( + render = { + val scope = LocalLifecycleOwner.current.lifecycle.coroutineScope + val viewModel = viewModel { + ChatViewModel( + coroutineScope = scope, + chatsRepository = ChatsRepository, + profileRepository = ProfileRepository, + chat = destination, + ) + } + ChatScreen( + movableSharedElementScope = movableSharedElementScope(), + state = viewModel.state.collectAsStateWithLifecycle().value, + onAction = viewModel.accept + ) + }, + paneMapping = { + mapOf( + ThreePane.Primary to it, + ThreePane.Secondary to NavTabs.ChatRooms, + ) + } +) diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/ChatRoomsScreen.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/ChatRoomsScreen.kt new file mode 100644 index 0000000..393bc95 --- /dev/null +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/ChatRoomsScreen.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.demo.common.ui.chatrooms + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.tunjid.demo.common.ui.SampleCollapsingHeader +import com.tunjid.demo.common.ui.data.ChatRoom + +@Composable +fun ChatRoomsScreen( + state: State, + onAction: (Action) -> Unit, +) { + SampleCollapsingHeader( + title = "Chat Rooms", + headerColor = MaterialTheme.colorScheme.primaryContainer, + onBackPressed = {} + ) { + LazyColumn( + modifier = Modifier.fillMaxSize() + ) { + items( + items = state.chatRooms, + key = ChatRoom::name, + itemContent = { room -> + Text( + text = room.name, + modifier = Modifier + .clickable { + onAction(Action.Navigation.ToRoom(roomName = room.name)) + } + ) + } + ) + } + } +} \ No newline at end of file diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/ChatRoomsViewModel.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/ChatRoomsViewModel.kt new file mode 100644 index 0000000..2adbe9e --- /dev/null +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/ChatRoomsViewModel.kt @@ -0,0 +1,82 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.demo.common.ui.chatrooms + +import androidx.lifecycle.LifecycleCoroutineScope +import androidx.lifecycle.ViewModel +import com.tunjid.demo.common.ui.data.ChatRoom +import com.tunjid.demo.common.ui.data.ChatsRepository +import com.tunjid.demo.common.ui.data.NavigationAction +import com.tunjid.demo.common.ui.data.NavigationRepository +import com.tunjid.demo.common.ui.data.SampleDestination +import com.tunjid.demo.common.ui.data.navigationAction +import com.tunjid.demo.common.ui.data.navigationMutations +import com.tunjid.mutator.Mutation +import com.tunjid.mutator.coroutines.actionStateFlowMutator +import com.tunjid.mutator.coroutines.mapToMutation +import com.tunjid.mutator.coroutines.toMutationStream +import com.tunjid.treenav.push +import kotlinx.coroutines.flow.Flow + +class ChatRoomsViewModel( + coroutineScope: LifecycleCoroutineScope, + chatsRepository: ChatsRepository = ChatsRepository, + navigationRepository: NavigationRepository = NavigationRepository, +) : ViewModel() { + private val mutator = coroutineScope.actionStateFlowMutator( + initialState = State(), + inputs = listOf( + chatsRepository.loadMutations() + ), + actionTransform = { actions -> + actions.toMutationStream( + keySelector = Action::key + ) { + when (val type = type()) { + is Action.Navigation -> navigationRepository.navigationMutations( + type.flow + ) + } + } + } + ) + + val state = mutator.state + + val accept = mutator.accept +} + +private fun ChatsRepository.loadMutations(): Flow> = rooms.mapToMutation { + copy(chatRooms = it) +} + + +data class State( + val chatRooms: List = emptyList() +) + +sealed class Action( + val key: String +) { + sealed class Navigation : Action("Navigation"), NavigationAction { + data class ToRoom( + val roomName: String, + ) : Navigation(), NavigationAction by navigationAction( + { push(SampleDestination.Chat(roomName)) } + ) + } +} \ No newline at end of file diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/Configuration.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/Configuration.kt new file mode 100644 index 0000000..0dbfdb2 --- /dev/null +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/chatrooms/Configuration.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.demo.common.ui.chatrooms + +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.coroutineScope +import androidx.lifecycle.viewmodel.compose.viewModel +import com.tunjid.demo.common.ui.data.ChatsRepository +import com.tunjid.demo.common.ui.data.SampleDestination +import com.tunjid.treenav.adaptive.threepane.threePaneAdaptiveNodeConfiguration + +fun chatRoomPaneConfiguration( +) = threePaneAdaptiveNodeConfiguration( + render = { + val scope = LocalLifecycleOwner.current.lifecycle.coroutineScope + val viewModel = viewModel { + ChatRoomsViewModel( + coroutineScope = scope, + chatsRepository = ChatsRepository + ) + } + ChatRoomsScreen( + state = viewModel.state.collectAsStateWithLifecycle().value, + onAction = viewModel.accept + ) + } +) \ No newline at end of file diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/data/AppData.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/data/AppData.kt new file mode 100644 index 0000000..f3fbb06 --- /dev/null +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/data/AppData.kt @@ -0,0 +1,191 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.demo.common.ui.data + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.datetime.Instant + +data class ChatData( + val chatRooms: Map, + val profiles: Map +) + +data class ChatRoom( + val name: String, + val messages: List +) + +data class Message( + val sender: String, + val timestamp: Instant, + val content: String +) + +data class Profile( + val name: String, + val jobTitle: String, + val location: String, + val selfDescription: String +) + +object ChatsRepository { + val rooms: Flow> = flowOf( + chatData.chatRooms.values.toList() + ) + + fun room(roomName: String): Flow = flowOf( + chatData.chatRooms.getValue(roomName) + ) + + fun chatsFor(roomName: String): Flow> = flowOf( + chatData.chatRooms[roomName]?.messages ?: emptyList() + ) +} + +object ProfileRepository { + val me: Flow = flowOf( + chatData.profiles.values.random() + ) + fun profileFor(name: String): Flow = flow { + chatData.profiles[name]?.let { emit(it) } + } +} + + +private val chatData = ChatData( + chatRooms = mapOf( + "SDK Design" to ChatRoom( + name = "SDK Design", + messages = listOf( + Message( + sender = "Aisha", + timestamp = Instant.parse("2024-10-05T13:00:00Z"), + content = "Hey team, any thoughts on how we should handle message delivery confirmations in the SDK?" + ), + Message( + sender = "Bjorn", + timestamp = Instant.parse("2024-10-05T13:05:00Z"), + content = "I think we should provide callbacks for both client-side and server-side confirmations." + ), + Message( + sender = "Kenji", + timestamp = Instant.parse("2024-10-05T13:10:00Z"), + content = "Agreed. We should also consider adding an option for offline message storage." + ) + ) + ), + "API Integration" to ChatRoom( + name = "API Integration", + messages = listOf( + Message( + sender = "Diego", + timestamp = Instant.parse("2024-10-05T14:00:00Z"), + content = "Has anyone started working on the API integration for user authentication?" + ), + Message( + sender = "Aisha", + timestamp = Instant.parse("2024-10-05T14:05:00Z"), + content = "I've been looking into it. I think OAuth 2.0 would be the best approach." + ) + ) + ), + "Testing" to ChatRoom( + name = "Testing", + messages = listOf( + Message( + sender = "Aisha", + timestamp = Instant.parse("2024-10-05T15:00:00Z"), + content = "Bjorn, are you ready to start testing the message sending functionality?" + ), + Message( + sender = "Bjorn", + timestamp = Instant.parse("2024-10-05T15:05:00Z"), + content = "Almost! Just finishing up the unit tests." + ) + ) + ), + "Documentation" to ChatRoom( + name = "Documentation", + messages = listOf( + Message( + sender = "Kenji", + timestamp = Instant.parse("2024-10-05T16:00:00Z"), + content = "Lin, can you start working on the documentation for the SDK?" + ), + Message( + sender = "Lin", + timestamp = Instant.parse("2024-10-05T16:05:00Z"), + content = "Sure, I'll get started on that today." + ) + ) + ), + "Random" to ChatRoom( + name = "Random", + messages = listOf( + Message( + sender = "Diego", + timestamp = Instant.parse("2024-10-05T17:00:00Z"), + content = "Anyone want to grab coffee?" + ), + Message( + sender = "Aisha", + timestamp = Instant.parse("2024-10-05T17:05:00Z"), + content = "Sure, I'm in!" + ), + Message( + sender = "Bjorn", + timestamp = Instant.parse("2024-10-05T17:10:00Z"), + content = "Count me in too!" + ) + ) + ) + ), + profiles = mapOf( + "Aisha" to Profile( + name = "Aisha", + jobTitle = "Lead Software Engineer", + location = "Lagos, Nigeria", + selfDescription = "Passionate about building scalable and reliable chat infrastructure." + ), + "Bjorn" to Profile( + name = "Bjorn", + jobTitle = "Software Engineer", + location = "Stockholm, Sweden", + selfDescription = "Enjoys tackling complex problems and writing clean, efficient code." + ), + "Kenji" to Profile( + name = "Kenji", + jobTitle = "QA Engineer", + location = "Tokyo, Japan", + selfDescription = "Dedicated to ensuring the quality and reliability of our chat SDK." + ), + "Diego" to Profile( + name = "Diego", + jobTitle = "Product Manager", + location = "Buenos Aires, Argentina", + selfDescription = "Driven by creating products that users love and that solve real problems." + ), + "Lin" to Profile( + name = "Lin", + jobTitle = "Technical Writer", + location = "Beijing, China", + selfDescription = "Loves explaining complex technical concepts in a clear and concise way." + ) + ) +) \ No newline at end of file diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/data/NavigationRepository.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/data/NavigationRepository.kt new file mode 100644 index 0000000..6cc1e51 --- /dev/null +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/data/NavigationRepository.kt @@ -0,0 +1,112 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.demo.common.ui.data + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.material.icons.filled.Settings +import com.tunjid.mutator.Mutation +import com.tunjid.mutator.coroutines.mapToManyMutations +import com.tunjid.treenav.MultiStackNav +import com.tunjid.treenav.Node +import com.tunjid.treenav.StackNav +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +sealed interface SampleDestination : Node { + + enum class NavTabs( + val title: String, + ) : SampleDestination { + ChatRooms("Chat Rooms"), + Settings("Settings"); + + override val id: String get() = title + + val icon + get() = when (this) { + ChatRooms -> Icons.AutoMirrored.Filled.List + Settings -> Icons.Default.Settings + } + } + + data class Chat( + val roomName: String, + ) : SampleDestination { + + override val id: String + get() = roomName + + override val children: List + get() = listOf(NavTabs.ChatRooms) + } + + data class Profile( + val profileName: String, + val roomName: String?, + ) : SampleDestination { + + override val id: String + get() = "$profileName-$roomName" + + override val children: List + get() = listOfNotNull( + roomName?.let(::Chat), + roomName?.let { NavTabs.ChatRooms } + ) + } +} + +fun interface NavigationAction { + fun navigate(multiStackNav: MultiStackNav): MultiStackNav +} + +fun navigationAction( + block: MultiStackNav.() -> MultiStackNav +) = NavigationAction(block) + +object NavigationRepository { + private val mutableNavigationStateFlow = MutableStateFlow(InitialNavState) + + val navigationStateFlow: StateFlow = mutableNavigationStateFlow.asStateFlow() + + fun navigate(action: NavigationAction) { + mutableNavigationStateFlow.update(action::navigate) + } +} + +fun NavigationRepository.navigationMutations( + navigationActions: Flow +): Flow> = + navigationActions.mapToManyMutations { + navigate(it) + } + +private val InitialNavState = MultiStackNav( + name = "Sample", + stacks = listOf( + StackNav( + name = "chatrooms", + children = listOf( + SampleDestination.NavTabs.ChatRooms, + ) + ) + ) +) \ No newline at end of file diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/Configuration.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/Configuration.kt new file mode 100644 index 0000000..c69500d --- /dev/null +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/Configuration.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.demo.common.ui.profile + +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.coroutineScope +import androidx.lifecycle.viewmodel.compose.viewModel +import com.tunjid.demo.common.ui.data.SampleDestination +import com.tunjid.demo.common.ui.data.SampleDestination.NavTabs +import com.tunjid.treenav.adaptive.threepane.ThreePane +import com.tunjid.treenav.adaptive.threepane.configurations.movableSharedElementScope +import com.tunjid.treenav.adaptive.threepane.threePaneAdaptiveNodeConfiguration + +fun profileAdaptiveConfiguration( + destination: SampleDestination.Profile +) = threePaneAdaptiveNodeConfiguration( + render = { + val scope = LocalLifecycleOwner.current.lifecycle.coroutineScope + val viewModel = viewModel { + ProfileViewModel( + coroutineScope = scope, + destination = destination, + ) + } + ProfileScreen( + movableSharedElementScope = movableSharedElementScope(), + state = viewModel.state.collectAsStateWithLifecycle().value, + onAction = viewModel.accept + ) + }, + paneMapping = { + mapOf( + ThreePane.Primary to destination, + ThreePane.Secondary to destination.roomName?.let(SampleDestination::Chat), + ThreePane.Tertiary to destination.roomName?.let { NavTabs.ChatRooms }, + ) + } +) \ No newline at end of file diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/ProfileScreen.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/ProfileScreen.kt new file mode 100644 index 0000000..d6f1d6a --- /dev/null +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/ProfileScreen.kt @@ -0,0 +1,122 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.demo.common.ui.profile + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.splineBasedDecay +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.statusBars +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import com.tunjid.composables.collapsingheader.CollapsingHeader +import com.tunjid.composables.collapsingheader.CollapsingHeaderState +import com.tunjid.demo.common.ui.ProfilePhoto +import com.tunjid.demo.common.ui.ProfilePhotoArgs +import com.tunjid.demo.common.ui.SampleTopAppBar +import com.tunjid.scaffold.treenav.adaptive.moveablesharedelement.MovableSharedElementScope +import kotlin.math.max +import kotlin.math.roundToInt + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun ProfileScreen( + movableSharedElementScope: MovableSharedElementScope, + state: State, + onAction: (Action) -> Unit, +) { + val density = LocalDensity.current + val collapsedHeight = with(density) { 56.dp.toPx() } + + WindowInsets.statusBars.getTop(density).toFloat() + + WindowInsets.statusBars.getBottom(density).toFloat() + val headerState = remember { + CollapsingHeaderState( + collapsedHeight = collapsedHeight, + initialExpandedHeight = with(density) { 400.dp.toPx() }, + decayAnimationSpec = splineBasedDecay(density) + ) + } + val animatedColor by animateColorAsState( + MaterialTheme.colorScheme.primaryContainer.copy( + alpha = max( + 1f - headerState.progress, + 0.6f + ) + ) + ) + CollapsingHeader( + state = headerState, + headerContent = { + Box { + Column( + modifier = Modifier + .fillMaxWidth() + .height(400.dp) + .offset { + IntOffset( + x = 0, + y = -headerState.translation.roundToInt() + ) + } + .background(animatedColor) + ) { + val profileName = state.profileName ?: state.profile?.name + if (profileName != null) { + val sharedImage = movableSharedElementScope.movableSharedElementOf( + key = profileName, + sharedElement = { args: ProfilePhotoArgs, innerModifier: Modifier -> + ProfilePhoto(args, innerModifier) + } + ) + sharedImage( + ProfilePhotoArgs( + profileName = profileName, + contentScale = ContentScale.Crop, + contentDescription = null, + ), + Modifier.fillMaxSize(), + ) + } + } + SampleTopAppBar( + title = state.profile?.name ?: "", + onBackPressed = { onAction(Action.Navigation.Pop) }, + modifier = Modifier.onSizeChanged { + headerState.collapsedHeight = it.height.toFloat() + } + ) + } + }, + body = { + } + ) +} \ No newline at end of file diff --git a/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/ProfileViewModel.kt b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/ProfileViewModel.kt new file mode 100644 index 0000000..b7345d1 --- /dev/null +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/profile/ProfileViewModel.kt @@ -0,0 +1,85 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.demo.common.ui.profile + +import androidx.lifecycle.LifecycleCoroutineScope +import androidx.lifecycle.ViewModel +import com.tunjid.demo.common.ui.data.NavigationAction +import com.tunjid.demo.common.ui.data.NavigationRepository +import com.tunjid.demo.common.ui.data.Profile +import com.tunjid.demo.common.ui.data.ProfileRepository +import com.tunjid.demo.common.ui.data.SampleDestination +import com.tunjid.demo.common.ui.data.navigationAction +import com.tunjid.demo.common.ui.data.navigationMutations +import com.tunjid.mutator.Mutation +import com.tunjid.mutator.coroutines.actionStateFlowMutator +import com.tunjid.mutator.coroutines.mapToMutation +import com.tunjid.mutator.coroutines.toMutationStream +import com.tunjid.treenav.MultiStackNav +import com.tunjid.treenav.pop +import kotlinx.coroutines.flow.Flow + +class ProfileViewModel( + coroutineScope: LifecycleCoroutineScope, + profileRepository: ProfileRepository = ProfileRepository, + navigationRepository: NavigationRepository = NavigationRepository, + destination: SampleDestination.Profile, +) : ViewModel() { + private val mutator = coroutineScope.actionStateFlowMutator( + initialState = State( + profileName = destination.profileName + ), + inputs = listOf( + profileRepository.profileMutations(destination) + ), + actionTransform = { actions -> + actions.toMutationStream( + keySelector = Action::key + ) { + when (val type = type()) { + is Action.Navigation -> navigationRepository.navigationMutations( + type.flow + ) + } + } + } + ) + + val state = mutator.state + + val accept = mutator.accept +} + +private fun ProfileRepository.profileMutations( + destination: SampleDestination.Profile, +): Flow> = + profileFor(destination.profileName).mapToMutation { copy(profile = it) } + +data class State( + val profileName: String? = null, + val profile: Profile? = null +) + +sealed class Action( + val key: String +) { + sealed class Navigation : Action("Navigation"), NavigationAction { + data object Pop : Navigation(), NavigationAction by navigationAction( + MultiStackNav::pop + ) + } +} \ No newline at end of file diff --git a/strings/build.gradle.kts b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/settings/Configuration.kt similarity index 67% rename from strings/build.gradle.kts rename to sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/settings/Configuration.kt index b6e150b..8ecee66 100644 --- a/strings/build.gradle.kts +++ b/sample/common/src/commonMain/kotlin/com/tunjid/demo/common/ui/settings/Configuration.kt @@ -14,16 +14,12 @@ * limitations under the License. */ -plugins { - `kotlin-library-convention` -} +package com.tunjid.demo.common.ui.settings -kotlin { - sourceSets { - val commonMain by getting { - dependencies { - implementation(project(":treenav")) - } - } - } -} +import com.tunjid.demo.common.ui.data.SampleDestination +import com.tunjid.treenav.adaptive.threepane.threePaneAdaptiveNodeConfiguration + +fun settingsPaneConfiguration( +) = threePaneAdaptiveNodeConfiguration( + render = { } +) \ No newline at end of file diff --git a/sample/common/src/iosMain/kotlin/main.ios.kt b/sample/common/src/iosMain/kotlin/main.ios.kt new file mode 100644 index 0000000..52228e6 --- /dev/null +++ b/sample/common/src/iosMain/kotlin/main.ios.kt @@ -0,0 +1,9 @@ +import androidx.compose.ui.window.ComposeUIViewController +import com.tunjid.demo.common.ui.AppTheme +import com.tunjid.demo.common.ui.App + +fun MainViewController() = ComposeUIViewController { + AppTheme { + App() + } +} diff --git a/sample/desktop/build.gradle.kts b/sample/desktop/build.gradle.kts new file mode 100755 index 0000000..a3d273f --- /dev/null +++ b/sample/desktop/build.gradle.kts @@ -0,0 +1,60 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import org.jetbrains.compose.desktop.application.dsl.TargetFormat + +plugins { + kotlin("multiplatform") // kotlin("jvm") doesn't work well in IDEA/AndroidStudio (https://github.com/JetBrains/compose-jb/issues/22) + id("org.jetbrains.compose") + id("kotlin-jvm-convention") + alias(libs.plugins.compose.compiler) +} + +kotlin { + jvm { + withJava() + } + + sourceSets { + named("jvmMain") { + dependencies { + implementation(project(":sample:common")) + + implementation(compose.desktop.currentOs) + implementation(libs.jetbrains.compose.material3) + implementation(libs.kotlinx.coroutines.swing) + } + } + } +} + +compose.desktop { + application { + mainClass = "com.tunjid.demo.MainKt" + + nativeDistributions { + targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) + packageName = "Tiling" + packageVersion = "1.0.0" + + windows { + menuGroup = "Compose Examples" + // see https://wixtoolset.org/documentation/manual/v3/howtos/general/generate_guids.html + upgradeUuid = "C2F20D8A-F643-4BB8-9ADD-28797B7514AF" + } + } + } +} diff --git a/sample/desktop/build/classes/kotlin/jvm/main/META-INF/desktop.kotlin_module b/sample/desktop/build/classes/kotlin/jvm/main/META-INF/desktop.kotlin_module new file mode 100644 index 0000000..a805767 Binary files /dev/null and b/sample/desktop/build/classes/kotlin/jvm/main/META-INF/desktop.kotlin_module differ diff --git a/sample/desktop/build/classes/kotlin/jvm/main/com/tunjid/demo/MainKt.class b/sample/desktop/build/classes/kotlin/jvm/main/com/tunjid/demo/MainKt.class new file mode 100644 index 0000000..cd1bf36 Binary files /dev/null and b/sample/desktop/build/classes/kotlin/jvm/main/com/tunjid/demo/MainKt.class differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/inputs/source-to-output.tab b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/inputs/source-to-output.tab new file mode 100644 index 0000000..bdf584a Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/inputs/source-to-output.tab differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/inputs/source-to-output.tab.keystream b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/inputs/source-to-output.tab.keystream new file mode 100644 index 0000000..931dcf0 Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/inputs/source-to-output.tab.keystream differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/inputs/source-to-output.tab.keystream.len b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/inputs/source-to-output.tab.keystream.len new file mode 100644 index 0000000..d06c17e Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/inputs/source-to-output.tab.keystream.len differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/inputs/source-to-output.tab.len b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/inputs/source-to-output.tab.len new file mode 100644 index 0000000..2a17e6e Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/inputs/source-to-output.tab.len differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/inputs/source-to-output.tab.values.at b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/inputs/source-to-output.tab.values.at new file mode 100644 index 0000000..2871190 Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/inputs/source-to-output.tab.values.at differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/inputs/source-to-output.tab_i b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/inputs/source-to-output.tab_i new file mode 100644 index 0000000..bce0089 Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/inputs/source-to-output.tab_i differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/inputs/source-to-output.tab_i.len b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/inputs/source-to-output.tab_i.len new file mode 100644 index 0000000..131e265 Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/inputs/source-to-output.tab_i.len differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab new file mode 100644 index 0000000..fb77ab0 Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.keystream b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.keystream new file mode 100644 index 0000000..8a97e0c Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.keystream differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.keystream.len b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.keystream.len new file mode 100644 index 0000000..80eee97 Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.keystream.len differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.len b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.len new file mode 100644 index 0000000..9e27f73 Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.len differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.values.at b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.values.at new file mode 100644 index 0000000..fd1772f Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab.values.at differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab_i b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab_i new file mode 100644 index 0000000..6807685 Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab_i differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab_i.len b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab_i.len new file mode 100644 index 0000000..131e265 Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/internal-name-to-source.tab_i.len differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/package-parts.tab b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/package-parts.tab new file mode 100644 index 0000000..bdf584a Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/package-parts.tab differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/package-parts.tab.keystream b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/package-parts.tab.keystream new file mode 100644 index 0000000..e7acecf Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/package-parts.tab.keystream differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/package-parts.tab.keystream.len b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/package-parts.tab.keystream.len new file mode 100644 index 0000000..7274ac0 Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/package-parts.tab.keystream.len differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/package-parts.tab.len b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/package-parts.tab.len new file mode 100644 index 0000000..2a17e6e Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/package-parts.tab.len differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/package-parts.tab.values.at b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/package-parts.tab.values.at new file mode 100644 index 0000000..46d6744 Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/package-parts.tab.values.at differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/package-parts.tab_i b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/package-parts.tab_i new file mode 100644 index 0000000..21b6db2 Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/package-parts.tab_i differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/package-parts.tab_i.len b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/package-parts.tab_i.len new file mode 100644 index 0000000..131e265 Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/package-parts.tab_i.len differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/proto.tab b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/proto.tab new file mode 100644 index 0000000..979c874 Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/proto.tab differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/proto.tab.keystream b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/proto.tab.keystream new file mode 100644 index 0000000..56bde15 Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/proto.tab.keystream differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/proto.tab.keystream.len b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/proto.tab.keystream.len new file mode 100644 index 0000000..c259288 Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/proto.tab.keystream.len differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/proto.tab.len b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/proto.tab.len new file mode 100644 index 0000000..01bdaa1 Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/proto.tab.len differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/proto.tab.values.at b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/proto.tab.values.at new file mode 100644 index 0000000..67fb84c Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/proto.tab.values.at differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/proto.tab_i b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/proto.tab_i new file mode 100644 index 0000000..6bca597 Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/proto.tab_i differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/proto.tab_i.len b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/proto.tab_i.len new file mode 100644 index 0000000..131e265 Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/proto.tab_i.len differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab new file mode 100644 index 0000000..bdf584a Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.keystream b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.keystream new file mode 100644 index 0000000..931dcf0 Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.keystream differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.keystream.len b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.keystream.len new file mode 100644 index 0000000..d06c17e Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.keystream.len differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.len b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.len new file mode 100644 index 0000000..2a17e6e Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.len differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.values.at b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.values.at new file mode 100644 index 0000000..c0418c1 Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab.values.at differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab_i b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab_i new file mode 100644 index 0000000..bce0089 Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab_i differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab_i.len b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab_i.len new file mode 100644 index 0000000..131e265 Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/jvm/kotlin/source-to-classes.tab_i.len differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/counters.tab b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/counters.tab new file mode 100644 index 0000000..166c057 --- /dev/null +++ b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/counters.tab @@ -0,0 +1,2 @@ +1 +0 \ No newline at end of file diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/file-to-id.tab b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/file-to-id.tab new file mode 100644 index 0000000..bdf584a Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/file-to-id.tab differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/file-to-id.tab.keystream b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/file-to-id.tab.keystream new file mode 100644 index 0000000..931dcf0 Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/file-to-id.tab.keystream differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/file-to-id.tab.keystream.len b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/file-to-id.tab.keystream.len new file mode 100644 index 0000000..d06c17e Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/file-to-id.tab.keystream.len differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/file-to-id.tab.len b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/file-to-id.tab.len new file mode 100644 index 0000000..2a17e6e Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/file-to-id.tab.len differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/file-to-id.tab.values.at b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/file-to-id.tab.values.at new file mode 100644 index 0000000..5875372 Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/file-to-id.tab.values.at differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/file-to-id.tab_i b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/file-to-id.tab_i new file mode 100644 index 0000000..bce0089 Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/file-to-id.tab_i differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/file-to-id.tab_i.len b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/file-to-id.tab_i.len new file mode 100644 index 0000000..131e265 Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/file-to-id.tab_i.len differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/id-to-file.tab b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/id-to-file.tab new file mode 100644 index 0000000..8aad32b Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/id-to-file.tab differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/id-to-file.tab.keystream b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/id-to-file.tab.keystream new file mode 100644 index 0000000..08e7df1 Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/id-to-file.tab.keystream differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/id-to-file.tab.keystream.len b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/id-to-file.tab.keystream.len new file mode 100644 index 0000000..b7da01d Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/id-to-file.tab.keystream.len differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/id-to-file.tab.len b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/id-to-file.tab.len new file mode 100644 index 0000000..2a17e6e Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/id-to-file.tab.len differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/id-to-file.tab.values.at b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/id-to-file.tab.values.at new file mode 100644 index 0000000..fd1e6b6 Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/id-to-file.tab.values.at differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/id-to-file.tab_i.len b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/id-to-file.tab_i.len new file mode 100644 index 0000000..1b1cb4d Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/id-to-file.tab_i.len differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/lookups.tab b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/lookups.tab new file mode 100644 index 0000000..75e4ef7 Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/lookups.tab differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/lookups.tab.keystream b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/lookups.tab.keystream new file mode 100644 index 0000000..f8e2957 Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/lookups.tab.keystream differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/lookups.tab.keystream.len b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/lookups.tab.keystream.len new file mode 100644 index 0000000..aab62cd Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/lookups.tab.keystream.len differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/lookups.tab.len b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/lookups.tab.len new file mode 100644 index 0000000..575d132 Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/lookups.tab.len differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/lookups.tab.values.at b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/lookups.tab.values.at new file mode 100644 index 0000000..a9f08da Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/lookups.tab.values.at differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/lookups.tab_i b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/lookups.tab_i new file mode 100644 index 0000000..1faf477 Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/lookups.tab_i differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/lookups.tab_i.len b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/lookups.tab_i.len new file mode 100644 index 0000000..131e265 Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/caches-jvm/lookups/lookups.tab_i.len differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/last-build.bin b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/last-build.bin new file mode 100644 index 0000000..d80288d Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/cacheable/last-build.bin differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/classpath-snapshot/shrunk-classpath-snapshot.bin b/sample/desktop/build/kotlin/compileKotlinJvm/classpath-snapshot/shrunk-classpath-snapshot.bin new file mode 100644 index 0000000..aeb5c0c Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/classpath-snapshot/shrunk-classpath-snapshot.bin differ diff --git a/sample/desktop/build/kotlin/compileKotlinJvm/local-state/build-history.bin b/sample/desktop/build/kotlin/compileKotlinJvm/local-state/build-history.bin new file mode 100644 index 0000000..6cfb852 Binary files /dev/null and b/sample/desktop/build/kotlin/compileKotlinJvm/local-state/build-history.bin differ diff --git a/sample/desktop/build/libs/desktop-jvm.jar b/sample/desktop/build/libs/desktop-jvm.jar new file mode 100644 index 0000000..f6c4e65 Binary files /dev/null and b/sample/desktop/build/libs/desktop-jvm.jar differ diff --git a/sample/desktop/build/tmp/jvmJar/MANIFEST.MF b/sample/desktop/build/tmp/jvmJar/MANIFEST.MF new file mode 100644 index 0000000..58630c0 --- /dev/null +++ b/sample/desktop/build/tmp/jvmJar/MANIFEST.MF @@ -0,0 +1,2 @@ +Manifest-Version: 1.0 + diff --git a/sample/desktop/src/jvmMain/kotlin/com/tunjid/demo/Main.kt b/sample/desktop/src/jvmMain/kotlin/com/tunjid/demo/Main.kt new file mode 100644 index 0000000..ab8762a --- /dev/null +++ b/sample/desktop/src/jvmMain/kotlin/com/tunjid/demo/Main.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.tunjid.demo + +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.application +import androidx.compose.ui.window.rememberWindowState +import com.tunjid.demo.common.ui.AppTheme +import com.tunjid.demo.common.ui.App + +fun main() { + application { + val windowState = rememberWindowState( + size = DpSize(400.dp, 800.dp) + ) + Window( + onCloseRequest = ::exitApplication, + state = windowState, + title = "Tiling Demo" + ) { + AppTheme { + App() + } + } + } +} + + diff --git a/settings.gradle.kts b/settings.gradle.kts index 36aefde..8b5c097 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -15,23 +15,33 @@ */ pluginManagement { + includeBuild("build-logic") repositories { - gradlePluginPortal() + google() mavenCentral() + gradlePluginPortal() + maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") } +} - plugins { - val kotlinVersion = "1.6.10" - - kotlin("jvm").version(kotlinVersion) - kotlin("dokka").version(kotlinVersion) - kotlin("multiplatform").version(kotlinVersion) +dependencyResolutionManagement { + // Workaround for KT-51379 + repositoriesMode.set(RepositoriesMode.PREFER_PROJECT) + repositories { + google() + mavenCentral() + maven("https://maven.pkg.jetbrains.space/public/p/compose/dev") } } -rootProject.name = "TreeNav" + +rootProject.name = "TreeNavigation" include( - ":treenav", - ":strings", + ":library:treenav", + ":library:strings", + ":library:adaptive", + ":sample:android", + ":sample:common", + ":sample:desktop", )