├── gradle.properties
├── demo
├── src
│ └── main
│ │ ├── res
│ │ ├── values
│ │ │ ├── strings.xml
│ │ │ ├── colors.xml
│ │ │ └── themes.xml
│ │ ├── mipmap-hdpi
│ │ │ ├── ic_launcher.webp
│ │ │ └── ic_launcher_round.webp
│ │ ├── mipmap-mdpi
│ │ │ ├── ic_launcher.webp
│ │ │ └── ic_launcher_round.webp
│ │ ├── mipmap-xhdpi
│ │ │ ├── ic_launcher.webp
│ │ │ └── ic_launcher_round.webp
│ │ ├── mipmap-xxhdpi
│ │ │ ├── ic_launcher.webp
│ │ │ └── ic_launcher_round.webp
│ │ ├── mipmap-xxxhdpi
│ │ │ ├── ic_launcher.webp
│ │ │ └── ic_launcher_round.webp
│ │ ├── mipmap-anydpi-v26
│ │ │ ├── ic_launcher.xml
│ │ │ └── ic_launcher_round.xml
│ │ ├── drawable-v24
│ │ │ └── ic_launcher_foreground.xml
│ │ └── drawable
│ │ │ └── ic_launcher_background.xml
│ │ ├── AndroidManifest.xml
│ │ └── java
│ │ └── com
│ │ └── zachklipp
│ │ └── composedata
│ │ └── demo
│ │ ├── AddressModel.kt
│ │ ├── ContactInfoModel.kt
│ │ └── DemoActivity.kt
└── build.gradle
├── processor
├── src
│ └── main
│ │ ├── resources
│ │ └── META-INF
│ │ │ └── services
│ │ │ └── com.google.devtools.ksp.processing.SymbolProcessor
│ │ └── kotlin
│ │ └── com
│ │ └── zachklipp
│ │ └── composedata
│ │ ├── Config.kt
│ │ ├── ModelImplSpec.kt
│ │ ├── BuilderInterfaceSpec.kt
│ │ ├── ModelInterface.kt
│ │ ├── TypeConverter.kt
│ │ ├── ComposeDataProcessor.kt
│ │ ├── Parser.kt
│ │ └── Generators.kt
└── build.gradle.kts
├── settings.gradle.kts
├── gradle
└── wrapper
│ └── gradle-wrapper.properties
├── runtime
├── build.gradle.kts
└── src
│ └── main
│ └── kotlin
│ └── com
│ └── zachklipp
│ └── composedata
│ └── ComposeModel.kt
├── .gitignore
├── gradlew.bat
├── gradlew
├── README.md
└── LICENSE
/gradle.properties:
--------------------------------------------------------------------------------
1 | kotlin.code.style=official
2 |
3 | android.useAndroidX=true
4 |
--------------------------------------------------------------------------------
/demo/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | ComposeData Demo
3 |
4 |
--------------------------------------------------------------------------------
/processor/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessor:
--------------------------------------------------------------------------------
1 | com.zachklipp.composedata.ComposeDataProcessor
--------------------------------------------------------------------------------
/demo/src/main/res/mipmap-hdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zach-klippenstein/compose-model/HEAD/demo/src/main/res/mipmap-hdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/demo/src/main/res/mipmap-mdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zach-klippenstein/compose-model/HEAD/demo/src/main/res/mipmap-mdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/demo/src/main/res/mipmap-xhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zach-klippenstein/compose-model/HEAD/demo/src/main/res/mipmap-xhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/demo/src/main/res/mipmap-xxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zach-klippenstein/compose-model/HEAD/demo/src/main/res/mipmap-xxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/demo/src/main/res/mipmap-xxxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zach-klippenstein/compose-model/HEAD/demo/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/demo/src/main/res/mipmap-hdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zach-klippenstein/compose-model/HEAD/demo/src/main/res/mipmap-hdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/demo/src/main/res/mipmap-mdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zach-klippenstein/compose-model/HEAD/demo/src/main/res/mipmap-mdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/demo/src/main/res/mipmap-xhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zach-klippenstein/compose-model/HEAD/demo/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/demo/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zach-klippenstein/compose-model/HEAD/demo/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/demo/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zach-klippenstein/compose-model/HEAD/demo/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/processor/src/main/kotlin/com/zachklipp/composedata/Config.kt:
--------------------------------------------------------------------------------
1 | package com.zachklipp.composedata
2 |
3 | /**
4 | * TODO write documentation
5 | */
6 | data class Config(
7 | val saveable: Boolean,
8 | )
9 |
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | pluginManagement {
2 | repositories {
3 | gradlePluginPortal()
4 | google()
5 | }
6 | }
7 | rootProject.name = "ComposeModel"
8 |
9 | include(
10 | "demo",
11 | "processor",
12 | "runtime"
13 | )
14 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | distributionBase=GRADLE_USER_HOME
2 | distributionPath=wrapper/dists
3 | distributionUrl=https\://services.gradle.org/distributions/gradle-6.8.2-all.zip
4 | zipStoreBase=GRADLE_USER_HOME
5 | zipStorePath=wrapper/dists
6 |
--------------------------------------------------------------------------------
/runtime/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | kotlin("jvm")
3 | }
4 |
5 | group = "com.zachklipp"
6 | version = "1.0-SNAPSHOT"
7 |
8 | repositories {
9 | mavenCentral()
10 | }
11 |
12 | dependencies {
13 | // TODO make multiplatform
14 | // api("androidx.compose.runtime:runtime:1.0.0-alpha12")
15 | }
16 |
--------------------------------------------------------------------------------
/demo/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/demo/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/demo/src/main/res/values/colors.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #FFBB86FC
4 | #FF6200EE
5 | #FF3700B3
6 | #FF03DAC5
7 | #FF018786
8 | #FF000000
9 | #FFFFFFFF
10 |
11 |
--------------------------------------------------------------------------------
/demo/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
10 |
11 |
--------------------------------------------------------------------------------
/processor/src/main/kotlin/com/zachklipp/composedata/ModelImplSpec.kt:
--------------------------------------------------------------------------------
1 | package com.zachklipp.composedata
2 |
3 | import com.squareup.kotlinpoet.ClassName
4 | import com.squareup.kotlinpoet.MemberName
5 | import com.squareup.kotlinpoet.TypeName
6 | import com.squareup.kotlinpoet.TypeSpec
7 |
8 | /**
9 | * TODO write documentation
10 | */
11 | data class ModelImplSpec(
12 | val spec: TypeSpec,
13 | val name: ClassName,
14 | val imports: List,
15 | val saverSpec: TypeSpec?,
16 | val saverName: TypeName?
17 | )
18 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | target
2 |
3 | # macOS
4 | .DS_Store
5 |
6 | # Compiled class file
7 | *.class
8 |
9 | # Log file
10 | *.log
11 |
12 | # BlueJ files
13 | *.ctxt
14 |
15 | # Mobile Tools for Java (J2ME)
16 | .mtj.tmp/
17 |
18 | # Package Files #
19 | *.jar
20 | *.war
21 | *.nar
22 | *.ear
23 | *.zip
24 | *.tar.gz
25 | *.rar
26 |
27 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
28 | hs_err_pid*
29 |
30 | # Gradle
31 | out/
32 | .gradle/
33 | build/
34 | local.properties
35 | .gradletasknamecache
36 |
37 |
38 | # Intellij
39 | *.iml
40 | .idea/
41 | captures/
42 |
--------------------------------------------------------------------------------
/processor/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | kotlin("jvm")
3 | }
4 |
5 | group = "com.zachklipp"
6 | version = "1.0-SNAPSHOT"
7 |
8 | repositories {
9 | mavenCentral()
10 | google()
11 | }
12 |
13 | dependencies {
14 | implementation("com.google.devtools.ksp:symbol-processing-api:1.4.30-1.0.0-alpha02")
15 | implementation("com.squareup.okio:okio:2.10.0")
16 | implementation("com.squareup:kotlinpoet:1.7.2")
17 |
18 | // Just need these to reflect on some names.
19 | implementation(project(":runtime"))
20 | // compileOnly("androidx.compose.foundation:foundation:1.0.0-alpha12") {
21 | // this.artifact {
22 | // this.extension="aar"
23 | // }
24 | // }
25 | }
26 |
--------------------------------------------------------------------------------
/processor/src/main/kotlin/com/zachklipp/composedata/BuilderInterfaceSpec.kt:
--------------------------------------------------------------------------------
1 | package com.zachklipp.composedata
2 |
3 | import com.squareup.kotlinpoet.FunSpec
4 | import com.squareup.kotlinpoet.LambdaTypeName
5 | import com.squareup.kotlinpoet.PropertySpec
6 | import com.squareup.kotlinpoet.TypeSpec
7 |
8 | data class BuilderInterfaceSpec(
9 | val modelInterface: ModelInterface,
10 | val spec: TypeSpec,
11 | val properties: List>,
12 | val eventHandlers: List
13 | )
14 |
15 | data class EventHandlerSpec(
16 | val name: String,
17 | val eventHandler: ModelEventHandler,
18 | val lambdaTypeName: LambdaTypeName,
19 | val setter: FunSpec
20 | )
21 |
--------------------------------------------------------------------------------
/demo/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/demo/src/main/java/com/zachklipp/composedata/demo/AddressModel.kt:
--------------------------------------------------------------------------------
1 | package com.zachklipp.composedata.demo
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.compose.ui.tooling.preview.Preview
5 | import com.zachklipp.composedata.ComposeModel
6 |
7 | @ComposeModel
8 | interface AddressModel {
9 | val street: String get() = ""
10 | val city: String get() = ""
11 | val state: String get() = ""
12 |
13 | fun onStreetChanged(street: String)
14 | fun onCityChanged(city: String)
15 | fun onStateChanged(state: String)
16 | }
17 |
18 | @Composable fun AddressModel() = rememberAddressModel {
19 | onStreetChanged { street = it }
20 | onCityChanged { city = it }
21 | onStateChanged { state = it }
22 | }
23 |
24 | @Preview(showBackground = true)
25 | @Composable fun AddressPreview() {
26 | AddressModel()
27 | }
28 |
--------------------------------------------------------------------------------
/processor/src/main/kotlin/com/zachklipp/composedata/ModelInterface.kt:
--------------------------------------------------------------------------------
1 | package com.zachklipp.composedata
2 |
3 | import com.google.devtools.ksp.symbol.KSClassDeclaration
4 | import com.google.devtools.ksp.symbol.KSFunctionDeclaration
5 | import com.google.devtools.ksp.symbol.KSPropertyDeclaration
6 | import com.google.devtools.ksp.symbol.Visibility
7 |
8 | /**
9 | * TODO write documentation
10 | */
11 | data class ModelInterface(
12 | val packageName: String,
13 | val simpleName: String,
14 | val declaration: KSClassDeclaration,
15 | val visibility: Visibility,
16 | val properties: List,
17 | val eventHandlers: List,
18 | )
19 |
20 | data class ModelProperty(
21 | val name: String,
22 | val declaration: KSPropertyDeclaration,
23 | val hasDefault: Boolean = declaration.getter != null
24 | )
25 |
26 | data class ModelEventHandler(
27 | val name: String,
28 | val declaration: KSFunctionDeclaration,
29 | val hasDefault: Boolean = !declaration.isAbstract,
30 | )
31 |
--------------------------------------------------------------------------------
/demo/src/main/java/com/zachklipp/composedata/demo/ContactInfoModel.kt:
--------------------------------------------------------------------------------
1 | package com.zachklipp.composedata.demo
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.compose.ui.tooling.preview.Preview
5 | import com.zachklipp.composedata.ComposeModel
6 |
7 | /**
8 | * Represents some contact info about a person.
9 | * Because it's annotated with a [ComposeModel] annotation, a [rememberContactInfoModel] function is
10 | * automatically generated for it. The [ContactInfoModel] function below uses the generated function
11 | * to implement business logic.
12 | */
13 | @ComposeModel
14 | interface ContactInfoModel {
15 |
16 | val name: String
17 | val addressModel: AddressModel
18 |
19 | // Is excluded from builder function parameters.
20 | val edits: Int get() = 0
21 |
22 | fun onNameChanged(name: String)
23 | fun onSubmitClicked()
24 |
25 | // Generates a warning.
26 | fun badlyNamedHandler()
27 |
28 | // This function is excluded from the builder since it has a default value.
29 | fun log(message: String) = println("ContactInfoModel: $message")
30 | }
31 |
32 | /**
33 | * This function doesn't draw UI - it is only responsible for driving a [ContactInfoModel].
34 | */
35 | @Composable fun ContactInfoModel(initialName: String = "world") = rememberContactInfoModel(
36 | name = initialName,
37 | addressModel = AddressModel()
38 | ) {
39 | onNameChanged {
40 | name = it
41 | edits++
42 | }
43 | }
44 |
45 | @Preview(showBackground = true)
46 | @Composable fun ContactInfoPreview() {
47 | ContactInfoModel()
48 | }
49 |
--------------------------------------------------------------------------------
/processor/src/main/kotlin/com/zachklipp/composedata/TypeConverter.kt:
--------------------------------------------------------------------------------
1 | package com.zachklipp.composedata
2 |
3 | import com.google.devtools.ksp.processing.KSPLogger
4 | import com.google.devtools.ksp.symbol.KSClassifierReference
5 | import com.google.devtools.ksp.symbol.KSTypeReference
6 | import com.squareup.kotlinpoet.ClassName
7 | import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy
8 | import com.squareup.kotlinpoet.TypeName
9 |
10 | /**
11 | * TODO write documentation
12 | */
13 | class TypeConverter(private val logger: KSPLogger) {
14 |
15 | fun KSTypeReference.toTypeName(): TypeName {
16 | logger.warn("toTypeName($this)")
17 |
18 | val resolved = resolve()
19 |
20 | return ClassName(
21 | resolved.declaration.packageName.asString(),
22 | resolved.declaration.simpleName.getShortName()
23 | )
24 |
25 | return when (val element = element) {
26 | is KSClassifierReference -> {
27 | ClassName.bestGuess(element.referencedName())
28 | .apply {
29 | if (element.typeArguments.isNotEmpty()) {
30 | parameterizedBy(element.typeArguments.map {
31 | it.type!!.toTypeName()
32 | })
33 | }
34 | }
35 | }
36 | // is KSCallableReference -> {
37 | // TODO()
38 | // }
39 | null -> {
40 | logger.error("Type element is null", this)
41 | TODO()
42 | }
43 | else -> {
44 | logger.error("Unsupported type: ${element::class.simpleName}: $this", this)
45 | TODO()
46 | }
47 | }
48 | }
49 | }
--------------------------------------------------------------------------------
/demo/src/main/res/drawable-v24/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
15 |
18 |
21 |
22 |
23 |
24 |
30 |
31 |
--------------------------------------------------------------------------------
/demo/build.gradle:
--------------------------------------------------------------------------------
1 | plugins {
2 | id 'com.android.application'
3 | id 'kotlin-android'
4 | id 'com.google.devtools.ksp'
5 | id 'idea'
6 | }
7 |
8 | android {
9 | compileSdkVersion 30
10 |
11 | defaultConfig {
12 | applicationId "com.zachklipp.composedata.demo"
13 | minSdkVersion 21
14 | targetSdkVersion 30
15 | versionCode 1
16 | versionName "1.0"
17 |
18 | testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
19 | }
20 |
21 | compileOptions {
22 | sourceCompatibility JavaVersion.VERSION_1_8
23 | targetCompatibility JavaVersion.VERSION_1_8
24 | }
25 |
26 | kotlinOptions {
27 | jvmTarget = '1.8'
28 | useIR = true
29 | }
30 |
31 | buildFeatures {
32 | compose true
33 | }
34 |
35 | composeOptions {
36 | kotlinCompilerExtensionVersion "1.0.0-alpha12"
37 | }
38 | }
39 |
40 | idea {
41 | module {
42 | sourceDirs += file("$buildDir/generated/ksp/debug/kotlin")
43 | sourceDirs += file("$buildDir/generated/ksp/main/kotlin")
44 | // Just these doesn't seem to work.
45 | generatedSourceDirs += file("$buildDir/generated/ksp/debug/kotlin")
46 | generatedSourceDirs += file("$buildDir/generated/ksp/main/kotlin")
47 | }
48 | }
49 |
50 | dependencies {
51 | ksp project(":processor")
52 |
53 | implementation project(":runtime")
54 | implementation "androidx.compose.ui:ui:1.0.0-alpha12"
55 | implementation "androidx.compose.foundation:foundation:1.0.0-alpha12"
56 | implementation 'androidx.appcompat:appcompat:1.2.0'
57 | implementation 'androidx.activity:activity-compose:1.3.0-alpha02'
58 | debugImplementation "androidx.compose.ui:ui-tooling:1.0.0-alpha12"
59 | debugImplementation "org.jetbrains.kotlin:kotlin-reflect:1.4.30"
60 |
61 | testImplementation 'junit:junit:4.13.2'
62 |
63 | androidTestImplementation 'com.android.support.test:runner:1.0.2'
64 | androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'
65 | }
--------------------------------------------------------------------------------
/runtime/src/main/kotlin/com/zachklipp/composedata/ComposeModel.kt:
--------------------------------------------------------------------------------
1 | package com.zachklipp.composedata
2 |
3 | import kotlin.annotation.AnnotationRetention.BINARY
4 | import kotlin.annotation.AnnotationTarget.CLASS
5 |
6 | /**
7 | * Annotates an interface as a model. The interface must contain no mutable properties, but
8 | * properties may have default getters. All functions must either have a default implementation or
9 | * return `Unit`.
10 | *
11 | * A composable function will then be generated for an interface `Foo` named `rememberFoo`. This
12 | * function will create an remember a `Foo` and return it. It takes a parameter for each property
13 | * of the interface without a default value, as well as a function that should implement the model's
14 | * business logic. The function parameter has a receiver of type `FooBuilder` – this is a generated
15 | * class that mirrors `Foo`, but does not implement it:
16 | * - For each property in `Foo`, `FooBuilder` has a mutable property of the same name. This
17 | * property will be initialized to either the original property's default value or the value
18 | * passed to `rememberFoo`. When the property is changed, it will notify observers of the
19 | * property on the original interface of the change.
20 | * - For each abstract function in `Foo`, `FooBuilder` has a function of the same name that takes
21 | * a _lambda_ with the same signature as the original function. The lambda passed to this
22 | * function will be invoked whenever the function on the returned interface is called.
23 | *
24 | * @param saveable Whether to generate a `Saver` for the model and save it using `rememberSaveable`.
25 | * All properties on the class will be saved. All properties much have a type that is automatically
26 | * saveable in a bundle. Default is true.
27 | */
28 | @MustBeDocumented
29 | @Target(CLASS)
30 | @Retention(BINARY)
31 | // TODO @StableMarker
32 | annotation class ComposeModel(
33 | val saveable: Boolean = true,
34 | )
35 |
--------------------------------------------------------------------------------
/demo/src/main/java/com/zachklipp/composedata/demo/DemoActivity.kt:
--------------------------------------------------------------------------------
1 | package com.zachklipp.composedata.demo
2 |
3 | import android.os.Bundle
4 | import androidx.activity.compose.setContent
5 | import androidx.appcompat.app.AppCompatActivity
6 | import androidx.compose.foundation.border
7 | import androidx.compose.foundation.layout.Column
8 | import androidx.compose.foundation.layout.Spacer
9 | import androidx.compose.foundation.layout.padding
10 | import androidx.compose.foundation.layout.size
11 | import androidx.compose.foundation.text.BasicText
12 | import androidx.compose.foundation.text.BasicTextField
13 | import androidx.compose.runtime.Composable
14 | import androidx.compose.ui.Modifier
15 | import androidx.compose.ui.graphics.Color
16 | import androidx.compose.ui.text.TextStyle
17 | import androidx.compose.ui.text.font.FontStyle.Italic
18 | import androidx.compose.ui.unit.dp
19 |
20 | class DemoActivity : AppCompatActivity() {
21 | override fun onCreate(savedInstanceState: Bundle?) {
22 | super.onCreate(savedInstanceState)
23 | setContent {
24 | App()
25 | }
26 | }
27 | }
28 |
29 | @Composable fun App() {
30 | val contactInfo = ContactInfoModel()
31 |
32 | Column {
33 | BasicText("Hello, ${contactInfo.name}!")
34 | BasicTextField(
35 | value = contactInfo.name, onValueChange = contactInfo::onNameChanged,
36 | Modifier.border(1.dp, Color.Black).padding(4.dp)
37 | )
38 | BasicText("${contactInfo.edits} edits", style = TextStyle(fontStyle = Italic))
39 | Spacer(Modifier.size(16.dp))
40 | AddressEditor(contactInfo.addressModel)
41 | }
42 | }
43 |
44 | @Composable private fun AddressEditor(addressModel: AddressModel) {
45 | Column {
46 | BasicText("Street:")
47 | BasicTextField(
48 | addressModel.street, onValueChange = addressModel::onStreetChanged,
49 | Modifier.border(1.dp, Color.Black).padding(4.dp)
50 | )
51 | BasicText("City:")
52 | BasicTextField(
53 | addressModel.city, onValueChange = addressModel::onCityChanged,
54 | Modifier.border(1.dp, Color.Black).padding(4.dp)
55 | )
56 | BasicText("State:")
57 | BasicTextField(
58 | addressModel.state, onValueChange = addressModel::onStateChanged,
59 | Modifier.border(1.dp, Color.Black).padding(4.dp)
60 | )
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%" == "" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%" == "" set DIRNAME=.
29 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
34 |
35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
37 |
38 | @rem Find java.exe
39 | if defined JAVA_HOME goto findJavaFromJavaHome
40 |
41 | set JAVA_EXE=java.exe
42 | %JAVA_EXE% -version >NUL 2>&1
43 | if "%ERRORLEVEL%" == "0" goto execute
44 |
45 | echo.
46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
47 | echo.
48 | echo Please set the JAVA_HOME variable in your environment to match the
49 | echo location of your Java installation.
50 |
51 | goto fail
52 |
53 | :findJavaFromJavaHome
54 | set JAVA_HOME=%JAVA_HOME:"=%
55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
56 |
57 | if exist "%JAVA_EXE%" goto execute
58 |
59 | echo.
60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
61 | echo.
62 | echo Please set the JAVA_HOME variable in your environment to match the
63 | echo location of your Java installation.
64 |
65 | goto fail
66 |
67 | :execute
68 | @rem Setup the command line
69 |
70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
71 |
72 |
73 | @rem Execute Gradle
74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
75 |
76 | :end
77 | @rem End local scope for the variables with windows NT shell
78 | if "%ERRORLEVEL%"=="0" goto mainEnd
79 |
80 | :fail
81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
82 | rem the _cmd.exe /c_ return code!
83 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
84 | exit /b 1
85 |
86 | :mainEnd
87 | if "%OS%"=="Windows_NT" endlocal
88 |
89 | :omega
90 |
--------------------------------------------------------------------------------
/processor/src/main/kotlin/com/zachklipp/composedata/ComposeDataProcessor.kt:
--------------------------------------------------------------------------------
1 | package com.zachklipp.composedata
2 |
3 | import com.google.devtools.ksp.processing.CodeGenerator
4 | import com.google.devtools.ksp.processing.Dependencies
5 | import com.google.devtools.ksp.processing.KSPLogger
6 | import com.google.devtools.ksp.processing.Resolver
7 | import com.google.devtools.ksp.processing.SymbolProcessor
8 | import com.google.devtools.ksp.symbol.KSAnnotated
9 | import com.google.devtools.ksp.symbol.KSClassDeclaration
10 | import com.google.devtools.ksp.validate
11 | import com.squareup.kotlinpoet.ClassName
12 | import com.squareup.kotlinpoet.FileSpec
13 | import java.io.PrintWriter
14 |
15 | class ComposeDataProcessor : SymbolProcessor {
16 |
17 | private lateinit var codeGenerator: CodeGenerator
18 | private lateinit var logger: KSPLogger
19 | private lateinit var parser: Parser
20 | private lateinit var typeConverter: TypeConverter
21 |
22 | override fun init(
23 | options: Map,
24 | kotlinVersion: KotlinVersion,
25 | codeGenerator: CodeGenerator,
26 | logger: KSPLogger
27 | ) {
28 | this.codeGenerator = codeGenerator
29 | this.logger = logger
30 | parser = Parser(logger)
31 | typeConverter = TypeConverter(logger)
32 | }
33 |
34 | override fun process(resolver: Resolver): List {
35 | val annotatedSymbols =
36 | resolver.getSymbolsWithAnnotation(COMPOSE_DATA_ANNOTATION.qualifiedName!!)
37 | if (annotatedSymbols.isEmpty()) {
38 | return emptyList()
39 | }
40 |
41 | val invalidSymbols = mutableListOf()
42 |
43 | annotatedSymbols.forEach { symbol ->
44 | if (symbol !is KSClassDeclaration) return@forEach
45 | if (!symbol.validate()) {
46 | logger.warn("Invalid", symbol)
47 | invalidSymbols += symbol
48 | return@forEach
49 | }
50 |
51 | processComposeDataClass(symbol, resolver)
52 | }
53 |
54 | return invalidSymbols
55 | }
56 |
57 | override fun finish() = Unit
58 |
59 | private fun processComposeDataClass(symbol: KSClassDeclaration, resolver: Resolver) {
60 | val modelInterface = parser.parseModelInterface(symbol, resolver.builtIns) ?: return
61 | val implClassName = ClassName(modelInterface.packageName, "${modelInterface.simpleName}Impl")
62 | val config = parser.parseConfig(modelInterface, resolver)
63 | val builderInterface = generateBuilderInterface(modelInterface, typeConverter)
64 | val implClass =
65 | generateImplClass(modelInterface, builderInterface, implClassName, config, typeConverter)
66 | val rememberFunction =
67 | generateRememberFunction(modelInterface, builderInterface, implClass, typeConverter)
68 |
69 | val packageName = symbol.packageName.asString()
70 | val generatedFile = FileSpec.builder(packageName, builderInterface.spec.name!!)
71 | .addFunction(rememberFunction)
72 | .addType(builderInterface.spec)
73 | .addType(implClass.spec)
74 | .apply {
75 | implClass.imports.takeUnless { it.isEmpty() }?.forEach {
76 | addImport(it.packageName, it.simpleName)
77 | }
78 | }
79 | .build()
80 |
81 | codeGenerator.createNewFile(
82 | Dependencies(aggregating = false, symbol.containingFile!!),
83 | packageName = packageName,
84 | fileName = generatedFile.name
85 | ).let(::PrintWriter).use {
86 | generatedFile.writeTo(it)
87 | }
88 | }
89 |
90 | companion object {
91 | val COMPOSE_DATA_ANNOTATION = ComposeModel::class
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/processor/src/main/kotlin/com/zachklipp/composedata/Parser.kt:
--------------------------------------------------------------------------------
1 | package com.zachklipp.composedata
2 |
3 | import com.google.devtools.ksp.getClassDeclarationByName
4 | import com.google.devtools.ksp.getDeclaredFunctions
5 | import com.google.devtools.ksp.getDeclaredProperties
6 | import com.google.devtools.ksp.getVisibility
7 | import com.google.devtools.ksp.processing.KSBuiltIns
8 | import com.google.devtools.ksp.processing.KSPLogger
9 | import com.google.devtools.ksp.processing.Resolver
10 | import com.google.devtools.ksp.symbol.ClassKind.INTERFACE
11 | import com.google.devtools.ksp.symbol.KSAnnotation
12 | import com.google.devtools.ksp.symbol.KSClassDeclaration
13 | import com.google.devtools.ksp.symbol.KSFunctionDeclaration
14 | import com.google.devtools.ksp.symbol.KSPropertyDeclaration
15 | import com.zachklipp.composedata.ComposeDataProcessor.Companion.COMPOSE_DATA_ANNOTATION
16 | import kotlin.reflect.KProperty1
17 |
18 | class Parser(private val logger: KSPLogger) {
19 |
20 | fun parseModelInterface(symbol: KSClassDeclaration, builtins: KSBuiltIns): ModelInterface? {
21 | if (symbol.classKind != INTERFACE) {
22 | logger.error(
23 | "Only interfaces may be annotated with ${COMPOSE_DATA_ANNOTATION}, but this is a ${symbol.classKind}",
24 | symbol
25 | )
26 | return null
27 | }
28 | if (symbol.superTypes.isNotEmpty()) {
29 | logger.error(
30 | "$COMPOSE_DATA_ANNOTATION-annotated interfaces must not extend any interfaces, " +
31 | "but this interface extends ${symbol.superTypes}",
32 | symbol
33 | )
34 | return null
35 | }
36 | if (symbol.typeParameters.isNotEmpty()) {
37 | logger.error(
38 | "$COMPOSE_DATA_ANNOTATION-annotated interfaces must not have type parameters.",
39 | symbol
40 | )
41 | return null
42 | }
43 |
44 | val properties = symbol.getDeclaredProperties().map {
45 | parseModelProperty(it) ?: return null
46 | }
47 | val eventHandlers = symbol.getDeclaredFunctions().mapNotNull {
48 | parseModelEventHandler(it, builtins)
49 | }
50 |
51 | return ModelInterface(
52 | symbol.packageName.asString(),
53 | symbol.simpleName.asString(),
54 | symbol,
55 | symbol.getVisibility(),
56 | properties,
57 | eventHandlers
58 | )
59 | }
60 |
61 | fun parseConfig(model: ModelInterface, resolver: Resolver): Config {
62 | val composeDataAnnotationType = resolver.getClassDeclarationByName()
63 | val annotation = model.declaration.annotations.single {
64 | it.annotationType.resolve().declaration == composeDataAnnotationType
65 | }
66 | return Config(
67 | saveable = annotation[ComposeModel::saveable] ?: true,
68 | )
69 | }
70 |
71 | private fun parseModelProperty(declaration: KSPropertyDeclaration): ModelProperty? {
72 | if (declaration.isMutable) {
73 | logger.error(
74 | "$COMPOSE_DATA_ANNOTATION interfaces must not declare mutable properties: $declaration",
75 | declaration
76 | )
77 | return null
78 | }
79 |
80 | return ModelProperty(declaration.simpleName.asString(), declaration)
81 | }
82 |
83 | private fun parseModelEventHandler(
84 | declaration: KSFunctionDeclaration,
85 | builtins: KSBuiltIns
86 | ): ModelEventHandler? {
87 | // Ignore functions with a default implementation.
88 | if (!declaration.isAbstract) {
89 | logger.info(
90 | "Skipping function $declaration because it has a default implementation.",
91 | declaration
92 | )
93 | return null
94 | }
95 | if (declaration.returnType == null) {
96 | logger.error("Unknown return type", declaration)
97 | return null
98 | }
99 | if (declaration.returnType!!.resolve() != builtins.unitType) {
100 | logger.error(
101 | "$COMPOSE_DATA_ANNOTATION event handler functions must return Unit",
102 | declaration
103 | )
104 | return null
105 | }
106 |
107 | if (declaration.typeParameters.isNotEmpty()) {
108 | logger.error(
109 | "$COMPOSE_DATA_ANNOTATION interfaces must not declare generic functions: $declaration",
110 | declaration
111 | )
112 | return null
113 | }
114 |
115 | if (!declaration.simpleName.asString().startsWith("on")) {
116 | logger.warn(
117 | "Event handler functions should have the prefix \"on\": $declaration",
118 | declaration
119 | )
120 | }
121 |
122 | return ModelEventHandler(
123 | name = declaration.simpleName.asString(),
124 | declaration,
125 | hasDefault = !declaration.isAbstract
126 | )
127 | }
128 |
129 | @Suppress("UNCHECKED_CAST")
130 | private operator fun KSAnnotation.get(name: KProperty1<*, T>): T? =
131 | arguments.single { it.name!!.asString() == name.name }.value as T?
132 | }
--------------------------------------------------------------------------------
/demo/src/main/res/drawable/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
10 |
15 |
20 |
25 |
30 |
35 |
40 |
45 |
50 |
55 |
60 |
65 |
70 |
75 |
80 |
85 |
90 |
95 |
100 |
105 |
110 |
115 |
120 |
125 |
130 |
135 |
140 |
145 |
150 |
155 |
160 |
165 |
170 |
171 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | #
4 | # Copyright 2015 the original author or authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 |
19 | ##############################################################################
20 | ##
21 | ## Gradle start up script for UN*X
22 | ##
23 | ##############################################################################
24 |
25 | # Attempt to set APP_HOME
26 | # Resolve links: $0 may be a link
27 | PRG="$0"
28 | # Need this for relative symlinks.
29 | while [ -h "$PRG" ] ; do
30 | ls=`ls -ld "$PRG"`
31 | link=`expr "$ls" : '.*-> \(.*\)$'`
32 | if expr "$link" : '/.*' > /dev/null; then
33 | PRG="$link"
34 | else
35 | PRG=`dirname "$PRG"`"/$link"
36 | fi
37 | done
38 | SAVED="`pwd`"
39 | cd "`dirname \"$PRG\"`/" >/dev/null
40 | APP_HOME="`pwd -P`"
41 | cd "$SAVED" >/dev/null
42 |
43 | APP_NAME="Gradle"
44 | APP_BASE_NAME=`basename "$0"`
45 |
46 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
47 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
48 |
49 | # Use the maximum available, or set MAX_FD != -1 to use that value.
50 | MAX_FD="maximum"
51 |
52 | warn () {
53 | echo "$*"
54 | }
55 |
56 | die () {
57 | echo
58 | echo "$*"
59 | echo
60 | exit 1
61 | }
62 |
63 | # OS specific support (must be 'true' or 'false').
64 | cygwin=false
65 | msys=false
66 | darwin=false
67 | nonstop=false
68 | case "`uname`" in
69 | CYGWIN* )
70 | cygwin=true
71 | ;;
72 | Darwin* )
73 | darwin=true
74 | ;;
75 | MINGW* )
76 | msys=true
77 | ;;
78 | NONSTOP* )
79 | nonstop=true
80 | ;;
81 | esac
82 |
83 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
84 |
85 |
86 | # Determine the Java command to use to start the JVM.
87 | if [ -n "$JAVA_HOME" ] ; then
88 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
89 | # IBM's JDK on AIX uses strange locations for the executables
90 | JAVACMD="$JAVA_HOME/jre/sh/java"
91 | else
92 | JAVACMD="$JAVA_HOME/bin/java"
93 | fi
94 | if [ ! -x "$JAVACMD" ] ; then
95 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
96 |
97 | Please set the JAVA_HOME variable in your environment to match the
98 | location of your Java installation."
99 | fi
100 | else
101 | JAVACMD="java"
102 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
103 |
104 | Please set the JAVA_HOME variable in your environment to match the
105 | location of your Java installation."
106 | fi
107 |
108 | # Increase the maximum file descriptors if we can.
109 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
110 | MAX_FD_LIMIT=`ulimit -H -n`
111 | if [ $? -eq 0 ] ; then
112 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
113 | MAX_FD="$MAX_FD_LIMIT"
114 | fi
115 | ulimit -n $MAX_FD
116 | if [ $? -ne 0 ] ; then
117 | warn "Could not set maximum file descriptor limit: $MAX_FD"
118 | fi
119 | else
120 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
121 | fi
122 | fi
123 |
124 | # For Darwin, add options to specify how the application appears in the dock
125 | if $darwin; then
126 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
127 | fi
128 |
129 | # For Cygwin or MSYS, switch paths to Windows format before running java
130 | if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
131 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
132 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
133 |
134 | JAVACMD=`cygpath --unix "$JAVACMD"`
135 |
136 | # We build the pattern for arguments to be converted via cygpath
137 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
138 | SEP=""
139 | for dir in $ROOTDIRSRAW ; do
140 | ROOTDIRS="$ROOTDIRS$SEP$dir"
141 | SEP="|"
142 | done
143 | OURCYGPATTERN="(^($ROOTDIRS))"
144 | # Add a user-defined pattern to the cygpath arguments
145 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
146 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
147 | fi
148 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
149 | i=0
150 | for arg in "$@" ; do
151 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
152 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
153 |
154 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
155 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
156 | else
157 | eval `echo args$i`="\"$arg\""
158 | fi
159 | i=`expr $i + 1`
160 | done
161 | case $i in
162 | 0) set -- ;;
163 | 1) set -- "$args0" ;;
164 | 2) set -- "$args0" "$args1" ;;
165 | 3) set -- "$args0" "$args1" "$args2" ;;
166 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
167 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
168 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
169 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
170 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
171 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
172 | esac
173 | fi
174 |
175 | # Escape application args
176 | save () {
177 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
178 | echo " "
179 | }
180 | APP_ARGS=`save "$@"`
181 |
182 | # Collect all arguments for the java command, following the shell quoting and substitution rules
183 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
184 |
185 | exec "$JAVACMD" "$@"
186 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # ComposeModel
2 |
3 | This is a proof-of-concept for a compiler plugin that generates models from interfaces describing
4 | their public API, where the model business logic is implemented as a composable function.
5 |
6 | ## Usage
7 |
8 | ### Defining your model API
9 |
10 | ```kotlin
11 | @ComposeModel
12 | interface TodoListModel {
13 | // Data
14 | val todos: List = emptyList()
15 | val completedTodos: List = emptyList()
16 |
17 | // Event handlers
18 | fun onTodoAdded(todo: TodoModel)
19 | fun onTodoCompleted(todo: TodoModel)
20 | }
21 | ```
22 |
23 | This interface defines a model for a todo list. We can write a composable function to render the
24 | list, but that's left as an exercise for the reader. What this library does is generate some code
25 | to help you implement the business logic for this model using Compose.
26 |
27 | In other words, the model interface defines both the model's public API, and a DSL for implementing
28 | the model.
29 |
30 | ### Defining your model behavior
31 |
32 | Here's an implementation:
33 |
34 | ```kotlin
35 | @Composable fun TodoListModel(): TodoListModel = rememberTodoListModel {
36 | onTodoAdded { todo ->
37 | todos += todo
38 | }
39 | onTodoCompleted { todone ->
40 | require(todone in todos) { "Invalid todo: $todone" }
41 | todos -= todone
42 | completedTodos += todone
43 | }
44 | }
45 | ```
46 |
47 | `rememberTodoListModel` is generated for you. The code inside the lambda gets a mutable version of
48 | `TodoListModel` – it can write to the properties, and when it calls the event handlers, it actually
49 | passes lambdas that _handle_ the events. The lambda is a composable function, and you can do stuff
50 | like create private state with `remember` and `rememberSaveable`, launch coroutines with
51 | `LaunchedEffect`, etc. You can read `CompositionLocal`s, but all the usual warnings about that
52 | apply.
53 |
54 | Let's demonstrate private state by adding a timer:
55 | ```kotlin
56 | @ComposeModel
57 | interface TodoListModel {
58 | // Data
59 | // …
60 |
61 | val timer: String
62 |
63 | // …
64 | }
65 | ```
66 | Note that the `timer` property doesn't have a default value, so we'll have to specify it explicitly.
67 | ```kotlin
68 | @Composable fun TodoListModel(): TodoListModel = rememberTodoListModel(
69 | // When the function is re-generated after the above change, this parameter will be required,
70 | // and the code won't compile until we specify it.
71 | timer = Duration.ZERO.toString()
72 | ) {
73 | // Run the timer loop in a coroutine for as long as the model is composed.
74 | LaunchedEffect(Unit) {
75 | val startTime = System.currentTimeNanos()
76 | while(true) {
77 | delay(1000)
78 | timer = (System.currentTimeNanos() - startTime).nanoseconds.toString()
79 | }
80 | }
81 |
82 | // …
83 | }
84 | ```
85 |
86 | You could even emit things (e.g. UI composables), because Compose doesn't provide any APIs
87 | for stopping you, but that's strongly discouraged. The model composable should only be responsible
88 | for the _model_'s business logic – UI should be defined separately. The behavior is also undefined –
89 | models don't expect their children to emit UI, so there's no meaningful layout context.
90 |
91 | The generated `rememberTodoListModel` function and the builder interface are both `internal`, so
92 | they don't pollute your module's public API.
93 |
94 | ### Persisting your model
95 |
96 | If you were to run this app, you'd find it automatically saves and restores the models on config
97 | change. By default, `rememberTodoListModel` will store your model in the `UiSavedStateRegistry`.
98 | Only the properties are stored, and they must all be auto-saveable (in the same sense as the default
99 | `autoSaver()` value used by `rememberSaveable`). You can turn off this behavior by passing
100 | `saveable = false` to the `@ComposeModel` annotation.
101 |
102 | ## How it works
103 |
104 | The plugin is implemented as a [KSP](https://github.com/google/ksp) processor.
105 |
106 | - The builder interface is simply a copy of the model interface with vals changed to vars, default
107 | getters erased, and the event handler function signatures changed.
108 | - A private implementation class is generated. This class implements both the model interface and
109 | the builder interface. Since properties have the same names in both interfaces, they don't clash.
110 | Each property is backed by a `MutableState`. Two overloads of each event handler function are
111 | generated – one for each interface. Each pair of functions has a backing property that is simply
112 | a mutable lambda holder. When a builder event handler is called the backing property is set, and
113 | when the model function is called it is invoked. This is _not_ a `MutableState` since nothing
114 | needs to be notified when the event handler changes. If the model is to be saveable, a `Saver`
115 | implementation is also generated for this class that stores each property in a map.
116 | - The remember function simply calls `remember { Impl() }` or `rememberSaveable { Impl() }` and
117 | then passes it to the lambda argument on every composition before returning the remembered object.
118 |
119 | ## Is this a terrible idea?
120 |
121 | Probably. There are quite a few potential issues:
122 |
123 | - If a parent model implementation reads its child's properties, I believe it won't see changes to
124 | them until the next composition pass – and this is usually strongly advised against by the Compose
125 | team when it comes up in the Kotlin Slack.
126 | - It's easy to forget to set all the event handlers in the `remember*` function. This could maybe
127 | be enforced better with a real compiler plugin that could warn if there was a missing call.
128 | - Ideally model composables would not be allowed to emit any UI. There's no way to enforce this at
129 | compile time, nor at runtime.
130 | - We could run the model composition separately from the UI composition, with an `Applier` type
131 | that doesn't allow emitting anything (`Nothing` nodes), but that's problematic because:
132 | - It still doesn't provide safety at compile time, only runtime.
133 | - Some things, like text editing, don't work when changes and updates are shuffled between
134 | different compositions.
135 | - We could wrap the root model composable in a special layout that throws if any children are
136 | emitted, but that would require remembering to wrap the root, and obviously would only provide
137 | runtime safety.
138 |
139 | ## Status
140 |
141 | This project is _very_ rough. The code is super gross and undocumented, there's no real tests, and
142 | it's not published. There is a demo module that should build and run however, and you can checkout
143 | the repo and mess around if you like. There's some validation with vaguely useful error messages,
144 | but there's probably a lot of ways to get the plugin to just puke.
145 |
146 | ### Future work
147 |
148 | I don't expect I'll spend much more time on this, but if I wanted to make it a real thing, some
149 | features I'd like to add are:
150 |
151 | - Annotation for leaving certain properties out of persistence (probably just use `@Transient`).
152 | - Annotation for specifying custom `Saver`s for individual properties.
153 | - Helpers for writing unit tests – create a special composition that forbids emissions.
154 | - ~Support model properties with `StateFlow` types. The builder interface would still just get a
155 | mutable property, but instead of being backed by a `MutableState` it would be backed by a
156 | `MutableStateFlow`.~ This makes it harder to do some of the other things, and the use case of
157 | supporting consumtion from non-Compose code can be addressed in a more elegant way (see below).
158 | - Multiplatform support.
159 | - The `@ComposeModel` annotation should be a `@StableMarker` to opt-in to compiler optimizations.
160 | - Implement as a full-fledged compiler plugin instead of a KSP processor to integrate more tightly
161 | with the IDE (real-time redlines, not require a manual build to show changes to generated code),
162 | and maybe make the generated APIs cleaner.
163 | - Optionally generate a simple factory function that returns an immutable, value-type-like
164 | implementation of the interface (implements `equals` and `hashcode`) and does so only using the
165 | properties, not the functions (one of the big issues we've had testing renderings in Workflow).
166 | - Create a helper for consuming from legacy Android `View`s (similar to Workflow's `LayoutRunner`)
167 | that automatically observes snapshot reads in its update function to automatically update views
168 | that are configured using `MutableState`.
169 | - Make it possible define custom annotations that alias specific combinations of `@ComposeModel`
170 | parameters:
171 | ```kotlin
172 | @ComposeModel(someProperty = true, someOtherProperty = false)
173 | annotation class SquareModel
174 | ```
175 | - Optionally generate a `rememberFooAsState` or `AsFlow` function that has the same signature as `rememberFoo`
176 | but returns a `MutableState` or `StateFlow` instead of a `Foo`, and pushes a new value-type `Foo` (see
177 | above) the state on every change instead of updating only individual properties. This could be
178 | useful for integrating with libraries like Workflow which expect a stream of immutable objects, instead of a single
179 | object that changes over time. Would need to make sure updates aren't always a frame late though.
180 | - Link the builder classes to their source interfaces in the type system so that other code can express
181 | relationships between them. E.g.
182 | ```kotlin
183 | // In the runtime artifact:
184 | interface ComposeModelBuilder
185 |
186 | // Example of generated builder:
187 | interface FooModelBuilder : ComposeModelBuilder { /* … */ }
188 | ```
189 | - Create factory and/or remember functions that don't take a builder lambda and instead return a `Pair`.
190 | Then third-party abstractions could be created that do something like:
191 | ```kotlin
192 | fun > doSomething(
193 | modelFactory: () -> Pair,
194 | customBuilder: BuilderT.() -> Unit
195 | ): ModelT {
196 | val (model, builder) = factory()
197 | // Do something with builder.
198 | customBuilder(builder)
199 | return model
200 | }
201 |
202 | // And be called like:
203 | val fooModel = doSomething(createFooModel(arg1, arg2)) {
204 | // Build the Foo somehow
205 | }
206 | ```
207 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright [yyyy] [name of copyright owner]
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/processor/src/main/kotlin/com/zachklipp/composedata/Generators.kt:
--------------------------------------------------------------------------------
1 | package com.zachklipp.composedata
2 |
3 | import com.google.devtools.ksp.symbol.KSPropertyDeclaration
4 | import com.squareup.kotlinpoet.AnnotationSpec
5 | import com.squareup.kotlinpoet.ClassName
6 | import com.squareup.kotlinpoet.CodeBlock
7 | import com.squareup.kotlinpoet.FunSpec
8 | import com.squareup.kotlinpoet.KModifier.ABSTRACT
9 | import com.squareup.kotlinpoet.KModifier.INTERNAL
10 | import com.squareup.kotlinpoet.KModifier.OVERRIDE
11 | import com.squareup.kotlinpoet.KModifier.PRIVATE
12 | import com.squareup.kotlinpoet.LambdaTypeName
13 | import com.squareup.kotlinpoet.MemberName
14 | import com.squareup.kotlinpoet.ParameterSpec
15 | import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy
16 | import com.squareup.kotlinpoet.PropertySpec
17 | import com.squareup.kotlinpoet.TypeName
18 | import com.squareup.kotlinpoet.TypeSpec
19 | import com.squareup.kotlinpoet.asClassName
20 |
21 | private val COMPOSABLE_ANNOTATION = ClassName("androidx.compose.runtime", "Composable")
22 | private val STABLE_ANNOTATION = ClassName("androidx.compose.runtime", "Stable")
23 | private val REMEMBER_FUN = MemberName("androidx.compose.runtime", "remember")
24 | private val REMEMBER_SAVEABLE_FUN =
25 | MemberName("androidx.compose.runtime.saveable", "rememberSaveable")
26 | private val MAP_OF_FUN = MemberName("kotlin.collections", "mapOf")
27 | private val MUTABLE_STATE_OF_FUN = MemberName("androidx.compose.runtime", "mutableStateOf")
28 | private val UNIT_NAME = Unit::class.asClassName()
29 | private val SAVER_INTERFACE = ClassName("androidx.compose.runtime.saveable", "Saver")
30 |
31 | fun generateRememberFunction(
32 | model: ModelInterface,
33 | builderInterfaceSpec: BuilderInterfaceSpec,
34 | implSpec: ModelImplSpec,
35 | converter: TypeConverter
36 | ): FunSpec {
37 | with(converter) {
38 | val interfaceName = ClassName(model.packageName, model.simpleName)
39 |
40 | val updateLambdaType = LambdaTypeName.get(
41 | receiver = ClassName(model.packageName, builderInterfaceSpec.spec.name!!),
42 | returnType = UNIT_NAME
43 | ).copy(annotations = listOf(AnnotationSpec.builder(COMPOSABLE_ANNOTATION).build()))
44 |
45 | val updateParam = ParameterSpec("update", updateLambdaType)
46 |
47 | val requiredParams = model.properties.filter { !it.hasDefault }
48 | .map { property ->
49 | ParameterSpec(property.name, property.declaration.type.toTypeName())
50 | }
51 |
52 | return FunSpec.builder("remember${model.simpleName}")
53 | // Builder function is always internal – the code should wrap the builder with
54 | // its own actual function that includes the implementation.
55 | .addModifiers(INTERNAL)
56 | .addAnnotation(COMPOSABLE_ANNOTATION)
57 | .addParameters(requiredParams)
58 | // This must be the last parameter trailing lambda syntax.
59 | .addParameter(updateParam)
60 | .returns(interfaceName)
61 | .addCode(
62 | CodeBlock.builder()
63 | .add("return %L",
64 | rememberMaybeSaveable(implSpec.saverName) {
65 | add(
66 | generateImplConstructorCall(
67 | implSpec.name,
68 | requiredParams.associate { it.name to CodeBlock.of("%N", it.name) })
69 | )
70 | // add("%N(", implSpec.spec)
71 | // requiredParams.forEach { add("%N, ", it.name) }
72 | // add(")\n")
73 | })
74 | .add(".also { %N(it) }", updateParam)
75 | .build()
76 | )
77 | .build()
78 | }
79 | }
80 |
81 | fun generateBuilderInterface(
82 | model: ModelInterface,
83 | converter: TypeConverter
84 | ): BuilderInterfaceSpec {
85 | with(converter) {
86 | val builderInterfaceName = ClassName(model.packageName, "${model.simpleName}Builder")
87 |
88 | val propertySpecs = model.properties.map {
89 | it to
90 | PropertySpec.builder(it.declaration.simpleName.asString(), it.declaration.type.toTypeName())
91 | .mutable(true)
92 | .build()
93 | }
94 |
95 | val eventHandlerSpecs = model.eventHandlers.map { eventHandler ->
96 | val declaration = eventHandler.declaration
97 | val eventHandlerLambda = LambdaTypeName.get(
98 | parameters = declaration.parameters.map { eventParam ->
99 | ParameterSpec(eventParam.name!!.asString(), eventParam.type.toTypeName())
100 | },
101 | returnType = UNIT_NAME
102 | )
103 |
104 | EventHandlerSpec(
105 | declaration.simpleName.asString(),
106 | eventHandler,
107 | eventHandlerLambda,
108 | setter = FunSpec.builder(declaration.simpleName.asString())
109 | .addModifiers(ABSTRACT)
110 | .addParameter("handler", eventHandlerLambda)
111 | .build()
112 | )
113 | }
114 |
115 | return BuilderInterfaceSpec(
116 | model,
117 | spec = TypeSpec.interfaceBuilder(builderInterfaceName)
118 | // Internal for the same reason that the remember function is.
119 | .addModifiers(INTERNAL)
120 | .addAnnotation(STABLE_ANNOTATION)
121 | .addProperties(propertySpecs.map { it.second })
122 | .addFunctions(eventHandlerSpecs.map { it.setter })
123 | .build(),
124 | propertySpecs,
125 | eventHandlerSpecs
126 | )
127 | }
128 | }
129 |
130 | fun generateImplClass(
131 | model: ModelInterface,
132 | builder: BuilderInterfaceSpec,
133 | implClassName: ClassName,
134 | config: Config,
135 | converter: TypeConverter
136 | ): ModelImplSpec {
137 | with(converter) {
138 | val constructorParamsByName = model.properties.filter { !it.hasDefault }
139 | .associate {
140 | val name = it.declaration.simpleName.asString()
141 | name to ParameterSpec(name, it.declaration.type.toTypeName())
142 | }
143 |
144 | val properties = model.properties.map { property ->
145 | PropertySpec.builder(property.name, property.declaration.type.toTypeName())
146 | .addModifiers(OVERRIDE)
147 | .mutable(true)
148 | .delegate(mutableStateOf(property.declaration.defaultOr(CodeBlock.of("%N", property.name))))
149 | .build()
150 | }
151 |
152 | val eventHandlerProperties: Map = builder.eventHandlers.associate {
153 | val property = PropertySpec
154 | .builder("${it.name}\$handler", it.lambdaTypeName.copy(nullable = true))
155 | .addModifiers(PRIVATE)
156 | .mutable(true)
157 | .initializer("null")
158 | .build()
159 | Pair(it.name, property)
160 | }
161 |
162 | val eventHandlerSetters = builder.eventHandlers.map {
163 | it.setter.toBuilder()
164 | .apply { modifiers -= ABSTRACT }
165 | .addModifiers(OVERRIDE)
166 | .addCode("%N = handler", eventHandlerProperties.getValue(it.name))
167 | .build()
168 | }
169 |
170 | val eventHandlers = model.eventHandlers.map { eventHandler ->
171 | val backingProperty = eventHandlerProperties.getValue(eventHandler.name)
172 | val parameters = eventHandler.declaration.parameters.map {
173 | ParameterSpec(it.name!!.asString(), it.type.toTypeName())
174 | }
175 | FunSpec.builder(eventHandler.name)
176 | .addModifiers(OVERRIDE)
177 | .addParameters(parameters)
178 | .addCode("%N?.invoke(", backingProperty)
179 | .apply {
180 | parameters.forEach {
181 | addCode("%N, ", it)
182 | }
183 | }
184 | .addCode(")")
185 | .build()
186 | }
187 |
188 | val implBuilder = TypeSpec.classBuilder(implClassName)
189 | .addModifiers(PRIVATE)
190 | .addAnnotation(STABLE_ANNOTATION)
191 | .addSuperinterface(ClassName(model.packageName, model.simpleName))
192 | .addSuperinterface(ClassName(model.packageName, builder.spec.name!!))
193 | .primaryConstructor(
194 | FunSpec.constructorBuilder()
195 | .addParameters(constructorParamsByName.values)
196 | .build()
197 | )
198 | .addProperties(properties)
199 | .addProperties(eventHandlerProperties.values)
200 | .addFunctions(eventHandlerSetters)
201 | .addFunctions(eventHandlers)
202 |
203 | val saverSpec = if (config.saveable) {
204 | generateSaver(implClassName, constructorParamsByName.values.toList(), properties)
205 | .also(implBuilder::addType)
206 | } else null
207 |
208 | return ModelImplSpec(
209 | implBuilder.build(),
210 | name = implClassName,
211 | saverSpec = saverSpec,
212 | saverName = saverSpec?.name?.let(implClassName::nestedClass),
213 | imports = listOf(
214 | MemberName("androidx.compose.runtime", "getValue"),
215 | MemberName("androidx.compose.runtime", "setValue"),
216 | )
217 | )
218 | }
219 | }
220 |
221 | private fun generateSaver(
222 | implType: TypeName,
223 | implConstructorParams: List,
224 | properties: List
225 | ): TypeSpec {
226 | val savedType = Map::class.parameterizedBy(String::class, Any::class)
227 |
228 | val saveFunction = FunSpec.builder("save")
229 | .addModifiers(OVERRIDE)
230 | .receiver(ClassName("androidx.compose.runtime.saveable", "SaverScope"))
231 | .addParameter(ParameterSpec("value", implType))
232 | .returns(savedType.copy(nullable = true))
233 | .addCode(
234 | "return %L", stringMapOf(
235 | properties.map { it.name to CodeBlock.of("value.%N", it.name) }
236 | )
237 | )
238 | .build()
239 |
240 | val constructorParamNames = implConstructorParams.mapTo(mutableSetOf()) { it.name }
241 | val nonConstructorProperties = properties.filterNot { it.name in constructorParamNames }
242 | val restoreConstructor = generateImplConstructorCall(implType,
243 | implConstructorParams.associate {
244 | it.name to CodeBlock.of("value.getValue(%S)·as·%T", it.name, it.type)
245 | })
246 | val restoreInitializers = nonConstructorProperties.map {
247 | CodeBlock.of("%1N·=·value.getValue(%1S)·as·%2T", it.name, it.type)
248 | }
249 |
250 | val restoreFunction = FunSpec.builder("restore")
251 | .addModifiers(OVERRIDE)
252 | .addParameter(ParameterSpec("value", savedType))
253 | .returns(implType.copy(nullable = true))
254 | .addCode(
255 | CodeBlock.builder()
256 | .add("return %L", restoreConstructor)
257 | .apply {
258 | if (restoreInitializers.isNotEmpty()) {
259 | beginControlFlow(".apply")
260 | restoreInitializers.forEach {
261 | addStatement("%L", it)
262 | }
263 | endControlFlow()
264 | }
265 | }
266 | .build()
267 | )
268 | .build()
269 |
270 | return TypeSpec.objectBuilder("Saver")
271 | .addSuperinterface(SAVER_INTERFACE.parameterizedBy(implType, savedType))
272 | // .primaryConstructor(
273 | // FunSpec.constructorBuilder()
274 | // .addParameter("propertySaver", propertySaverType)
275 | // .build()
276 | // )
277 | // .addProperty(
278 | // PropertySpec.builder("propertySaver", propertySaverType)
279 | // .initializer("propertySaver")
280 | // .build()
281 | // )
282 | .addFunction(saveFunction)
283 | .addFunction(restoreFunction)
284 | .build()
285 | }
286 |
287 | private fun mutableStateOf(initializer: CodeBlock) =
288 | CodeBlock.of("%M(%L)", MUTABLE_STATE_OF_FUN, initializer)
289 |
290 | private fun rememberMaybeSaveable(
291 | saverName: TypeName?,
292 | initializer: CodeBlock.Builder.() -> Unit
293 | ): CodeBlock {
294 | val rememberCall =
295 | saverName?.let { CodeBlock.of("%M(saver = %T)", REMEMBER_SAVEABLE_FUN, it) }
296 | ?: CodeBlock.of("%M", REMEMBER_FUN)
297 | return CodeBlock.builder()
298 | .beginControlFlow("%L", rememberCall)
299 | .apply(initializer)
300 | .endControlFlow()
301 | .build()
302 | }
303 |
304 | private fun stringMapOf(entries: Iterable>) =
305 | CodeBlock.builder()
306 | .addStatement("%M(", MAP_OF_FUN)
307 | .apply {
308 | entries.forEach { (name, value) ->
309 | addStatement("%S·to·%L,", name, value)
310 | }
311 | }
312 | .add(")")
313 | .build()
314 |
315 | private fun generateImplConstructorCall(
316 | implName: TypeName,
317 | propertyValues: Map
318 | ): CodeBlock = CodeBlock.builder()
319 | .addStatement("%T(", implName)
320 | .apply {
321 | propertyValues.forEach { (name, value) ->
322 | addStatement("%N·=·%L,", name, value)
323 | }
324 | }
325 | .addStatement(")")
326 | .build()
327 |
328 | private fun KSPropertyDeclaration.defaultOr(initialValue: CodeBlock): CodeBlock =
329 | getter?.let { CodeBlock.of("super.%N", simpleName.getShortName()) }
330 | ?: initialValue
331 |
332 | /*
333 |
334 | object Saver : androidx.compose.runtime.saveable.Saver>{
335 | override fun SaverScope.save(value: ViewModelImpl): Map? {
336 | return mapOf(
337 | "name" to value.name,
338 | "address" to value.address,
339 | "edits" to value.edits,
340 | )
341 | }
342 |
343 | override fun restore(value: Map): ViewModelImpl? {
344 | return ViewModelImpl(
345 | name = value.getValue("name") as String,
346 | address = value.getValue("address") as Address
347 | ).apply {
348 | edits = value.getValue("edits") as Int
349 | }
350 | }
351 | }
352 | */
--------------------------------------------------------------------------------