diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6331c57..fcd0ec9 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -38,11 +38,17 @@ jobs:
uses: gradle/actions/setup-gradle@v4
- name: Build and run checks
- id: gradle-build
+ id: gradle-check
run: |
./gradlew --quiet --continue --no-configuration-cache \
check
+ - name: Build sample app
+ id: gradle-sample-build
+ run: |
+ ./gradlew --quiet --continue --no-configuration-cache \
+ :sample:desktopApp:build
+
- name: (Fail-only) Upload reports
if: failure()
uses: actions/upload-artifact@v4
diff --git a/.run/Desktop App.run.xml b/.run/Desktop App.run.xml
new file mode 100644
index 0000000..1d0f8e9
--- /dev/null
+++ b/.run/Desktop App.run.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/annotations/src/commonMain/kotlin/com/r0adkll/kimchi/annotations/ContributesSubcomponent.kt b/annotations/src/commonMain/kotlin/com/r0adkll/kimchi/annotations/ContributesSubcomponent.kt
index 3ed89b7..ed63135 100644
--- a/annotations/src/commonMain/kotlin/com/r0adkll/kimchi/annotations/ContributesSubcomponent.kt
+++ b/annotations/src/commonMain/kotlin/com/r0adkll/kimchi/annotations/ContributesSubcomponent.kt
@@ -9,4 +9,9 @@ import kotlin.reflect.KClass
annotation class ContributesSubcomponent(
val scope: KClass<*>,
val parentScope: KClass<*>,
-)
+) {
+
+ @MustBeDocumented
+ @Retention(AnnotationRetention.SOURCE)
+ annotation class Factory
+}
diff --git a/build.gradle.kts b/build.gradle.kts
index 81791cc..0a4b573 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -11,9 +11,14 @@ import java.net.URI
import org.jetbrains.dokka.gradle.DokkaTaskPartial
plugins {
+ alias(libs.plugins.androidApplication) apply false
alias(libs.plugins.androidLibrary) apply false
+ alias(libs.plugins.compose.compiler) apply false
+ alias(libs.plugins.compose.multiplatform) apply false
alias(libs.plugins.dokka)
+ alias(libs.plugins.kotlinAndroid) apply false
alias(libs.plugins.kotlinMultiplatform) apply false
+ alias(libs.plugins.kotlinParcelize) apply false
alias(libs.plugins.ksp) apply false
alias(libs.plugins.mavenPublish) apply false
alias(libs.plugins.spotless)
diff --git a/compiler-utils/src/main/kotlin/com/r0adkll/kimchi/util/kotlinpoet/Functions.kt b/compiler-utils/src/main/kotlin/com/r0adkll/kimchi/util/kotlinpoet/Functions.kt
new file mode 100644
index 0000000..fc968b4
--- /dev/null
+++ b/compiler-utils/src/main/kotlin/com/r0adkll/kimchi/util/kotlinpoet/Functions.kt
@@ -0,0 +1,10 @@
+// Copyright (C) 2024 r0adkll
+// SPDX-License-Identifier: Apache-2.0
+package com.r0adkll.kimchi.util.kotlinpoet
+
+import com.google.devtools.ksp.symbol.KSFunctionDeclaration
+import com.squareup.kotlinpoet.ParameterSpec
+
+fun KSFunctionDeclaration.parameterSpecs(): List {
+ return parameters.map { it.toParameterSpec() }
+}
diff --git a/compiler-utils/src/main/kotlin/com/r0adkll/kimchi/util/kotlinpoet/Parameters.kt b/compiler-utils/src/main/kotlin/com/r0adkll/kimchi/util/kotlinpoet/Parameters.kt
new file mode 100644
index 0000000..16e2115
--- /dev/null
+++ b/compiler-utils/src/main/kotlin/com/r0adkll/kimchi/util/kotlinpoet/Parameters.kt
@@ -0,0 +1,18 @@
+// Copyright (C) 2024 r0adkll
+// SPDX-License-Identifier: Apache-2.0
+package com.r0adkll.kimchi.util.kotlinpoet
+
+import com.google.devtools.ksp.symbol.KSValueParameter
+import com.squareup.kotlinpoet.ParameterSpec
+import com.squareup.kotlinpoet.ksp.toAnnotationSpec
+import com.squareup.kotlinpoet.ksp.toTypeName
+
+fun KSValueParameter.toParameterSpec(): ParameterSpec {
+ return ParameterSpec.builder(name!!.asString(), type.toTypeName())
+ .addAnnotations(
+ annotations
+ .map { it.toAnnotationSpec() }
+ .toList(),
+ )
+ .build()
+}
diff --git a/compiler/src/main/kotlin/com/r0adkll/kimchi/processors/MergeComponentSymbolProcessor.kt b/compiler/src/main/kotlin/com/r0adkll/kimchi/processors/MergeComponentSymbolProcessor.kt
index 28bdc16..4059650 100644
--- a/compiler/src/main/kotlin/com/r0adkll/kimchi/processors/MergeComponentSymbolProcessor.kt
+++ b/compiler/src/main/kotlin/com/r0adkll/kimchi/processors/MergeComponentSymbolProcessor.kt
@@ -20,6 +20,8 @@ import com.r0adkll.kimchi.util.addIfNonNull
import com.r0adkll.kimchi.util.buildClass
import com.r0adkll.kimchi.util.buildFile
import com.r0adkll.kimchi.util.kotlinpoet.addBinding
+import com.r0adkll.kimchi.util.kotlinpoet.parameterSpecs
+import com.r0adkll.kimchi.util.ksp.SubcomponentDeclaration
import com.r0adkll.kimchi.util.ksp.findAnnotation
import com.r0adkll.kimchi.util.ksp.getScope
import com.r0adkll.kimchi.util.ksp.getSymbolsWithClassAnnotation
@@ -129,7 +131,8 @@ internal class MergeComponentSymbolProcessor(
parent: ClassName? = null,
): TypeSpec {
val classSimpleName = "Merged${element.simpleName.asString()}"
- val className = ClassName(packageName, classSimpleName)
+ val className = parent?.nestedClass(classSimpleName)
+ ?: ClassName(packageName, classSimpleName)
val isSubcomponent: Boolean = parent != null
val annotationKlass = if (isSubcomponent) ContributesSubcomponent::class else MergeComponent::class
@@ -142,7 +145,7 @@ internal class MergeComponentSymbolProcessor(
val subcomponents = classScanner.findContributedClasses(
annotation = ContributesSubcomponent::class,
scope = scope,
- )
+ ).map { SubcomponentDeclaration(it) }
val modules = classScanner.findContributedClasses(
annotation = ContributesTo::class,
@@ -184,7 +187,14 @@ internal class MergeComponentSymbolProcessor(
superclass(element.toClassName())
}
- val constructorParams = getConstructorParameters(element)
+ // If we are generating a subcomponent, then parse the underlying component constructor params
+ // from its defined factory class and function.
+ val constructorParams = if (isSubcomponent) {
+ val subcomponent = SubcomponentDeclaration(element)
+ subcomponent.factoryClass.factoryFunction.parameterSpecs()
+ } else {
+ getConstructorParameters(element)
+ }
// If this is a subcomponent, i.e it has a parent,
// then we need to add it's parent as an @Component parameter, but
@@ -211,8 +221,13 @@ internal class MergeComponentSymbolProcessor(
.build(),
)
- constructorParams.map { it.name }.forEach {
- addSuperclassConstructorParameter("%L", it)
+ // Subcomponents currently have a hard restriction on being interfaces with using a Factory
+ // to define how the merged component implements its constructor parameters. So we can just
+ // skip adding superclass constructor params in this case.
+ if (!isSubcomponent) {
+ constructorParams.map { it.name }.forEach {
+ addSuperclassConstructorParameter("%L", it)
+ }
}
// Add all the contributed interfaces
@@ -234,29 +249,17 @@ internal class MergeComponentSymbolProcessor(
// Now iterate through all the subcomponents, and add them
subcomponents.forEach { subcomponent ->
- val subcomponentClassSimpleName = "Merged${subcomponent.simpleName.asString()}"
- val subcomponentClassName = className.nestedClass(subcomponentClassSimpleName)
- val subcomponentConstructorParams = getConstructorParameters(subcomponent)
-
- // Generate a creation method for the component
- addFunction(
- FunSpec.builder("create${subcomponent.simpleName.asString().replaceFirstChar { it.uppercaseChar() }}")
- .returns(subcomponentClassName)
- .addParameters(subcomponentConstructorParams)
- .addStatement(
- "return %T.create(${subcomponentConstructorParams.joinToString { "%L" }}" +
- "${if (subcomponentConstructorParams.isNotEmpty()) ", " else ""}this)",
- subcomponentClassName,
- *subcomponentConstructorParams.map { it.name }.toTypedArray(),
- )
- .build(),
- )
+ // Add this subcomponents factory to the parent
+ addSuperinterface(subcomponent.factoryClass.toClassName())
+
+ // Generate the factory creation function overload to generate this subcomponent
+ addFunction(subcomponent.createFactoryFunctionOverload())
// Generate the Subcomponent
addType(
generateComponent(
classScanner = classScanner,
- packageName = "$packageName.$classSimpleName",
+ packageName = packageName,
element = subcomponent,
parent = className,
),
diff --git a/compiler/src/main/kotlin/com/r0adkll/kimchi/util/ksp/SubcomponentDeclaration.kt b/compiler/src/main/kotlin/com/r0adkll/kimchi/util/ksp/SubcomponentDeclaration.kt
new file mode 100644
index 0000000..4f31d41
--- /dev/null
+++ b/compiler/src/main/kotlin/com/r0adkll/kimchi/util/ksp/SubcomponentDeclaration.kt
@@ -0,0 +1,94 @@
+// Copyright (C) 2024 r0adkll
+// SPDX-License-Identifier: Apache-2.0
+package com.r0adkll.kimchi.util.ksp
+
+import com.google.devtools.ksp.KspExperimental
+import com.google.devtools.ksp.getDeclaredFunctions
+import com.google.devtools.ksp.isAnnotationPresent
+import com.google.devtools.ksp.symbol.KSClassDeclaration
+import com.google.devtools.ksp.symbol.KSFunctionDeclaration
+import com.r0adkll.kimchi.annotations.ContributesSubcomponent
+import com.r0adkll.kimchi.util.buildFun
+import com.r0adkll.kimchi.util.kotlinpoet.parameterSpecs
+import com.squareup.kotlinpoet.FunSpec
+import com.squareup.kotlinpoet.KModifier
+import com.squareup.kotlinpoet.ksp.toClassName
+import com.squareup.kotlinpoet.ksp.toTypeName
+
+/**
+ * A custom overlay of [KSClassDeclaration] to provide a unified way of
+ * accessing the specific components and assumptions of declarations
+ * annotated with [com.r0adkll.kimchi.annotations.ContributesSubcomponent]
+ */
+class SubcomponentDeclaration(
+ private val clazz: KSClassDeclaration,
+) : KSClassDeclaration by clazz {
+
+ val subcomponentSimpleName: String
+ get() = "Merged${simpleName.asString()}"
+
+ @OptIn(KspExperimental::class)
+ val factoryClass: FactoryDeclaration by lazy {
+ declarations
+ .filterIsInstance()
+ .filter { it.isAnnotationPresent(ContributesSubcomponent.Factory::class) }
+ .map { FactoryDeclaration(this, it) }
+ .firstOrNull()
+ ?: throw IllegalStateException(
+ "@ContributesSubcomponent must define a factory interface annotated with " +
+ "@ContributesSubcomponent.Factory",
+ )
+ }
+
+ fun createFactoryFunctionOverload(): FunSpec = with(factoryClass) {
+ return FunSpec.buildFun(factoryFunction.simpleName.asString()) {
+ addModifiers(KModifier.OVERRIDE)
+
+ returns(factoryFunction.returnType!!.toTypeName())
+
+ val factoryParameters = factoryFunction.parameterSpecs()
+ addParameters(factoryParameters)
+
+ // Build the return statement constructing the expected merged subcomponent, including
+ // parent.
+ addStatement(
+ "return %L.create(${factoryParameters.joinToString { "%L" }}" +
+ "${if (factoryParameters.isNotEmpty()) ", " else ""}this)",
+ subcomponentSimpleName,
+ *factoryParameters.map { it.name }.toTypedArray(),
+ )
+ }
+ }
+
+ /**
+ * A custom overlay of [KSClassDeclaration] to provide a unified way of accessing
+ * the specific components and assumptions of declarations annotated with
+ * [com.r0adkll.kimchi.annotations.ContributesSubcomponent.Factory]
+ */
+ class FactoryDeclaration(
+ private val subcomponent: SubcomponentDeclaration,
+ private val clazz: KSClassDeclaration,
+ ) : KSClassDeclaration by clazz {
+
+ init {
+ require(isInterface) {
+ "@ContributesSubcomponent.Factory annotated declarations must be an interface"
+ }
+ }
+
+ val factoryFunction: KSFunctionDeclaration by lazy {
+ getDeclaredFunctions()
+ .singleOrNull()
+ ?.also {
+ require(it.returnType != null) { "Factory methods are required to return their component" }
+ require(it.returnType!!.toTypeName() == subcomponent.toClassName()) {
+ "Factory methods are required to return their component"
+ }
+ }
+ ?: throw IllegalStateException(
+ "@ContributeSubcomponent.Factory interfaces must only have a " +
+ "single function declared",
+ )
+ }
+ }
+}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 80fe410..a6393cb 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,6 +1,9 @@
[versions]
agp = "8.5.2"
+androidx-activityCompose = "1.9.1"
autoservice = "1.1.1"
+circuit = "0.23.1"
+compose-multiplatform = "1.6.11"
dokka = "1.9.20"
kotlin = "2.0.10"
kotlin-inject = "0.7.1"
@@ -10,10 +13,10 @@ ksp-autoservce = "1.2.0"
ktlint = "0.49.1"
mavenPublish = "0.29.0"
spotless = "6.25.0"
-jvmTarget = "11"
-publishedJvmTarget = "1.8"
+material3Android = "1.2.1"
[libraries]
+androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" }
autoservice-annotations = { module = "com.google.auto.service:auto-service-annotations", version.ref = "autoservice" }
kotlin-ksp = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "ksp" }
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
@@ -23,13 +26,24 @@ kotlinpoet = { module = "com.squareup:kotlinpoet", version.ref = "kotlinpoet" }
kotlinpoet-ksp = { module = "com.squareup:kotlinpoet-ksp", version.ref = "kotlinpoet" }
ksp-autoservice = { module = "dev.zacsweers.autoservice:auto-service-ksp", version.ref = "ksp-autoservce" }
+# Sample app dependencies
+circuit-foundation = { module = "com.slack.circuit:circuit-foundation", version.ref = "circuit" }
+circuit-overlay = { module = "com.slack.circuit:circuit-overlay", version.ref = "circuit" }
+circuit-runtime = { module = "com.slack.circuit:circuit-runtime", version.ref = "circuit" }
+circuit-retained = { module = "com.slack.circuit:circuit-retained", version.ref = "circuit" }
+circuitx-gesturenav = { module = "com.slack.circuit:circuitx-gesture-navigation", version.ref = "circuit" }
+androidx-material3-android = { group = "androidx.compose.material3", name = "material3-android", version.ref = "material3Android" }
+
[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }
androidLibrary = { id = "com.android.library", version.ref = "agp" }
+compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
+compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" }
kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlinCocoapods = { id = "org.jetbrains.kotlin.native.cocoapods", version.ref = "kotlin" }
+kotlinParcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
mavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublish" }
spotless = { id = "com.diffplug.spotless", version.ref = "spotless" }
diff --git a/sample/androidApp/build.gradle.kts b/sample/androidApp/build.gradle.kts
new file mode 100644
index 0000000..fc074df
--- /dev/null
+++ b/sample/androidApp/build.gradle.kts
@@ -0,0 +1,63 @@
+// Copyright (C) 2024 r0adkll
+// SPDX-License-Identifier: Apache-2.0
+plugins {
+ alias(libs.plugins.androidApplication)
+ alias(libs.plugins.kotlinAndroid)
+ alias(libs.plugins.compose.compiler)
+ alias(libs.plugins.compose.multiplatform)
+ alias(libs.plugins.ksp)
+}
+
+android {
+ namespace = "com.r0adkll.kimchi.restaurant.android"
+ compileSdk = 34
+
+ defaultConfig {
+ applicationId = "com.r0adkll.kimchi.restaurant.android"
+ minSdk = 26
+ targetSdk = 34
+ versionCode = 1
+ versionName = "1.0"
+ }
+
+ buildFeatures {
+ compose = true
+ }
+
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+
+ buildTypes {
+ getByName("release") {
+ isMinifyEnabled = false
+ }
+ }
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+}
+
+dependencies {
+ implementation(projects.annotations)
+ implementation(projects.sample.shared)
+
+ implementation(libs.androidx.activity.compose)
+ implementation(libs.circuit.runtime)
+ implementation(libs.circuit.foundation)
+
+ api(compose.foundation)
+ api(compose.material)
+ api(compose.material3)
+ api(compose.materialIconsExtended)
+ api(compose.animation)
+
+ ksp(projects.compiler)
+ ksp(libs.kotlininject.ksp)
+}
diff --git a/sample/androidApp/src/main/AndroidManifest.xml b/sample/androidApp/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..f5ba8a0
--- /dev/null
+++ b/sample/androidApp/src/main/AndroidManifest.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/sample/androidApp/src/main/java/com/r0adkll/kimchi/restaurant/android/MainActivity.kt b/sample/androidApp/src/main/java/com/r0adkll/kimchi/restaurant/android/MainActivity.kt
new file mode 100644
index 0000000..f31c27e
--- /dev/null
+++ b/sample/androidApp/src/main/java/com/r0adkll/kimchi/restaurant/android/MainActivity.kt
@@ -0,0 +1,15 @@
+// Copyright (C) 2024 r0adkll
+// SPDX-License-Identifier: Apache-2.0
+package com.r0adkll.kimchi.restaurant.android
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent {
+ }
+ }
+}
diff --git a/sample/androidApp/src/main/java/com/r0adkll/kimchi/restaurant/android/MyApplicationTheme.kt b/sample/androidApp/src/main/java/com/r0adkll/kimchi/restaurant/android/MyApplicationTheme.kt
new file mode 100644
index 0000000..1565a5e
--- /dev/null
+++ b/sample/androidApp/src/main/java/com/r0adkll/kimchi/restaurant/android/MyApplicationTheme.kt
@@ -0,0 +1,57 @@
+// Copyright (C) 2024 r0adkll
+// SPDX-License-Identifier: Apache-2.0
+package com.r0adkll.kimchi.restaurant.android
+
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Shapes
+import androidx.compose.material3.Typography
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+
+@Composable
+fun MyApplicationTheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ content: @Composable () -> Unit,
+) {
+ val colors = if (darkTheme) {
+ darkColorScheme(
+ primary = Color(0xFFBB86FC),
+ secondary = Color(0xFF03DAC5),
+ tertiary = Color(0xFF3700B3),
+ )
+ } else {
+ lightColorScheme(
+ primary = Color(0xFF6200EE),
+ secondary = Color(0xFF03DAC5),
+ tertiary = Color(0xFF3700B3),
+ )
+ }
+ val typography = Typography(
+ bodyMedium = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp,
+ ),
+ )
+ val shapes = Shapes(
+ small = RoundedCornerShape(4.dp),
+ medium = RoundedCornerShape(4.dp),
+ large = RoundedCornerShape(0.dp),
+ )
+
+ MaterialTheme(
+ colorScheme = colors,
+ typography = typography,
+ shapes = shapes,
+ content = content,
+ )
+}
diff --git a/sample/androidApp/src/main/java/com/r0adkll/kimchi/restaurant/android/RestaurantApplication.kt b/sample/androidApp/src/main/java/com/r0adkll/kimchi/restaurant/android/RestaurantApplication.kt
new file mode 100644
index 0000000..431845a
--- /dev/null
+++ b/sample/androidApp/src/main/java/com/r0adkll/kimchi/restaurant/android/RestaurantApplication.kt
@@ -0,0 +1,12 @@
+// Copyright (C) 2024 r0adkll
+// SPDX-License-Identifier: Apache-2.0
+package com.r0adkll.kimchi.restaurant.android
+
+import android.app.Application
+
+class RestaurantApplication : Application() {
+
+ override fun onCreate() {
+ super.onCreate()
+ }
+}
diff --git a/sample/androidApp/src/main/res/values/styles.xml b/sample/androidApp/src/main/res/values/styles.xml
new file mode 100644
index 0000000..6b4fa3d
--- /dev/null
+++ b/sample/androidApp/src/main/res/values/styles.xml
@@ -0,0 +1,3 @@
+
+
+
\ No newline at end of file
diff --git a/sample/common/build.gradle.kts b/sample/common/build.gradle.kts
new file mode 100644
index 0000000..8624b3a
--- /dev/null
+++ b/sample/common/build.gradle.kts
@@ -0,0 +1,60 @@
+// Copyright (C) 2024 r0adkll
+// SPDX-License-Identifier: Apache-2.0
+import org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType
+
+// Copyright (C) 2024 r0adkll
+// SPDX-License-Identifier: Apache-2.0
+plugins {
+ alias(libs.plugins.kotlinMultiplatform)
+ alias(libs.plugins.androidLibrary)
+ alias(libs.plugins.kotlinParcelize)
+}
+
+kotlin {
+ jvm()
+ androidTarget()
+
+ applyDefaultHierarchyTemplate()
+
+ listOf(
+ iosX64(),
+ iosArm64(),
+ iosSimulatorArm64(),
+ ).forEach {
+ it.binaries.framework {
+ baseName = "common"
+ isStatic = true
+ }
+ }
+
+ sourceSets {
+ commonMain.dependencies {
+ api(projects.annotations)
+ api(projects.circuit.annotations)
+
+ api(libs.circuit.runtime)
+ }
+ commonTest.dependencies {
+ implementation(libs.kotlin.test)
+ }
+ }
+
+ targets.configureEach {
+ val isAndroidTarget = platformType == KotlinPlatformType.androidJvm
+ compilations.configureEach {
+ compileTaskProvider.configure {
+ compilerOptions {
+ if (isAndroidTarget) {
+ freeCompilerArgs.addAll(
+ "-P",
+ "plugin:org.jetbrains.kotlin.parcelize:additionalAnnotation=" +
+ "com.r0adkll.kimchi.restaurant.common.screens.Parcelize",
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+android { namespace = "com.r0adkll.kimchi.restaurant.common" }
diff --git a/sample/common/src/commonMain/kotlin/com/r0adkll/kimchi/restaurant/common/ComponentHolder.kt b/sample/common/src/commonMain/kotlin/com/r0adkll/kimchi/restaurant/common/ComponentHolder.kt
new file mode 100644
index 0000000..ddffb12
--- /dev/null
+++ b/sample/common/src/commonMain/kotlin/com/r0adkll/kimchi/restaurant/common/ComponentHolder.kt
@@ -0,0 +1,14 @@
+// Copyright (C) 2024 r0adkll
+// SPDX-License-Identifier: Apache-2.0
+package com.r0adkll.kimchi.restaurant.common
+
+object ComponentHolder {
+ var components = mutableSetOf()
+
+ inline fun component(): T {
+ return components
+ .filterIsInstance()
+ .firstOrNull()
+ ?: throw IllegalArgumentException("Unable to find a component for type '${T::class.simpleName}'")
+ }
+}
diff --git a/sample/common/src/commonMain/kotlin/com/r0adkll/kimchi/restaurant/common/scopes/AppScope.kt b/sample/common/src/commonMain/kotlin/com/r0adkll/kimchi/restaurant/common/scopes/AppScope.kt
new file mode 100644
index 0000000..cd207b9
--- /dev/null
+++ b/sample/common/src/commonMain/kotlin/com/r0adkll/kimchi/restaurant/common/scopes/AppScope.kt
@@ -0,0 +1,5 @@
+// Copyright (C) 2024 r0adkll
+// SPDX-License-Identifier: Apache-2.0
+package com.r0adkll.kimchi.restaurant.common.scopes
+
+object AppScope
diff --git a/sample/common/src/commonMain/kotlin/com/r0adkll/kimchi/restaurant/common/scopes/SingleIn.kt b/sample/common/src/commonMain/kotlin/com/r0adkll/kimchi/restaurant/common/scopes/SingleIn.kt
new file mode 100644
index 0000000..31e4bf3
--- /dev/null
+++ b/sample/common/src/commonMain/kotlin/com/r0adkll/kimchi/restaurant/common/scopes/SingleIn.kt
@@ -0,0 +1,10 @@
+// Copyright (C) 2024 r0adkll
+// SPDX-License-Identifier: Apache-2.0
+package com.r0adkll.kimchi.restaurant.common.scopes
+
+import kotlin.reflect.KClass
+import me.tatarka.inject.annotations.Scope
+
+@Scope
+@Retention(AnnotationRetention.RUNTIME)
+annotation class SingleIn(val scope: KClass<*>)
diff --git a/sample/common/src/commonMain/kotlin/com/r0adkll/kimchi/restaurant/common/scopes/UiScope.kt b/sample/common/src/commonMain/kotlin/com/r0adkll/kimchi/restaurant/common/scopes/UiScope.kt
new file mode 100644
index 0000000..f401f71
--- /dev/null
+++ b/sample/common/src/commonMain/kotlin/com/r0adkll/kimchi/restaurant/common/scopes/UiScope.kt
@@ -0,0 +1,5 @@
+// Copyright (C) 2024 r0adkll
+// SPDX-License-Identifier: Apache-2.0
+package com.r0adkll.kimchi.restaurant.common.scopes
+
+object UiScope
diff --git a/sample/common/src/commonMain/kotlin/com/r0adkll/kimchi/restaurant/common/scopes/UserScope.kt b/sample/common/src/commonMain/kotlin/com/r0adkll/kimchi/restaurant/common/scopes/UserScope.kt
new file mode 100644
index 0000000..fd928c1
--- /dev/null
+++ b/sample/common/src/commonMain/kotlin/com/r0adkll/kimchi/restaurant/common/scopes/UserScope.kt
@@ -0,0 +1,5 @@
+// Copyright (C) 2024 r0adkll
+// SPDX-License-Identifier: Apache-2.0
+package com.r0adkll.kimchi.restaurant.common.scopes
+
+object UserScope
diff --git a/sample/common/src/commonMain/kotlin/com/r0adkll/kimchi/restaurant/common/screens/MenuScreen.kt b/sample/common/src/commonMain/kotlin/com/r0adkll/kimchi/restaurant/common/screens/MenuScreen.kt
new file mode 100644
index 0000000..4044fe7
--- /dev/null
+++ b/sample/common/src/commonMain/kotlin/com/r0adkll/kimchi/restaurant/common/screens/MenuScreen.kt
@@ -0,0 +1,8 @@
+// Copyright (C) 2024 r0adkll
+// SPDX-License-Identifier: Apache-2.0
+package com.r0adkll.kimchi.restaurant.common.screens
+
+import com.slack.circuit.runtime.screen.Screen
+
+@Parcelize
+data object MenuScreen : Screen
diff --git a/sample/common/src/commonMain/kotlin/com/r0adkll/kimchi/restaurant/common/screens/Parcelize.kt b/sample/common/src/commonMain/kotlin/com/r0adkll/kimchi/restaurant/common/screens/Parcelize.kt
new file mode 100644
index 0000000..6c2b45a
--- /dev/null
+++ b/sample/common/src/commonMain/kotlin/com/r0adkll/kimchi/restaurant/common/screens/Parcelize.kt
@@ -0,0 +1,7 @@
+// Copyright (C) 2024 r0adkll
+// SPDX-License-Identifier: Apache-2.0
+package com.r0adkll.kimchi.restaurant.common.screens
+
+@Target(AnnotationTarget.CLASS)
+@Retention(AnnotationRetention.BINARY)
+annotation class Parcelize
diff --git a/sample/common/src/commonMain/kotlin/com/r0adkll/kimchi/restaurant/common/session/UserSession.kt b/sample/common/src/commonMain/kotlin/com/r0adkll/kimchi/restaurant/common/session/UserSession.kt
new file mode 100644
index 0000000..66e47d0
--- /dev/null
+++ b/sample/common/src/commonMain/kotlin/com/r0adkll/kimchi/restaurant/common/session/UserSession.kt
@@ -0,0 +1,8 @@
+// Copyright (C) 2024 r0adkll
+// SPDX-License-Identifier: Apache-2.0
+package com.r0adkll.kimchi.restaurant.common.session
+
+sealed interface UserSession {
+ data object LoggedOut : UserSession
+ data object LoggedIn : UserSession
+}
diff --git a/sample/desktopApp/build.gradle.kts b/sample/desktopApp/build.gradle.kts
new file mode 100644
index 0000000..cc78a2c
--- /dev/null
+++ b/sample/desktopApp/build.gradle.kts
@@ -0,0 +1,26 @@
+// Copyright (C) 2024 r0adkll
+// SPDX-License-Identifier: Apache-2.0
+import org.jetbrains.compose.desktop.application.dsl.TargetFormat
+
+plugins {
+ kotlin("jvm")
+ alias(libs.plugins.compose.multiplatform)
+ alias(libs.plugins.compose.compiler)
+}
+
+dependencies {
+ implementation(projects.sample.shared)
+ implementation(compose.desktop.currentOs)
+}
+
+compose.desktop {
+ application {
+ mainClass = "com.r0adkll.kimchi.restaurant.MainKt"
+
+ nativeDistributions {
+ targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
+ packageName = "com.r0adkll.kimchi.restaurant"
+ packageVersion = "1.0.0"
+ }
+ }
+}
diff --git a/sample/desktopApp/src/main/kotlin/com/r0adkll/kimchi/restaurant/Main.kt b/sample/desktopApp/src/main/kotlin/com/r0adkll/kimchi/restaurant/Main.kt
new file mode 100644
index 0000000..6b94612
--- /dev/null
+++ b/sample/desktopApp/src/main/kotlin/com/r0adkll/kimchi/restaurant/Main.kt
@@ -0,0 +1,75 @@
+// Copyright (C) 2023 r0adkll
+// SPDX-License-Identifier: Apache-2.0
+package com.r0adkll.kimchi.restaurant
+
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.isCtrlPressed
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Window
+import androidx.compose.ui.window.WindowPosition
+import androidx.compose.ui.window.application
+import androidx.compose.ui.window.rememberWindowState
+import com.r0adkll.kimchi.restaurant.common.ComponentHolder
+import com.r0adkll.kimchi.restaurant.common.screens.MenuScreen
+import com.r0adkll.kimchi.restaurant.common.session.UserSession
+import com.r0adkll.kimchi.restaurant.di.UserComponent
+import com.slack.circuit.backstack.rememberSaveableBackStack
+import com.slack.circuit.foundation.rememberCircuitNavigator
+import kimchi.merge.com.r0adkll.kimchi.restaurant.createMergedDesktopApplicationComponent
+
+@Suppress("CAST_NEVER_SUCCEEDS", "UNCHECKED_CAST", "USELESS_CAST", "KotlinRedundantDiagnosticSuppress")
+fun main() = application {
+ val applicationComponent = remember {
+ DesktopApplicationComponent.createMergedDesktopApplicationComponent()
+ .also {
+ ComponentHolder.components += it
+ }
+ }
+
+ val userComponent = remember {
+ ComponentHolder.component()
+ .create(UserSession.LoggedIn)
+ .also { ComponentHolder.components += it }
+ }
+
+ val backstack = rememberSaveableBackStack(listOf(MenuScreen))
+ val navigator = rememberCircuitNavigator(backstack) { /* no-op */ }
+
+ val windowState = rememberWindowState(
+ width = 1080.dp,
+ height = 720.dp,
+ position = WindowPosition.Aligned(Alignment.Center),
+ )
+ Window(
+ title = "Kimchi Restaurant",
+ onCloseRequest = ::exitApplication,
+ state = windowState,
+ onKeyEvent = {
+ if ((it.isCtrlPressed && it.key == Key.D) || it.key == Key.Escape) {
+ navigator.pop()
+ true
+ } else {
+ false
+ }
+ },
+ ) {
+ val component: WindowComponent = remember(applicationComponent) {
+ ComponentHolder.component().create()
+ }
+
+ component.restaurantContent(
+ backstack,
+ navigator,
+ WindowInsets(
+ top = 24.dp,
+ bottom = 24.dp,
+ ),
+ Modifier,
+ )
+ }
+}
diff --git a/sample/features/menu/api/build.gradle.kts b/sample/features/menu/api/build.gradle.kts
new file mode 100644
index 0000000..74c1262
--- /dev/null
+++ b/sample/features/menu/api/build.gradle.kts
@@ -0,0 +1,69 @@
+// Copyright (C) 2024 r0adkll
+// SPDX-License-Identifier: Apache-2.0
+import java.util.Locale
+import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
+import org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType
+
+plugins {
+ alias(libs.plugins.kotlinMultiplatform)
+ alias(libs.plugins.androidLibrary)
+ alias(libs.plugins.ksp)
+}
+
+kotlin {
+ jvm()
+ androidTarget()
+
+ applyDefaultHierarchyTemplate()
+
+ listOf(
+ iosX64(),
+ iosArm64(),
+ iosSimulatorArm64(),
+ ).forEach {
+ it.binaries.framework {
+ baseName = "menu_api"
+ isStatic = true
+ }
+ }
+
+ sourceSets {
+ commonMain.dependencies {
+ api(projects.annotations)
+ api(projects.sample.common)
+ }
+ commonTest.dependencies {
+ implementation(libs.kotlin.test)
+ }
+ }
+}
+
+addKspDependencyForAllTargets(projects.compiler)
+
+android { namespace = "com.r0adkll.kimchi.restaurant.menu.api" }
+
+private fun Project.addKspDependencyForAllTargets(dependencyNotation: Any) {
+ val kmpExtension = extensions.getByType()
+ dependencies {
+ kmpExtension.targets
+ .asSequence()
+ .filter { target ->
+ // Don't add KSP for common target, only final platforms
+ target.platformType != KotlinPlatformType.common
+ }
+ .forEach { target ->
+ add(
+ "ksp${target.targetName.capitalized()}",
+ dependencyNotation,
+ )
+ }
+ }
+}
+
+fun String.capitalized(): CharSequence = let {
+ if (it.isEmpty()) {
+ it
+ } else it[0].titlecase(
+ Locale.getDefault(),
+ ) + it.substring(1)
+}
diff --git a/sample/features/menu/api/src/commonMain/kotlin/com/r0adkll/kimchi/restaurant/menu/MenuRepository.kt b/sample/features/menu/api/src/commonMain/kotlin/com/r0adkll/kimchi/restaurant/menu/MenuRepository.kt
new file mode 100644
index 0000000..7ea6ead
--- /dev/null
+++ b/sample/features/menu/api/src/commonMain/kotlin/com/r0adkll/kimchi/restaurant/menu/MenuRepository.kt
@@ -0,0 +1,10 @@
+// Copyright (C) 2024 r0adkll
+// SPDX-License-Identifier: Apache-2.0
+package com.r0adkll.kimchi.restaurant.menu
+
+import com.r0adkll.kimchi.restaurant.menu.model.MenuItem
+
+interface MenuRepository {
+
+ suspend fun getItems(): List