diff --git a/bom/build.gradle.kts b/bom/build.gradle.kts index a3c816eee..0ebecdc1d 100644 --- a/bom/build.gradle.kts +++ b/bom/build.gradle.kts @@ -11,17 +11,12 @@ plugins { } dependencies { - val ignoreProjects = listOf( - projects.bom.dependencyProject, - projects.uiSample.dependencyProject, - projects.utilBackendTest.dependencyProject, - projects.catalog.dependencyProject, - projects.materialIconGenerator.dependencyProject, - ) - constraints { rootProject.subprojects.forEach { project -> - if (project !in ignoreProjects) { + if ( + project != projects.bom.dependencyProject && + File(project.projectDir, "version.txt").exists() + ) { api( ArtifactConfig.of(project).toString() .also { artifact -> diff --git a/settings.gradle.kts b/settings.gradle.kts index 22e287b80..b391ddb42 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -37,6 +37,7 @@ include( ":util-backend-kotlinc", ":util-backend-test", ":util-compose-runtime-test", + ":util-compose-snapshot-test", ":runtime", ":material", ":material-icon", @@ -46,6 +47,8 @@ include( ":ui-plugin", ":ui-plugin:image", ":ui-plugin:image:gif", + ":ui-plugin:interceptor", + ":ui-plugin:interceptor:textfield", ":ui-sample", ":sugar-material", ":sugar-processor", diff --git a/ui-plugin/image/gif/src/main/kotlin/team/duckie/quackquack/ui/plugin/image/gif/QuackImageGifPlugin.kt b/ui-plugin/image/gif/src/main/kotlin/team/duckie/quackquack/ui/plugin/image/gif/QuackImageGifPlugin.kt index a318023a8..a9760804f 100644 --- a/ui-plugin/image/gif/src/main/kotlin/team/duckie/quackquack/ui/plugin/image/gif/QuackImageGifPlugin.kt +++ b/ui-plugin/image/gif/src/main/kotlin/team/duckie/quackquack/ui/plugin/image/gif/QuackImageGifPlugin.kt @@ -18,11 +18,12 @@ import team.duckie.quackquack.ui.plugin.image.QuackImagePlugin * 내부적으로 [QuackImagePlugin.CoilImageLoader]를 사용합니다. */ @Stable -public val QuackImageGifPlugin: QuackImagePlugin.CoilImageLoader = QuackImagePlugin.CoilImageLoader { _, _, _, _ -> - components { - add( - if (Build.VERSION.SDK_INT >= 28) ImageDecoderDecoder.Factory() - else GifDecoder.Factory(), - ) +public val QuackImageGifPlugin: QuackImagePlugin.CoilImageLoader = + QuackImagePlugin.CoilImageLoader { _, _, _, _ -> + components { + add( + if (Build.VERSION.SDK_INT >= 28) ImageDecoderDecoder.Factory() + else GifDecoder.Factory(), + ) + } } -} diff --git a/ui-plugin/image/gif/src/test/kotlin/team/duckie/quackquack/ui/plugin/image/gif/QuackImageGifPluginTest.kt b/ui-plugin/image/gif/src/test/kotlin/team/duckie/quackquack/ui/plugin/image/gif/QuackImageGifPluginSnapshot.kt similarity index 98% rename from ui-plugin/image/gif/src/test/kotlin/team/duckie/quackquack/ui/plugin/image/gif/QuackImageGifPluginTest.kt rename to ui-plugin/image/gif/src/test/kotlin/team/duckie/quackquack/ui/plugin/image/gif/QuackImageGifPluginSnapshot.kt index cf4d004ba..a82f809d9 100644 --- a/ui-plugin/image/gif/src/test/kotlin/team/duckie/quackquack/ui/plugin/image/gif/QuackImageGifPluginTest.kt +++ b/ui-plugin/image/gif/src/test/kotlin/team/duckie/quackquack/ui/plugin/image/gif/QuackImageGifPluginSnapshot.kt @@ -24,7 +24,7 @@ import team.duckie.quackquack.ui.plugin.rememberQuackPlugins @Ignore("GIF 녹화 안됨") @RunWith(AndroidJUnit4::class) -class QuackImageGifPluginTest { +class QuackImageGifPluginSnapshot { @get:Rule val compose = createAndroidComposeRule() diff --git a/ui-plugin/image/src/main/kotlin/team/duckie/quackquack/ui/plugin/image/QuackImagePlugin.kt b/ui-plugin/image/src/main/kotlin/team/duckie/quackquack/ui/plugin/image/QuackImagePlugin.kt index 1602e26e9..2798a1b52 100644 --- a/ui-plugin/image/src/main/kotlin/team/duckie/quackquack/ui/plugin/image/QuackImagePlugin.kt +++ b/ui-plugin/image/src/main/kotlin/team/duckie/quackquack/ui/plugin/image/QuackImagePlugin.kt @@ -31,8 +31,9 @@ public sealed interface QuackImagePlugin : QuackPlugin { * @param contentDescription `QuackImage`의 `contentDescription` 인자로 제공된 값 * @param quackPluginLocal `Modifier.quackPluginLocal`로 제공된 값 */ + // compose-ui 의존성 없어서 Modifier.quackPluginLocal에 링크를 적용하지 않음 @Stable - public fun ImageLoader.Builder.builder( + public fun ImageLoader.Builder.quackBuild( context: Context, src: Any?, contentDescription: String?, diff --git a/ui-plugin/image/src/test/kotlin/team/duckie/quackquack/ui/plugin/image/QuackImagePluginTest.kt b/ui-plugin/image/src/test/kotlin/team/duckie/quackquack/ui/plugin/image/QuackImagePluginTest.kt index 862a68853..0a8b8ecf6 100644 --- a/ui-plugin/image/src/test/kotlin/team/duckie/quackquack/ui/plugin/image/QuackImagePluginTest.kt +++ b/ui-plugin/image/src/test/kotlin/team/duckie/quackquack/ui/plugin/image/QuackImagePluginTest.kt @@ -97,7 +97,7 @@ private fun imageResultOf(drawable: Drawable, request: ImageRequest) = private class QuackImageCoilBuilderIntercepter( private val map: MutableMap = mutableMapOf(), ) : QuackImagePlugin.CoilImageLoader { - override fun ImageLoader.Builder.builder( + override fun ImageLoader.Builder.quackBuild( context: Context, src: Any?, contentDescription: String?, diff --git a/ui-plugin/interceptor/build.gradle.kts b/ui-plugin/interceptor/build.gradle.kts new file mode 100644 index 000000000..5118130d8 --- /dev/null +++ b/ui-plugin/interceptor/build.gradle.kts @@ -0,0 +1,55 @@ +/* + * Designed and developed by Duckie Team 2023. + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/quack-quack-android/blob/main/LICENSE + */ + +@file:Suppress("UnstableApiUsage") + +import org.jetbrains.dokka.gradle.DokkaMultiModuleTask + +plugins { + quackquack("android-library") + quackquack("android-compose") + quackquack("kotlin-explicit-api") + quackquack("quack-publishing") + alias(libs.plugins.test.roborazzi) +} + +tasks.withType { + dependsOn(":ui-plugin:dokkaHtmlMultiModule") +} + +android { + namespace = "team.duckie.quackquack.ui.plugin.interceptor" + + testOptions { + unitTests { + isIncludeAndroidResources = true + isReturnDefaultValues = true + all { test -> + test.systemProperty("robolectric.graphicsMode", "NATIVE") + } + } + } +} + +dependencies { + api(projects.uiPlugin.orArtifact()) + implementations( + libs.compose.runtime, + libs.compose.ui.core, + projects.material, + projects.utilModifier, + ) + testImplementations( + libs.test.robolectric, + libs.test.junit.compose, + libs.test.kotest.assertion.core, + libs.test.kotlin.coroutines, // needed for compose-ui-test + libs.bundles.test.roborazzi, + projects.ui, + projects.utilComposeSnapshotTest, + ) +} diff --git a/ui-plugin/interceptor/src/main/AndroidManifest.xml b/ui-plugin/interceptor/src/main/AndroidManifest.xml new file mode 100644 index 000000000..b1e11303c --- /dev/null +++ b/ui-plugin/interceptor/src/main/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + diff --git a/ui-plugin/interceptor/src/main/kotlin/team/duckie/quackquack/ui/plugin/interceptor/QuackInterceptorPlugin.kt b/ui-plugin/interceptor/src/main/kotlin/team/duckie/quackquack/ui/plugin/interceptor/QuackInterceptorPlugin.kt new file mode 100644 index 000000000..69dc82b72 --- /dev/null +++ b/ui-plugin/interceptor/src/main/kotlin/team/duckie/quackquack/ui/plugin/interceptor/QuackInterceptorPlugin.kt @@ -0,0 +1,40 @@ +/* + * Designed and developed by Duckie Team 2023. + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/quack-quack-android/blob/main/LICENSE + */ + +package team.duckie.quackquack.ui.plugin.interceptor + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.ui.Modifier +import team.duckie.quackquack.ui.plugin.QuackPlugin +import team.duckie.quackquack.ui.plugin.QuackPluginLocal +import team.duckie.quackquack.ui.plugin.quackPluginLocal + +/** 꽥꽥 컴포넌트가 그려지기 전에 특정 조건을 가로채서 변경할 수 있는 플러그인입니다. */ +@Immutable +public sealed interface QuackInterceptorPlugin : QuackPlugin { + /** 컴포넌트의 디자인 토큰을 가로채서 변경합니다. */ + @Immutable + public fun interface DesignToken : QuackInterceptorPlugin { + /** + * 컴포넌트의 디자인 토큰을 가로채서 변경한 새로운 디자인 토큰을 반환합니다. + * + * @param componentName 플러그인이 적용될 컴포넌트의 이름 + * @param componentDesignToken 플러그인이 적용되고 있는 컴포넌트에 적용된 디자인 토큰. + * 이 값은 이 플러그인이 적용되기 전에 미리 갖고 있던 디자인 토큰을 나타냅니다. + * @param componentModifier 플러그인이 적용되고 있는 컴포넌트가 사용하고 있는 [Modifier] + * @param quackPluginLocal [Modifier.quackPluginLocal]로 제공된 값 + */ + @Stable + public fun intercept( + componentName: String, + componentDesignToken: Any, + componentModifier: Modifier, + quackPluginLocal: QuackPluginLocal?, + ): Any + } +} diff --git a/ui-plugin/interceptor/src/main/kotlin/team/duckie/quackquack/ui/plugin/interceptor/extensions.kt b/ui-plugin/interceptor/src/main/kotlin/team/duckie/quackquack/ui/plugin/interceptor/extensions.kt new file mode 100644 index 000000000..e865b0603 --- /dev/null +++ b/ui-plugin/interceptor/src/main/kotlin/team/duckie/quackquack/ui/plugin/interceptor/extensions.kt @@ -0,0 +1,64 @@ +/* + * Designed and developed by Duckie Team 2023. + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/quack-quack-android/blob/main/LICENSE + */ + +package team.duckie.quackquack.ui.plugin.interceptor + +import androidx.annotation.VisibleForTesting +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import team.duckie.quackquack.ui.plugin.EmptyQuackPlugins +import team.duckie.quackquack.ui.plugin.LocalQuackPlugins +import team.duckie.quackquack.ui.plugin.QuackPluginLocal +import team.duckie.quackquack.ui.plugin.lastByTypeOrNull +import team.duckie.quackquack.util.modifier.getElementByTypeOrNull + +/** 현재 실행 중인 call-site의 메서드 이름을 반환합니다. */ +@PublishedApi +internal val currentMethodName: String + // https://stackoverflow.com/a/32329165/14299073 + inline get() = Thread.currentThread().stackTrace[1].methodName + +/** intercept된 스타일이 기존에 제공된 스타일과 다른 타입일 때 throw할 에러 메시지 */ +@PublishedApi +@VisibleForTesting +internal const val InterceptedStyleTypeExceptionMessage: String = + "The intercepted style is of a different type than the original style." + +/** + * 주어진 [디자인 토큰][style]에 [QuackInterceptorPlugin.DesignToken] 플러그인을 적용한 후, + * 새로운 [디자인 토큰][Style]을 반환합니다. + * + * 만약 [기존 디자인 토큰][style]과 새로운 디자인 토큰의 타입이 다르다면 [IllegalStateException]이 + * 발생합니다. 새로운 디자인 토큰의 타입은 [Style]과 일치할 것이라 가정합니다. + * + * @param Style intercept의 결과로 생성될 디자인 토큰의 타입 + * @param style 기존에 적용된 디자인 토큰 + * @param modifier 현재 컴포넌트에 적용된 [Modifier] + */ +@Composable +public inline fun rememberInterceptedStyleSafely(style: Any, modifier: Modifier): Style { + val localQuackPlugins = LocalQuackPlugins.current + + return remember(localQuackPlugins, style, modifier) { + localQuackPlugins.takeIf { it != EmptyQuackPlugins }?.let { plugins -> + val interceptorPlugin = plugins.lastByTypeOrNull() + ?: return@let null + val quackPluginLocal = modifier.getElementByTypeOrNull() + + interceptorPlugin + .intercept( + componentName = currentMethodName, + componentDesignToken = style, + componentModifier = modifier, + quackPluginLocal = quackPluginLocal, + ) + } ?: style + }.also { interceptedStyle -> + check(interceptedStyle is Style, lazyMessage = ::InterceptedStyleTypeExceptionMessage) + } as Style +} diff --git a/ui-plugin/interceptor/src/test/kotlin/team/duckie/quackquack/ui/plugin/interceptor/QuackInterceptorPluginTest.kt b/ui-plugin/interceptor/src/test/kotlin/team/duckie/quackquack/ui/plugin/interceptor/QuackInterceptorPluginTest.kt new file mode 100644 index 000000000..b728416d8 --- /dev/null +++ b/ui-plugin/interceptor/src/test/kotlin/team/duckie/quackquack/ui/plugin/interceptor/QuackInterceptorPluginTest.kt @@ -0,0 +1,129 @@ +/* + * Designed and developed by Duckie Team 2023. + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/quack-quack-android/blob/main/LICENSE + */ + +@file:OptIn(ExperimentalQuackQuackApi::class) + +package team.duckie.quackquack.ui.plugin.interceptor + +import com.github.takahirom.roborazzi.RoborazziRule.Ignore as NoSnapshot +import androidx.activity.ComponentActivity +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.github.takahirom.roborazzi.RoborazziRule +import io.kotest.assertions.throwables.shouldThrowWithMessage +import io.kotest.matchers.maps.shouldMatchExactly +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.theme.QuackTheme +import team.duckie.quackquack.ui.QuackTag +import team.duckie.quackquack.ui.QuackTagStyle +import team.duckie.quackquack.ui.TagStyleMarker +import team.duckie.quackquack.ui.plugin.rememberQuackPlugins +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi +import team.duckie.quackquack.util.compose.snapshot.test.SnapshotName +import team.duckie.quackquack.util.compose.snapshot.test.snapshotPath + +@RunWith(AndroidJUnit4::class) +class QuackInterceptorPluginTest { + @get:Rule + val compose = createAndroidComposeRule() + + @get:Rule + val roborazzi = RoborazziRule( + composeRule = compose, + captureRoot = compose.onRoot(), + options = RoborazziRule.Options( + outputFileProvider = { description, _, fileExtension -> + val snapshotName = description.getAnnotation(SnapshotName::class.java)?.name ?: description.methodName + snapshotPath( + domain = "QuackInterceptorPlugin", + snapshotName = snapshotName, + isGif = fileExtension == "gif", + ) + }, + ), + ) + + @SnapshotName("QuackTagRadiusStyleIntercepted") + @Test + fun `style intercept works fine`() { + val map = mutableMapOf() + + var interceptedStyle: QuackTagStyle? = null + val interceptedRadius = Int.MAX_VALUE.dp + + compose.setContent { + QuackTheme( + plugins = rememberQuackPlugins { + +QuackInterceptorPlugin.DesignToken { componentName, componentDesignToken, componentModifier, _ -> + map["componentName"] = componentName + map["componentDesignToken"] = componentDesignToken + map["componentModifier"] = componentModifier + + (if (componentName == "QuackTag") { + @Suppress("UNCHECKED_CAST") + componentDesignToken as QuackTagStyle + object : QuackTagStyle by componentDesignToken { + override val radius = interceptedRadius + override val colors = + componentDesignToken.colors.copy( + backgroundColor = QuackColor.Gray3, + contentColor = QuackColor.Black, + ) + } + } else { + componentDesignToken + }).also { intercepttedResult -> + @Suppress("UNCHECKED_CAST") + interceptedStyle = intercepttedResult as QuackTagStyle + } + } + }, + ) { + QuackTag( + text = "Intercepted Tag", + style = QuackTagStyle.Filled, + onClick = {}, + ) + } + } + + map.shouldMatchExactly( + "componentName" to { it shouldBe "QuackTag" }, + "componentDesignToken" to { it.toString() shouldBe QuackTagStyle.Filled.toString() }, + "componentModifier" to { it shouldBe Modifier }, + ) + interceptedStyle.shouldNotBeNull().radius shouldBe interceptedRadius + } + + @NoSnapshot + @Test + fun InterceptedStyleTypeExceptionMessage() { + shouldThrowWithMessage(InterceptedStyleTypeExceptionMessage) { + compose.setContent { + QuackTheme( + plugins = rememberQuackPlugins { + +QuackInterceptorPlugin.DesignToken { _, _, _, _ -> Unit } + }, + ) { + QuackTag( + text = "", + style = QuackTagStyle.Filled, + onClick = {}, + ) + } + } + } + } +} diff --git a/ui-plugin/interceptor/src/test/resources/robolectric.properties b/ui-plugin/interceptor/src/test/resources/robolectric.properties new file mode 100644 index 000000000..2531a1168 --- /dev/null +++ b/ui-plugin/interceptor/src/test/resources/robolectric.properties @@ -0,0 +1,8 @@ +# +# Designed and developed by Duckie Team 2023. +# +# Licensed under the MIT. +# Please see full license: https://github.com/duckie-team/quack-quack-android/blob/main/LICENSE +# + +sdk=33 diff --git a/ui-plugin/interceptor/src/test/snapshots/QuackInterceptorPlugin/QuackTagRadiusStyleIntercepted.png b/ui-plugin/interceptor/src/test/snapshots/QuackInterceptorPlugin/QuackTagRadiusStyleIntercepted.png new file mode 100644 index 000000000..d7838469d Binary files /dev/null and b/ui-plugin/interceptor/src/test/snapshots/QuackInterceptorPlugin/QuackTagRadiusStyleIntercepted.png differ diff --git a/ui-plugin/interceptor/textfield/build.gradle.kts b/ui-plugin/interceptor/textfield/build.gradle.kts new file mode 100644 index 000000000..ff2dc563a --- /dev/null +++ b/ui-plugin/interceptor/textfield/build.gradle.kts @@ -0,0 +1,55 @@ +/* + * Designed and developed by Duckie Team 2023. + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/quack-quack-android/blob/main/LICENSE + */ + +@file:Suppress("UnstableApiUsage") + +import org.jetbrains.dokka.gradle.DokkaMultiModuleTask + +plugins { + quackquack("android-library") + quackquack("android-compose") + quackquack("kotlin-explicit-api") + quackquack("test-junit") + quackquack("quack-publishing") + alias(libs.plugins.test.roborazzi) +} + +tasks.withType { + dependsOn(":ui-plugin:dokkaHtmlMultiModule") +} + +android { + namespace = "team.duckie.quackquack.ui.plugin.interceptor.textfield" + + testOptions { + unitTests { + isIncludeAndroidResources = true + isReturnDefaultValues = true + all { test -> + test.systemProperty("robolectric.graphicsMode", "NATIVE") + } + } + } +} + +dependencies { + api(projects.uiPlugin.interceptor.orArtifact()) + implementations( + libs.compose.runtime, + libs.compose.ui.text, + projects.ui, + ) + testImplementations( + libs.compose.foundation, + libs.test.robolectric, + libs.test.junit.compose, + libs.test.kotest.assertion.core, + libs.test.kotlin.coroutines, // needed for compose-ui-test + libs.bundles.test.roborazzi, + projects.utilComposeSnapshotTest, + ) +} diff --git a/ui-plugin/interceptor/textfield/src/main/AndroidManifest.xml b/ui-plugin/interceptor/textfield/src/main/AndroidManifest.xml new file mode 100644 index 000000000..b1e11303c --- /dev/null +++ b/ui-plugin/interceptor/textfield/src/main/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + diff --git a/ui-plugin/interceptor/textfield/src/main/kotlin/team/duckie/quackquack/ui/plugin/interceptor/textfield/QuackTextFieldFontFamilyRemovalPlugin.kt b/ui-plugin/interceptor/textfield/src/main/kotlin/team/duckie/quackquack/ui/plugin/interceptor/textfield/QuackTextFieldFontFamilyRemovalPlugin.kt new file mode 100644 index 000000000..305942a0d --- /dev/null +++ b/ui-plugin/interceptor/textfield/src/main/kotlin/team/duckie/quackquack/ui/plugin/interceptor/textfield/QuackTextFieldFontFamilyRemovalPlugin.kt @@ -0,0 +1,55 @@ +/* + * Designed and developed by Duckie Team 2023. + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/quack-quack-android/blob/main/LICENSE + */ + +@file:OptIn(ExperimentalDesignToken::class) + +package team.duckie.quackquack.ui.plugin.interceptor.textfield + +import androidx.compose.runtime.Stable +import androidx.compose.ui.text.font.FontFamily +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.ui.QuackTextFieldStyle +import team.duckie.quackquack.ui.TextFieldColorMarker +import team.duckie.quackquack.ui.TextFieldStyleMarker +import team.duckie.quackquack.ui.optin.ExperimentalDesignToken +import team.duckie.quackquack.ui.plugin.interceptor.QuackInterceptorPlugin + +/** + * 꽥꽥에서 제공되는 TextField 컴포넌트의 이름 모음. + * 여기에 포함된 컴포넌트에만 [QuackTextFieldFontFamilyRemovalPlugin]이 적용됩니다. + */ +private val quackTextFieldComponentNames = + listOf("QuackDefaultTextField", "QuackFilledTextField", "QuackOutlinedTextField") + +/** + * `QuackTextField`에 적용될 디자인 토큰에서 `fontFamily`의 값을 [FontFamily.Default]로 변경합니다. + * 이 플러그인은 [#761](https://github.com/duckie-team/quack-quack-android/issues/761)의 임시 해결책으로 제공됩니다. + */ +@Stable +public val QuackTextFieldFontFamilyRemovalPlugin: QuackInterceptorPlugin.DesignToken = + QuackInterceptorPlugin.DesignToken { componentName, componentDesignToken, _, _ -> + if (quackTextFieldComponentNames.contains(componentName)) { + @Suppress("UNCHECKED_CAST") + componentDesignToken as QuackTextFieldStyle + object : QuackTextFieldStyle by componentDesignToken { + override val typography = + with(componentDesignToken.typography) { + QuackTypography( + color = color, + size = size, + weight = weight, + letterSpacing = letterSpacing, + lineHeight = lineHeight, + textAlign = textAlign, + fontFamily = FontFamily.Default, + ) + } + } + } else { + componentDesignToken + } + } diff --git a/ui-plugin/interceptor/textfield/src/test/kotlin/team/duckie/quackquack/ui/plugin/interceptor/textfield/QuackTextFieldFontFamilyRemovalPluginSnapshot.kt b/ui-plugin/interceptor/textfield/src/test/kotlin/team/duckie/quackquack/ui/plugin/interceptor/textfield/QuackTextFieldFontFamilyRemovalPluginSnapshot.kt new file mode 100644 index 000000000..6ff3556a2 --- /dev/null +++ b/ui-plugin/interceptor/textfield/src/test/kotlin/team/duckie/quackquack/ui/plugin/interceptor/textfield/QuackTextFieldFontFamilyRemovalPluginSnapshot.kt @@ -0,0 +1,64 @@ +/* + * Designed and developed by Duckie Team 2023. + * + * Licensed under the MIT. + * Please see full license: https://github.com/duckie-team/quack-quack-android/blob/main/LICENSE + */ + +@file:OptIn(ExperimentalDesignToken::class, ExperimentalQuackQuackApi::class) + +package team.duckie.quackquack.ui.plugin.interceptor.textfield + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.github.takahirom.roborazzi.captureRoboImage +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import team.duckie.quackquack.material.theme.QuackTheme +import team.duckie.quackquack.ui.QuackDefaultTextField +import team.duckie.quackquack.ui.QuackFilledTextField +import team.duckie.quackquack.ui.QuackTextFieldStyle +import team.duckie.quackquack.ui.optin.ExperimentalDesignToken +import team.duckie.quackquack.ui.plugin.rememberQuackPlugins +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi +import team.duckie.quackquack.util.compose.snapshot.test.SnapshotPathGeneratorRule + +@RunWith(AndroidJUnit4::class) +class QuackTextFieldFontFamilyRemovalPluginSnapshot { + @get:Rule + val snapshotPath = SnapshotPathGeneratorRule("QuackTextFieldFontFamilyRemovalPlugin") + + @Test + fun DefaultTextField() { + captureRoboImage(snapshotPath()) { + QuackTheme( + plugins = rememberQuackPlugins { + +QuackTextFieldFontFamilyRemovalPlugin + }, + ) { + QuackDefaultTextField( + value = "퍠꿻땗뷃휉퉶뷟퍯 <- 잘 보이니?", + onValueChange = {}, + style = QuackTextFieldStyle.Default, + ) + } + } + } + + @Test + fun FilledTextField() { + captureRoboImage(snapshotPath()) { + QuackTheme( + plugins = rememberQuackPlugins { + +QuackTextFieldFontFamilyRemovalPlugin + }, + ) { + QuackFilledTextField( + value = "퍠꿻땗뷃휉퉶뷟퍯 <- 잘 보이니?", + onValueChange = {}, + style = QuackTextFieldStyle.FilledLarge, + ) + } + } + } +} diff --git a/ui-plugin/interceptor/textfield/src/test/resources/robolectric.properties b/ui-plugin/interceptor/textfield/src/test/resources/robolectric.properties new file mode 100644 index 000000000..2531a1168 --- /dev/null +++ b/ui-plugin/interceptor/textfield/src/test/resources/robolectric.properties @@ -0,0 +1,8 @@ +# +# Designed and developed by Duckie Team 2023. +# +# Licensed under the MIT. +# Please see full license: https://github.com/duckie-team/quack-quack-android/blob/main/LICENSE +# + +sdk=33 diff --git a/ui-plugin/interceptor/textfield/src/test/snapshots/QuackTextFieldFontFamilyRemovalPlugin/DefaultTextField.png b/ui-plugin/interceptor/textfield/src/test/snapshots/QuackTextFieldFontFamilyRemovalPlugin/DefaultTextField.png new file mode 100644 index 000000000..aa1ec4703 Binary files /dev/null and b/ui-plugin/interceptor/textfield/src/test/snapshots/QuackTextFieldFontFamilyRemovalPlugin/DefaultTextField.png differ diff --git a/ui-plugin/interceptor/textfield/src/test/snapshots/QuackTextFieldFontFamilyRemovalPlugin/FilledTextField.png b/ui-plugin/interceptor/textfield/src/test/snapshots/QuackTextFieldFontFamilyRemovalPlugin/FilledTextField.png new file mode 100644 index 000000000..795f6bf81 Binary files /dev/null and b/ui-plugin/interceptor/textfield/src/test/snapshots/QuackTextFieldFontFamilyRemovalPlugin/FilledTextField.png differ diff --git a/ui-plugin/interceptor/textfield/version.txt b/ui-plugin/interceptor/textfield/version.txt new file mode 100644 index 000000000..311be597a --- /dev/null +++ b/ui-plugin/interceptor/textfield/version.txt @@ -0,0 +1 @@ +2.0.0-alpha01 diff --git a/ui-plugin/interceptor/version.txt b/ui-plugin/interceptor/version.txt new file mode 100644 index 000000000..311be597a --- /dev/null +++ b/ui-plugin/interceptor/version.txt @@ -0,0 +1 @@ +2.0.0-alpha01 diff --git a/ui-plugin/src/main/kotlin/team/duckie/quackquack/ui/plugin/LocalQuackPlugins.kt b/ui-plugin/src/main/kotlin/team/duckie/quackquack/ui/plugin/LocalQuackPlugins.kt index 6810abacc..56550a5a3 100644 --- a/ui-plugin/src/main/kotlin/team/duckie/quackquack/ui/plugin/LocalQuackPlugins.kt +++ b/ui-plugin/src/main/kotlin/team/duckie/quackquack/ui/plugin/LocalQuackPlugins.kt @@ -15,6 +15,7 @@ import androidx.compose.runtime.staticCompositionLocalOf * 만약 등록된 플러그인이 없다면 [EmptyQuackPlugins]를 반환합니다. * 플러그인을 등록하려면 [rememberQuackPlugins]를 사용하세요. */ -public val LocalQuackPlugins: ProvidableCompositionLocal = staticCompositionLocalOf { - EmptyQuackPlugins -} +public val LocalQuackPlugins: ProvidableCompositionLocal = + staticCompositionLocalOf { + EmptyQuackPlugins + } diff --git a/ui-plugin/src/main/kotlin/team/duckie/quackquack/ui/plugin/QuackPlugin.kt b/ui-plugin/src/main/kotlin/team/duckie/quackquack/ui/plugin/QuackPlugin.kt index 34ae684bc..0a063878d 100644 --- a/ui-plugin/src/main/kotlin/team/duckie/quackquack/ui/plugin/QuackPlugin.kt +++ b/ui-plugin/src/main/kotlin/team/duckie/quackquack/ui/plugin/QuackPlugin.kt @@ -25,6 +25,7 @@ public interface QuackPlugin @Immutable public interface QuackPlugins { /** 등록된 전체 플러그인 */ + @Stable public val plugins: MutableVector /** @@ -36,6 +37,7 @@ public interface QuackPlugins { * } * ``` */ + @Stable public operator fun QuackPlugin.unaryPlus() } @@ -54,6 +56,7 @@ public object EmptyQuackPlugins : QuackPlugins { } /** capacity가 16인 기본 플러그인 저장소 인스턴스를 제공합니다. */ +@Immutable private class QuackPluginsScope : QuackPlugins { override val plugins = mutableVectorOf() override fun QuackPlugin.unaryPlus() { diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts index ebcc61a2f..63fda769a 100644 --- a/ui/build.gradle.kts +++ b/ui/build.gradle.kts @@ -91,6 +91,7 @@ dependencies { projects.material.orArtifact(), projects.materialIcon.orArtifact(), projects.uiPlugin.image.orArtifact(), + projects.uiPlugin.interceptor.orArtifact(), projects.util.orArtifact(), projects.utilModifier.orArtifact(), projects.casaAnnotation.orArtifact(), diff --git a/ui/src/main/kotlin/team/duckie/quackquack/ui/button.kt b/ui/src/main/kotlin/team/duckie/quackquack/ui/button.kt index 846eaba46..ecab93987 100644 --- a/ui/src/main/kotlin/team/duckie/quackquack/ui/button.kt +++ b/ui/src/main/kotlin/team/duckie/quackquack/ui/button.kt @@ -52,6 +52,7 @@ import team.duckie.quackquack.runtime.QuackDataModifierModel import team.duckie.quackquack.runtime.quackMaterializeOf import team.duckie.quackquack.sugar.material.NoSugar import team.duckie.quackquack.sugar.material.SugarToken +import team.duckie.quackquack.ui.plugin.interceptor.rememberInterceptedStyleSafely import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi import team.duckie.quackquack.ui.util.QuackDsl import team.duckie.quackquack.ui.util.asLoose @@ -768,6 +769,9 @@ public fun QuackButton( rippleEnabled: Boolean = true, @CasaValue("{}") onClick: () -> Unit, ) { + @Suppress("NAME_SHADOWING") + val style: QuackButtonStyle = rememberInterceptedStyleSafely(style = style, modifier = modifier) + val isSmallButton = style is QuackSmallButtonStyle // TODO: 다른 경우로 사이즈를 지정하는 방법이 있을까? // TODO(3): LayoutModifierNode 지원 diff --git a/ui/src/main/kotlin/team/duckie/quackquack/ui/image.kt b/ui/src/main/kotlin/team/duckie/quackquack/ui/image.kt index df4260991..7d4287ee3 100644 --- a/ui/src/main/kotlin/team/duckie/quackquack/ui/image.kt +++ b/ui/src/main/kotlin/team/duckie/quackquack/ui/image.kt @@ -126,25 +126,29 @@ public fun QuackImage( contentScale: ContentScale = ContentScale.Fit, contentDescription: String? = null, ) { + val context = LocalContext.current + val localQuackPlugins = LocalQuackPlugins.current val currentColorFilter = remember(tint) { tint.toColorFilterOrNull() } val imageLoader = - LocalQuackPlugins.current.takeIf { it != EmptyQuackPlugins }?.let { plugins -> - val context = LocalContext.current - val quackPluginLocal = modifier.getElementByTypeOrNull() - val imageLoaderPlugins = plugins.lastByTypeOrNull() + remember(localQuackPlugins, src, modifier, contentDescription) { + localQuackPlugins.takeIf { it != EmptyQuackPlugins }?.let { plugins -> + val imageLoaderPlugins = plugins.lastByTypeOrNull() + ?: return@let null + val quackPluginLocal = modifier.getElementByTypeOrNull() - var builder = ImageLoader.Builder(context) - if (imageLoaderPlugins != null) { - with(imageLoaderPlugins) { - builder = builder.builder( - context = context, - src = src, - contentDescription = contentDescription, - quackPluginLocal = quackPluginLocal, - ) - } + val builder = + with(imageLoaderPlugins) { + ImageLoader + .Builder(context) + .quackBuild( + context = context, + src = src, + contentDescription = contentDescription, + quackPluginLocal = quackPluginLocal, + ) + } + builder.build() } - builder.build() } ?: @Suppress("DEPRECATION") LocalImageLoader.current AsyncImage( diff --git a/ui/src/main/kotlin/team/duckie/quackquack/ui/tag.kt b/ui/src/main/kotlin/team/duckie/quackquack/ui/tag.kt index a390c6b7a..62debea83 100644 --- a/ui/src/main/kotlin/team/duckie/quackquack/ui/tag.kt +++ b/ui/src/main/kotlin/team/duckie/quackquack/ui/tag.kt @@ -54,6 +54,7 @@ import team.duckie.quackquack.runtime.QuackDataModifierModel import team.duckie.quackquack.runtime.quackMaterializeOf import team.duckie.quackquack.sugar.material.NoSugar import team.duckie.quackquack.sugar.material.SugarToken +import team.duckie.quackquack.ui.plugin.interceptor.rememberInterceptedStyleSafely import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi import team.duckie.quackquack.ui.util.QuackDsl import team.duckie.quackquack.ui.util.asLoose @@ -552,6 +553,9 @@ public fun QuackTag( rippleEnabled: Boolean = true, @CasaValue("{}") onClick: () -> Unit, ) { + @Suppress("NAME_SHADOWING") + val style: QuackTagStyle = rememberInterceptedStyleSafely(style = style, modifier = modifier) + val isGrayscaleFlat = style is QuackGrayscaleFlatTagDefaults if (isGrayscaleFlat) { check(selected) { QuackTagErrors.GrayscaleFlatStyleUnselectedState } diff --git a/ui/src/main/kotlin/team/duckie/quackquack/ui/textfield.kt b/ui/src/main/kotlin/team/duckie/quackquack/ui/textfield.kt index 73b3b9e7d..7314f1bb5 100644 --- a/ui/src/main/kotlin/team/duckie/quackquack/ui/textfield.kt +++ b/ui/src/main/kotlin/team/duckie/quackquack/ui/textfield.kt @@ -87,6 +87,7 @@ import team.duckie.quackquack.runtime.quackMaterializeOf import team.duckie.quackquack.sugar.material.NoSugar import team.duckie.quackquack.sugar.material.SugarToken import team.duckie.quackquack.ui.optin.ExperimentalDesignToken +import team.duckie.quackquack.ui.plugin.interceptor.rememberInterceptedStyleSafely import team.duckie.quackquack.ui.token.HorizontalDirection import team.duckie.quackquack.ui.token.VerticalDirection import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi @@ -1048,6 +1049,11 @@ public fun