Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
f8f2f34
chore
TBSten Nov 13, 2025
9427c99
feat: add @GenerateCombinedField annotation and test cases
TBSten Nov 13, 2025
2a36864
feat: implement KSP plugin for @GenerateCombinedField with recursive …
TBSten Nov 13, 2025
9cf2e33
test: add comprehensive tests for @GenerateCombinedField generated code
TBSten Nov 13, 2025
edfabdc
feat: add Nullable, Enum, and Value class support to KSP Plugin
TBSten Nov 13, 2025
519bc2e
Merge branch 'main' into feature/auto-generate-combined-field
TBSten Nov 21, 2025
0659d19
chore: regenerate API files after merge
TBSten Nov 21, 2025
bc18e15
fix: replace dots with underscores in generated property names
TBSten Nov 21, 2025
8ca4c6b
docs: add requirements and supported types to @GenerateCombinedField …
TBSten Nov 21, 2025
e99fc88
fix: improve error message to include actual property count
TBSten Nov 22, 2025
2225ff6
fix: correct TransformField syntax for value class fields
TBSten Nov 22, 2025
367808e
fix: handle nullable types in recursive dependency detection
TBSten Nov 22, 2025
1e1523e
feat: add error message for unsupported generic types
TBSten Nov 22, 2025
d8ac2e7
refactor: extract common primitive type mapping logic
TBSten Nov 22, 2025
b7255ab
Merge branch 'main' into feature/auto-generate-combined-field
TBSten Nov 22, 2025
5bbb7ee
Merge main into feature/auto-generate-combined-field
TBSten Dec 14, 2025
a8cd7e6
feat: support sealed interface/class for @GenerateCombinedField
TBSten Dec 15, 2025
9adc6c1
refactor: split GenerateCombinedField.kt into separate files
TBSten Dec 15, 2025
acd9085
Merge branch 'main' into feature/auto-generate-combined-field
TBSten Dec 15, 2025
216fc39
fix: correct generated code for value classes and MutablePreviewLabFi…
TBSten Dec 15, 2025
8d02f04
fix: correct PreviewLab import path in TestScreens.kt
TBSten Dec 15, 2025
e75f00f
fix: correct MutablePreviewLabField import path in test files
TBSten Dec 15, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions core/api/android/core.api
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@ public final class me/tbsten/compose/preview/lab/PreviewLabPreviewKt {
public static synthetic fun PreviewLabPreview$default (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/String;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lme/tbsten/compose/preview/lab/PreviewLabPreview;
}

public abstract interface annotation class me/tbsten/compose/preview/lab/generatecombinedfield/GenerateCombinedField : java/lang/annotation/Annotation {
}

public abstract interface annotation class me/tbsten/compose/preview/lab/util/JsOnlyExport : java/lang/annotation/Annotation {
}

Expand Down
4 changes: 4 additions & 0 deletions core/api/core.klib.api
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
// - Show declarations: true

// Library unique name: <me.tbsten.compose.preview.lab:core>
open annotation class me.tbsten.compose.preview.lab.generatecombinedfield/GenerateCombinedField : kotlin/Annotation { // me.tbsten.compose.preview.lab.generatecombinedfield/GenerateCombinedField|null[0]
constructor <init>() // me.tbsten.compose.preview.lab.generatecombinedfield/GenerateCombinedField.<init>|<init>(){}[0]
}

abstract interface me.tbsten.compose.preview.lab/PreviewLabPreview { // me.tbsten.compose.preview.lab/PreviewLabPreview|null[0]
abstract val code // me.tbsten.compose.preview.lab/PreviewLabPreview.code|{}code[0]
abstract fun <get-code>(): kotlin/String? // me.tbsten.compose.preview.lab/PreviewLabPreview.code.<get-code>|<get-code>(){}[0]
Expand Down
3 changes: 3 additions & 0 deletions core/api/jvm/core.api
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ public final class me/tbsten/compose/preview/lab/PreviewLabPreviewKt {
public static synthetic fun PreviewLabPreview$default (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Integer;Ljava/lang/String;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lme/tbsten/compose/preview/lab/PreviewLabPreview;
}

public abstract interface annotation class me/tbsten/compose/preview/lab/generatecombinedfield/GenerateCombinedField : java/lang/annotation/Annotation {
}

public abstract interface annotation class me/tbsten/compose/preview/lab/util/JsOnlyExport : java/lang/annotation/Annotation {
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package me.tbsten.compose.preview.lab.generatecombinedfield

/**
* Annotation to generate a field extension function for a data class or sealed interface/class.
*
* ## For Data Classes
*
* When applied to a data class with a companion object, this will generate
* a `field` extension function on the companion object that creates a
* MutablePreviewLabField<T> with CombinedField.
*
* ### Requirements for Data Classes
*
* - Must be a `data class`
* - Must have a `companion object`
* - Must have a primary constructor
* - Must have at least 1 property
* - Must have at most 10 properties
*
* ### Example for Data Classes
*
* ```
* @GenerateCombinedField
* data class MyUiState(val str: String, val int: Int, val bool: Boolean) {
* companion object
* }
*
* // Generates:
* fun MyUiState.Companion.field(label: String, initialValue: MyUiState): MutablePreviewLabField<MyUiState> = ...
* ```
*
* ## For Sealed Interfaces/Classes
*
* When applied to a sealed interface or sealed class with a companion object,
* this will generate a `field` extension function that creates a
* MutablePreviewLabField<T> with PolymorphicField, automatically detecting all
* subclasses and generating appropriate fields for each.
*
* ### Requirements for Sealed Types
*
* - Must be a `sealed interface` or `sealed class`
* - Must have a `companion object`
* - All direct subclasses must be:
* - `data class` (will generate CombinedField)
* - `data object` or `object` (will generate FixedField)
*
* ### Example for Sealed Interfaces
*
* ```
* @GenerateCombinedField
* sealed interface UiState {
* data object Loading : UiState
* data class Success(val data: String) : UiState
* data class Error(val message: String) : UiState
* companion object
* }
*
* // Generates:
* fun UiState.Companion.field(label: String, initialValue: UiState): MutablePreviewLabField<UiState> =
* PolymorphicField(
* label = label,
* initialValue = initialValue,
* fields = listOf(
* FixedField("Loading", UiState.Loading),
* combined(label = "Success", ...) { UiState.Success(it) },
* combined(label = "Error", ...) { UiState.Error(it) },
* ),
* )
* ```
*
* ## Supported Property Types
*
* - Primitive types: String, Int, Float, Boolean, etc.
* - Compose value types: Dp, Color, etc.
* - Nullable types
* - Enum types
* - Value classes
* - Nested data classes with companion objects (recursive generation)
*/
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.SOURCE)
annotation class GenerateCombinedField
Original file line number Diff line number Diff line change
Expand Up @@ -496,7 +496,9 @@ enum class PreviewsForUiDebug(
},
)

HorizontalDivider()
HorizontalDivider(
modifier = Modifier.padding(vertical = 20.dp),
)

Scaffold(
topBar = fieldValue {
Expand Down
5 changes: 5 additions & 0 deletions integrationTest/uiLib/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ kotlin {
implementation(compose.components.uiToolingPreview)
implementation("me.tbsten.compose.preview.lab:starter:${libs.versions.composePreviewLab.get()}")
}

jvmTest.dependencies {
implementation(kotlin("test"))
implementation(kotlin("test-junit"))
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package me.tbsten.compose.preview.lab.sample.lib.testcase

import me.tbsten.compose.preview.lab.generatecombinedfield.GenerateCombinedField

/**
* Test case 3: Complex combination with multiple nested levels and various types
*
* This tests:
* - Multiple levels of nesting
* - Various primitive types (String, Int, Boolean, Float)
* - Multiple nested data classes in a single parent
*/

@GenerateCombinedField
data class UserInfo(
val name: String,
val age: Int,
val isVerified: Boolean,
) {
companion object
}

fun UserInfo.Companion.fake() = UserInfo(
name = "John Doe",
age = 30,
isVerified = true,
)

@GenerateCombinedField
data class AppSettings(
val isDarkMode: Boolean,
val fontSize: Int,
val volume: Float,
) {
companion object
}

fun AppSettings.Companion.fake() = AppSettings(
isDarkMode = false,
fontSize = 14,
volume = 0.5f,
)

@GenerateCombinedField
data class ComplexUiState(
val userInfo: UserInfo,
val settings: AppSettings,
val isLoading: Boolean,
val errorMessage: String,
) {
companion object
}

fun ComplexUiState.Companion.fake() = ComplexUiState(
userInfo = UserInfo.fake(),
settings = AppSettings.fake(),
isLoading = false,
errorMessage = "",
)

/**
* Expected generated code:
*
* fun UserInfo.Companion.field(label: String, initialValue: UserInfo) = CombinedField3(
* label = label,
* field1 = StringField("name", initialValue = initialValue.name),
* field2 = IntField("age", initialValue = initialValue.age),
* field3 = BooleanField("isVerified", initialValue = initialValue.isVerified),
* combine = { name, age, isVerified -> UserInfo(name = name, age = age, isVerified = isVerified) },
* split = { splitedOf(it.name, it.age, it.isVerified) },
* )
*
* fun AppSettings.Companion.field(label: String, initialValue: AppSettings) = CombinedField3(
* label = label,
* field1 = BooleanField("isDarkMode", initialValue = initialValue.isDarkMode),
* field2 = IntField("fontSize", initialValue = initialValue.fontSize),
* field3 = FloatField("volume", initialValue = initialValue.volume),
* combine = { isDarkMode, fontSize, volume -> AppSettings(isDarkMode = isDarkMode, fontSize = fontSize, volume = volume) },
* split = { splitedOf(it.isDarkMode, it.fontSize, it.volume) },
* )
*
* fun ComplexUiState.Companion.field(label: String, initialValue: ComplexUiState) = CombinedField4(
* label = label,
* field1 = UserInfo.field(label = "userInfo", initialValue = initialValue.userInfo),
* field2 = AppSettings.field(label = "settings", initialValue = initialValue.settings),
* field3 = BooleanField("isLoading", initialValue = initialValue.isLoading),
* field4 = StringField("errorMessage", initialValue = initialValue.errorMessage),
* combine = { userInfo, settings, isLoading, errorMessage ->
* ComplexUiState(
* userInfo = userInfo,
* settings = settings,
* isLoading = isLoading,
* errorMessage = errorMessage
* )
* },
* split = { splitedOf(it.userInfo, it.settings, it.isLoading, it.errorMessage) },
* )
*/
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package me.tbsten.compose.preview.lab.sample.lib.testcase

import me.tbsten.compose.preview.lab.generatecombinedfield.GenerateCombinedField

/**
* Test case 2: Nested data classes
*
* This tests the case where a data class contains another data class
* that also has @GenerateCombinedField annotation.
*
* Expected behavior:
* - Section1State.Companion.field() should be generated
* - NestedUiState.Companion.field() should be generated
* - NestedUiState's field should use Section1State.field() internally
*/
@GenerateCombinedField
data class Section1State(
val heading: String,
val body: String,
) {
companion object
}

fun Section1State.Companion.fake() = Section1State(
heading = "Section Heading",
body = "Section body text goes here",
)

@GenerateCombinedField
data class NestedUiState(
val section1State: Section1State,
val enableButton: Boolean,
) {
companion object
}

fun NestedUiState.Companion.fake() = NestedUiState(
section1State = Section1State.fake(),
enableButton = true,
)

/**
* Expected generated code:
*
* fun Section1State.Companion.field(label: String, initialValue: Section1State) = CombinedField2(
* label = label,
* field1 = StringField("heading", initialValue = initialValue.heading),
* field2 = StringField("body", initialValue = initialValue.body),
* combine = { heading, body ->
* Section1State(heading = heading, body = body)
* },
* split = { splitedOf(it.heading, it.body) },
* )
*
* fun NestedUiState.Companion.field(label: String, initialValue: NestedUiState): MutablePreviewLabField<NestedUiState> = CombinedField2(
* label = label,
* field1 = Section1State.field(label = "section1State", initialValue = initialValue.section1State),
* field2 = BooleanField(label = "enableButton", initialValue = initialValue.enableButton),
* combine = { section1State, enableButton ->
* NestedUiState(
* section1State = section1State,
* enableButton = enableButton
* )
* },
* split = { splitedOf(it.section1State, it.enableButton) },
* )
*/
Loading