Skip to content

Commit 77def6a

Browse files
authored
Introduce QuackInterceptorPlugin (#782)
- Add `QuackInterceptorPlugin` that a plugin that allows to intercept and change certain conditions before a QuackQuack component is drawn. - Add `QuackTextFieldFontFamilyRemovalPlugin` plugin to temporarily resolve #761. - `CoilImageLoader.builder` renamed to `CoilImageLoader.quackBuild`.
1 parent 3e5e7f5 commit 77def6a

File tree

35 files changed

+735
-40
lines changed

35 files changed

+735
-40
lines changed

bom/build.gradle.kts

+4-9
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,12 @@ plugins {
1111
}
1212

1313
dependencies {
14-
val ignoreProjects = listOf(
15-
projects.bom.dependencyProject,
16-
projects.uiSample.dependencyProject,
17-
projects.utilBackendTest.dependencyProject,
18-
projects.catalog.dependencyProject,
19-
projects.materialIconGenerator.dependencyProject,
20-
)
21-
2214
constraints {
2315
rootProject.subprojects.forEach { project ->
24-
if (project !in ignoreProjects) {
16+
if (
17+
project != projects.bom.dependencyProject &&
18+
File(project.projectDir, "version.txt").exists()
19+
) {
2520
api(
2621
ArtifactConfig.of(project).toString()
2722
.also { artifact ->

settings.gradle.kts

+3
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ include(
3737
":util-backend-kotlinc",
3838
":util-backend-test",
3939
":util-compose-runtime-test",
40+
":util-compose-snapshot-test",
4041
":runtime",
4142
":material",
4243
":material-icon",
@@ -46,6 +47,8 @@ include(
4647
":ui-plugin",
4748
":ui-plugin:image",
4849
":ui-plugin:image:gif",
50+
":ui-plugin:interceptor",
51+
":ui-plugin:interceptor:textfield",
4952
":ui-sample",
5053
":sugar-material",
5154
":sugar-processor",

ui-plugin/image/gif/src/main/kotlin/team/duckie/quackquack/ui/plugin/image/gif/QuackImageGifPlugin.kt

+8-7
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,12 @@ import team.duckie.quackquack.ui.plugin.image.QuackImagePlugin
1818
* 내부적으로 [QuackImagePlugin.CoilImageLoader]를 사용합니다.
1919
*/
2020
@Stable
21-
public val QuackImageGifPlugin: QuackImagePlugin.CoilImageLoader = QuackImagePlugin.CoilImageLoader { _, _, _, _ ->
22-
components {
23-
add(
24-
if (Build.VERSION.SDK_INT >= 28) ImageDecoderDecoder.Factory()
25-
else GifDecoder.Factory(),
26-
)
21+
public val QuackImageGifPlugin: QuackImagePlugin.CoilImageLoader =
22+
QuackImagePlugin.CoilImageLoader { _, _, _, _ ->
23+
components {
24+
add(
25+
if (Build.VERSION.SDK_INT >= 28) ImageDecoderDecoder.Factory()
26+
else GifDecoder.Factory(),
27+
)
28+
}
2729
}
28-
}

ui-plugin/image/gif/src/test/kotlin/team/duckie/quackquack/ui/plugin/image/gif/QuackImageGifPluginTest.kt ui-plugin/image/gif/src/test/kotlin/team/duckie/quackquack/ui/plugin/image/gif/QuackImageGifPluginSnapshot.kt

+1-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import team.duckie.quackquack.ui.plugin.rememberQuackPlugins
2424

2525
@Ignore("GIF 녹화 안됨")
2626
@RunWith(AndroidJUnit4::class)
27-
class QuackImageGifPluginTest {
27+
class QuackImageGifPluginSnapshot {
2828
@get:Rule
2929
val compose = createAndroidComposeRule<ComponentActivity>()
3030

ui-plugin/image/src/main/kotlin/team/duckie/quackquack/ui/plugin/image/QuackImagePlugin.kt

+2-1
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,9 @@ public sealed interface QuackImagePlugin : QuackPlugin {
3131
* @param contentDescription `QuackImage`의 `contentDescription` 인자로 제공된 값
3232
* @param quackPluginLocal `Modifier.quackPluginLocal`로 제공된 값
3333
*/
34+
// compose-ui 의존성 없어서 Modifier.quackPluginLocal에 링크를 적용하지 않음
3435
@Stable
35-
public fun ImageLoader.Builder.builder(
36+
public fun ImageLoader.Builder.quackBuild(
3637
context: Context,
3738
src: Any?,
3839
contentDescription: String?,

ui-plugin/image/src/test/kotlin/team/duckie/quackquack/ui/plugin/image/QuackImagePluginTest.kt

+1-1
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ private fun imageResultOf(drawable: Drawable, request: ImageRequest) =
9797
private class QuackImageCoilBuilderIntercepter(
9898
private val map: MutableMap<String, Any?> = mutableMapOf(),
9999
) : QuackImagePlugin.CoilImageLoader {
100-
override fun ImageLoader.Builder.builder(
100+
override fun ImageLoader.Builder.quackBuild(
101101
context: Context,
102102
src: Any?,
103103
contentDescription: String?,
+55
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/*
2+
* Designed and developed by Duckie Team 2023.
3+
*
4+
* Licensed under the MIT.
5+
* Please see full license: https://github.com/duckie-team/quack-quack-android/blob/main/LICENSE
6+
*/
7+
8+
@file:Suppress("UnstableApiUsage")
9+
10+
import org.jetbrains.dokka.gradle.DokkaMultiModuleTask
11+
12+
plugins {
13+
quackquack("android-library")
14+
quackquack("android-compose")
15+
quackquack("kotlin-explicit-api")
16+
quackquack("quack-publishing")
17+
alias(libs.plugins.test.roborazzi)
18+
}
19+
20+
tasks.withType<DokkaMultiModuleTask> {
21+
dependsOn(":ui-plugin:dokkaHtmlMultiModule")
22+
}
23+
24+
android {
25+
namespace = "team.duckie.quackquack.ui.plugin.interceptor"
26+
27+
testOptions {
28+
unitTests {
29+
isIncludeAndroidResources = true
30+
isReturnDefaultValues = true
31+
all { test ->
32+
test.systemProperty("robolectric.graphicsMode", "NATIVE")
33+
}
34+
}
35+
}
36+
}
37+
38+
dependencies {
39+
api(projects.uiPlugin.orArtifact())
40+
implementations(
41+
libs.compose.runtime,
42+
libs.compose.ui.core,
43+
projects.material,
44+
projects.utilModifier,
45+
)
46+
testImplementations(
47+
libs.test.robolectric,
48+
libs.test.junit.compose,
49+
libs.test.kotest.assertion.core,
50+
libs.test.kotlin.coroutines, // needed for compose-ui-test
51+
libs.bundles.test.roborazzi,
52+
projects.ui,
53+
projects.utilComposeSnapshotTest,
54+
)
55+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?xml version="1.0" encoding="utf-8"?><!--
2+
~ Designed and developed by Duckie Team 2023.
3+
~
4+
~ Licensed under the MIT.
5+
~ Please see full license: https://github.com/duckie-team/quack-quack-android/blob/main/LICENSE
6+
-->
7+
8+
<manifest />
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/*
2+
* Designed and developed by Duckie Team 2023.
3+
*
4+
* Licensed under the MIT.
5+
* Please see full license: https://github.com/duckie-team/quack-quack-android/blob/main/LICENSE
6+
*/
7+
8+
package team.duckie.quackquack.ui.plugin.interceptor
9+
10+
import androidx.compose.runtime.Immutable
11+
import androidx.compose.runtime.Stable
12+
import androidx.compose.ui.Modifier
13+
import team.duckie.quackquack.ui.plugin.QuackPlugin
14+
import team.duckie.quackquack.ui.plugin.QuackPluginLocal
15+
import team.duckie.quackquack.ui.plugin.quackPluginLocal
16+
17+
/** 꽥꽥 컴포넌트가 그려지기 전에 특정 조건을 가로채서 변경할 수 있는 플러그인입니다. */
18+
@Immutable
19+
public sealed interface QuackInterceptorPlugin : QuackPlugin {
20+
/** 컴포넌트의 디자인 토큰을 가로채서 변경합니다. */
21+
@Immutable
22+
public fun interface DesignToken : QuackInterceptorPlugin {
23+
/**
24+
* 컴포넌트의 디자인 토큰을 가로채서 변경한 새로운 디자인 토큰을 반환합니다.
25+
*
26+
* @param componentName 플러그인이 적용될 컴포넌트의 이름
27+
* @param componentDesignToken 플러그인이 적용되고 있는 컴포넌트에 적용된 디자인 토큰.
28+
* 이 값은 이 플러그인이 적용되기 전에 미리 갖고 있던 디자인 토큰을 나타냅니다.
29+
* @param componentModifier 플러그인이 적용되고 있는 컴포넌트가 사용하고 있는 [Modifier]
30+
* @param quackPluginLocal [Modifier.quackPluginLocal]로 제공된 값
31+
*/
32+
@Stable
33+
public fun intercept(
34+
componentName: String,
35+
componentDesignToken: Any,
36+
componentModifier: Modifier,
37+
quackPluginLocal: QuackPluginLocal?,
38+
): Any
39+
}
40+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/*
2+
* Designed and developed by Duckie Team 2023.
3+
*
4+
* Licensed under the MIT.
5+
* Please see full license: https://github.com/duckie-team/quack-quack-android/blob/main/LICENSE
6+
*/
7+
8+
package team.duckie.quackquack.ui.plugin.interceptor
9+
10+
import androidx.annotation.VisibleForTesting
11+
import androidx.compose.runtime.Composable
12+
import androidx.compose.runtime.remember
13+
import androidx.compose.ui.Modifier
14+
import team.duckie.quackquack.ui.plugin.EmptyQuackPlugins
15+
import team.duckie.quackquack.ui.plugin.LocalQuackPlugins
16+
import team.duckie.quackquack.ui.plugin.QuackPluginLocal
17+
import team.duckie.quackquack.ui.plugin.lastByTypeOrNull
18+
import team.duckie.quackquack.util.modifier.getElementByTypeOrNull
19+
20+
/** 현재 실행 중인 call-site의 메서드 이름을 반환합니다. */
21+
@PublishedApi
22+
internal val currentMethodName: String
23+
// https://stackoverflow.com/a/32329165/14299073
24+
inline get() = Thread.currentThread().stackTrace[1].methodName
25+
26+
/** intercept된 스타일이 기존에 제공된 스타일과 다른 타입일 때 throw할 에러 메시지 */
27+
@PublishedApi
28+
@VisibleForTesting
29+
internal const val InterceptedStyleTypeExceptionMessage: String =
30+
"The intercepted style is of a different type than the original style."
31+
32+
/**
33+
* 주어진 [디자인 토큰][style]에 [QuackInterceptorPlugin.DesignToken] 플러그인을 적용한 후,
34+
* 새로운 [디자인 토큰][Style]을 반환합니다.
35+
*
36+
* 만약 [기존 디자인 토큰][style]과 새로운 디자인 토큰의 타입이 다르다면 [IllegalStateException]이
37+
* 발생합니다. 새로운 디자인 토큰의 타입은 [Style]과 일치할 것이라 가정합니다.
38+
*
39+
* @param Style intercept의 결과로 생성될 디자인 토큰의 타입
40+
* @param style 기존에 적용된 디자인 토큰
41+
* @param modifier 현재 컴포넌트에 적용된 [Modifier]
42+
*/
43+
@Composable
44+
public inline fun <reified Style> rememberInterceptedStyleSafely(style: Any, modifier: Modifier): Style {
45+
val localQuackPlugins = LocalQuackPlugins.current
46+
47+
return remember(localQuackPlugins, style, modifier) {
48+
localQuackPlugins.takeIf { it != EmptyQuackPlugins }?.let { plugins ->
49+
val interceptorPlugin = plugins.lastByTypeOrNull<QuackInterceptorPlugin.DesignToken>()
50+
?: return@let null
51+
val quackPluginLocal = modifier.getElementByTypeOrNull<QuackPluginLocal>()
52+
53+
interceptorPlugin
54+
.intercept(
55+
componentName = currentMethodName,
56+
componentDesignToken = style,
57+
componentModifier = modifier,
58+
quackPluginLocal = quackPluginLocal,
59+
)
60+
} ?: style
61+
}.also { interceptedStyle ->
62+
check(interceptedStyle is Style, lazyMessage = ::InterceptedStyleTypeExceptionMessage)
63+
} as Style
64+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/*
2+
* Designed and developed by Duckie Team 2023.
3+
*
4+
* Licensed under the MIT.
5+
* Please see full license: https://github.com/duckie-team/quack-quack-android/blob/main/LICENSE
6+
*/
7+
8+
@file:OptIn(ExperimentalQuackQuackApi::class)
9+
10+
package team.duckie.quackquack.ui.plugin.interceptor
11+
12+
import com.github.takahirom.roborazzi.RoborazziRule.Ignore as NoSnapshot
13+
import androidx.activity.ComponentActivity
14+
import androidx.compose.ui.Modifier
15+
import androidx.compose.ui.test.junit4.createAndroidComposeRule
16+
import androidx.compose.ui.test.onRoot
17+
import androidx.compose.ui.unit.dp
18+
import androidx.test.ext.junit.runners.AndroidJUnit4
19+
import com.github.takahirom.roborazzi.RoborazziRule
20+
import io.kotest.assertions.throwables.shouldThrowWithMessage
21+
import io.kotest.matchers.maps.shouldMatchExactly
22+
import io.kotest.matchers.nulls.shouldNotBeNull
23+
import io.kotest.matchers.shouldBe
24+
import org.junit.Rule
25+
import org.junit.Test
26+
import org.junit.runner.RunWith
27+
import team.duckie.quackquack.material.QuackColor
28+
import team.duckie.quackquack.material.theme.QuackTheme
29+
import team.duckie.quackquack.ui.QuackTag
30+
import team.duckie.quackquack.ui.QuackTagStyle
31+
import team.duckie.quackquack.ui.TagStyleMarker
32+
import team.duckie.quackquack.ui.plugin.rememberQuackPlugins
33+
import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi
34+
import team.duckie.quackquack.util.compose.snapshot.test.SnapshotName
35+
import team.duckie.quackquack.util.compose.snapshot.test.snapshotPath
36+
37+
@RunWith(AndroidJUnit4::class)
38+
class QuackInterceptorPluginTest {
39+
@get:Rule
40+
val compose = createAndroidComposeRule<ComponentActivity>()
41+
42+
@get:Rule
43+
val roborazzi = RoborazziRule(
44+
composeRule = compose,
45+
captureRoot = compose.onRoot(),
46+
options = RoborazziRule.Options(
47+
outputFileProvider = { description, _, fileExtension ->
48+
val snapshotName = description.getAnnotation(SnapshotName::class.java)?.name ?: description.methodName
49+
snapshotPath(
50+
domain = "QuackInterceptorPlugin",
51+
snapshotName = snapshotName,
52+
isGif = fileExtension == "gif",
53+
)
54+
},
55+
),
56+
)
57+
58+
@SnapshotName("QuackTagRadiusStyleIntercepted")
59+
@Test
60+
fun `style intercept works fine`() {
61+
val map = mutableMapOf<String, Any?>()
62+
63+
var interceptedStyle: QuackTagStyle<TagStyleMarker>? = null
64+
val interceptedRadius = Int.MAX_VALUE.dp
65+
66+
compose.setContent {
67+
QuackTheme(
68+
plugins = rememberQuackPlugins {
69+
+QuackInterceptorPlugin.DesignToken { componentName, componentDesignToken, componentModifier, _ ->
70+
map["componentName"] = componentName
71+
map["componentDesignToken"] = componentDesignToken
72+
map["componentModifier"] = componentModifier
73+
74+
(if (componentName == "QuackTag") {
75+
@Suppress("UNCHECKED_CAST")
76+
componentDesignToken as QuackTagStyle<TagStyleMarker>
77+
object : QuackTagStyle<TagStyleMarker> by componentDesignToken {
78+
override val radius = interceptedRadius
79+
override val colors =
80+
componentDesignToken.colors.copy(
81+
backgroundColor = QuackColor.Gray3,
82+
contentColor = QuackColor.Black,
83+
)
84+
}
85+
} else {
86+
componentDesignToken
87+
}).also { intercepttedResult ->
88+
@Suppress("UNCHECKED_CAST")
89+
interceptedStyle = intercepttedResult as QuackTagStyle<TagStyleMarker>
90+
}
91+
}
92+
},
93+
) {
94+
QuackTag(
95+
text = "Intercepted Tag",
96+
style = QuackTagStyle.Filled,
97+
onClick = {},
98+
)
99+
}
100+
}
101+
102+
map.shouldMatchExactly(
103+
"componentName" to { it shouldBe "QuackTag" },
104+
"componentDesignToken" to { it.toString() shouldBe QuackTagStyle.Filled.toString() },
105+
"componentModifier" to { it shouldBe Modifier },
106+
)
107+
interceptedStyle.shouldNotBeNull().radius shouldBe interceptedRadius
108+
}
109+
110+
@NoSnapshot
111+
@Test
112+
fun InterceptedStyleTypeExceptionMessage() {
113+
shouldThrowWithMessage<IllegalStateException>(InterceptedStyleTypeExceptionMessage) {
114+
compose.setContent {
115+
QuackTheme(
116+
plugins = rememberQuackPlugins {
117+
+QuackInterceptorPlugin.DesignToken { _, _, _, _ -> Unit }
118+
},
119+
) {
120+
QuackTag(
121+
text = "",
122+
style = QuackTagStyle.Filled,
123+
onClick = {},
124+
)
125+
}
126+
}
127+
}
128+
}
129+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
#
2+
# Designed and developed by Duckie Team 2023.
3+
#
4+
# Licensed under the MIT.
5+
# Please see full license: https://github.com/duckie-team/quack-quack-android/blob/main/LICENSE
6+
#
7+
8+
sdk=33

0 commit comments

Comments
 (0)