diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml index ccaca45af..300f26e5f 100644 --- a/.github/workflows/android-ci.yml +++ b/.github/workflows/android-ci.yml @@ -46,6 +46,9 @@ jobs: - name: Run test run: ./gradlew test --stacktrace + - name: Verify components snapshot + run: ./gradlew :ui:verifyRoborazziDebug + - name: Run ui test on ui module via gmd if: contains(github.event.pull_request.labels.*.name, 'ui') run: ./gradlew :ui:pixel4api30aospatdDebugAndroidTest -Pandroid.testoptions.manageddevices.emulator.gpu="swiftshader_indirect" diff --git a/aide/src/main/kotlin/team/duckie/quackquack/aide/rule/AideModifiers.kt b/aide/src/main/kotlin/team/duckie/quackquack/aide/rule/AideModifiers.kt index 1e1299951..fe730496b 100644 --- a/aide/src/main/kotlin/team/duckie/quackquack/aide/rule/AideModifiers.kt +++ b/aide/src/main/kotlin/team/duckie/quackquack/aide/rule/AideModifiers.kt @@ -18,6 +18,9 @@ internal val aideModifiers: Map> = run { aide["button"] = listOf("icons") aide["_icons"] = emptyList() + aide["tag"] = listOf("trailingIcon") + aide["_trailingIcon"] = emptyList() + aide["text"] = listOf("span", "highlight") aide["_span"] = emptyList() aide["_highlight"] = emptyList() diff --git a/aide/src/main/kotlin/team/duckie/quackquack/aide/rule/QuackComponents.kt b/aide/src/main/kotlin/team/duckie/quackquack/aide/rule/QuackComponents.kt index ef952e398..66ad662bc 100644 --- a/aide/src/main/kotlin/team/duckie/quackquack/aide/rule/QuackComponents.kt +++ b/aide/src/main/kotlin/team/duckie/quackquack/aide/rule/QuackComponents.kt @@ -25,6 +25,13 @@ internal val quackComponents: Map = run { aide["QuackSecondarySmallButton"] = "button" aide["QuackSecondaryRoundSmallButton"] = "button" + aide["QuackOutlinedTag"] = "tag" + aide["QuackFilledTag"] = "tag" + aide["QuackGrayscaleFlatTag"] = "tag" + aide["QuackGrayscaleOutlinedTag"] = "tag" + aide["QuackTag"] = "tag" + aide["QuackBaseTag"] = "tag" + aide["QuackBody1"] = "text" aide["QuackBody2"] = "text" aide["QuackBody3"] = "text" diff --git a/bom/version.txt b/bom/version.txt index 3bf51efdd..16ba04bea 100644 --- a/bom/version.txt +++ b/bom/version.txt @@ -1 +1 @@ -2.0.0-alpha02 +2023.05.17 diff --git a/catalog/src/main/kotlin/team/duckie/quackquack/catalog/CasaModels.kt b/catalog/src/main/kotlin/team/duckie/quackquack/catalog/CasaModels.kt new file mode 100644 index 000000000..6b624918b --- /dev/null +++ b/catalog/src/main/kotlin/team/duckie/quackquack/catalog/CasaModels.kt @@ -0,0 +1,154 @@ +// This file was automatically generated by casa-processor. +// Do not modify it manually. +// @formatter:off +@file:Suppress("NoConsecutiveBlankLines", "PackageDirectoryMismatch", "Wrapping", + "TrailingCommaOnCallSite", "ArgumentListWrapping", "RedundantVisibilityModifier", + "UnusedImport", "NoUnusedImports", "SpacingAroundParens", "Indentation", "NoUnitReturn", + "RedundantUnitReturnType", "ModifierParameter", "KDocUnresolvedReference", "NoTrailingSpaces", + "NoMultipleSpaces", "ktlint") +@file:OptIn(ExperimentalQuackQuackApi::class) + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import kotlin.Boolean +import kotlin.Function0 +import kotlin.String +import kotlin.Suppress +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import team.duckie.quackquack.casa.material.CasaModel +import team.duckie.quackquack.ui.sugar.QuackBody1 +import team.duckie.quackquack.ui.sugar.QuackBody2 +import team.duckie.quackquack.ui.sugar.QuackBody3 +import team.duckie.quackquack.ui.sugar.QuackFilledTag +import team.duckie.quackquack.ui.sugar.QuackGrayscaleFlatTag +import team.duckie.quackquack.ui.sugar.QuackGrayscaleOutlinedTag +import team.duckie.quackquack.ui.sugar.QuackHeadLine1 +import team.duckie.quackquack.ui.sugar.QuackHeadLine2 +import team.duckie.quackquack.ui.sugar.QuackLarge1 +import team.duckie.quackquack.ui.sugar.QuackMediumButton +import team.duckie.quackquack.ui.sugar.QuackOutlinedTag +import team.duckie.quackquack.ui.sugar.QuackPrimaryFilledSmallButton +import team.duckie.quackquack.ui.sugar.QuackPrimaryLargeButton +import team.duckie.quackquack.ui.sugar.QuackPrimaryOutlinedRoundSmallButton +import team.duckie.quackquack.ui.sugar.QuackPrimaryOutlinedSmallButton +import team.duckie.quackquack.ui.sugar.QuackSecondaryLargeButton +import team.duckie.quackquack.ui.sugar.QuackSecondaryRoundSmallButton +import team.duckie.quackquack.ui.sugar.QuackSecondarySmallButton +import team.duckie.quackquack.ui.sugar.QuackSubtitle +import team.duckie.quackquack.ui.sugar.QuackSubtitle2 +import team.duckie.quackquack.ui.sugar.QuackTitle1 +import team.duckie.quackquack.ui.sugar.QuackTitle2 +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi + +private val buttonQuackButtonCasaModel: CasaModel = CasaModel( + name = "QuackButton", + domain = "button", + kdocDefaultSection = "버튼을 그립니다.", + components = persistentListOf Unit>>( + "QuackPrimaryLargeButton" to { QuackPrimaryLargeButton( + text = "QuackButton is experimental", + onClick = {}, + ) }, + "QuackSecondaryLargeButton" to { QuackSecondaryLargeButton( + text = "QuackButton is experimental", + onClick = {}, + ) }, + "QuackMediumButton" to { QuackMediumButton( + text = "QuackButton is experimental", + onClick = {}, + ) }, + "QuackPrimaryFilledSmallButton" to { QuackPrimaryFilledSmallButton( + text = "QuackButton is experimental", + onClick = {}, + ) }, + "QuackPrimaryOutlinedSmallButton" to { QuackPrimaryOutlinedSmallButton( + text = "QuackButton is experimental", + onClick = {}, + ) }, + "QuackPrimaryOutlinedRoundSmallButton" to { QuackPrimaryOutlinedRoundSmallButton( + text = "QuackButton is experimental", + onClick = {}, + ) }, + "QuackSecondarySmallButton" to { QuackSecondarySmallButton( + text = "QuackButton is experimental", + onClick = {}, + ) }, + "QuackSecondaryRoundSmallButton" to { QuackSecondaryRoundSmallButton( + text = "QuackButton is experimental", + onClick = {}, + ) }, + ).toImmutableList(), +) + + +private val tagQuackTagCasaModel: CasaModel = CasaModel( + name = "QuackTag", + domain = "tag", + kdocDefaultSection = "태그를 그립니다.", + components = persistentListOf Unit>>( + "QuackOutlinedTag" to { QuackOutlinedTag( + text = "QuackTagPreview", + onClick = {}, + ) }, + "QuackFilledTag" to { QuackFilledTag( + text = "QuackTagPreview", + onClick = {}, + ) }, + "QuackGrayscaleFlatTag" to { QuackGrayscaleFlatTag( + text = "QuackTagPreview", + onClick = {}, + ) }, + "QuackGrayscaleOutlinedTag" to { QuackGrayscaleOutlinedTag( + text = "QuackTagPreview", + onClick = {}, + ) }, + ).toImmutableList(), +) + +private val textQuackTextCasaModel: CasaModel = CasaModel( + name = "QuackText", + domain = "text", + kdocDefaultSection = "텍스트를 그립니다.", + components = persistentListOf Unit>>( + "QuackBody1" to { QuackBody1( + text = "QuackText", + ) }, + "QuackBody2" to { QuackBody2( + text = "QuackText", + ) }, + "QuackBody3" to { QuackBody3( + text = "QuackText", + ) }, + "QuackHeadLine1" to { QuackHeadLine1( + text = "QuackText", + ) }, + "QuackHeadLine2" to { QuackHeadLine2( + text = "QuackText", + ) }, + "QuackLarge1" to { QuackLarge1( + text = "QuackText", + ) }, + "QuackSubtitle" to { QuackSubtitle( + text = "QuackText", + ) }, + "QuackSubtitle2" to { QuackSubtitle2( + text = "QuackText", + ) }, + "QuackTitle1" to { QuackTitle1( + text = "QuackText", + ) }, + "QuackTitle2" to { QuackTitle2( + text = "QuackText", + ) }, + ).toImmutableList(), +) + +public val casaModels: ImmutableList = persistentListOf( + buttonQuackButtonCasaModel, + tagQuackTagCasaModel, + textQuackTextCasaModel, +) + diff --git a/catalog/src/main/kotlin/team/duckie/quackquack/catalog/MainActivity.kt b/catalog/src/main/kotlin/team/duckie/quackquack/catalog/MainActivity.kt index 8b6b8f4b7..3caad6cec 100644 --- a/catalog/src/main/kotlin/team/duckie/quackquack/catalog/MainActivity.kt +++ b/catalog/src/main/kotlin/team/duckie/quackquack/catalog/MainActivity.kt @@ -5,6 +5,9 @@ * Please see full license: https://github.com/duckie-team/quack-quack-android/blob/master/LICENSE */ +@file:OptIn(ExperimentalQuackQuackApi::class) +@file:Suppress("UnnecessaryOptInAnnotation") + package team.duckie.quackquack.catalog import android.os.Bundle @@ -18,43 +21,31 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import casaModels +import team.duckie.quackquack.casa.ui.CasaScreen import team.duckie.quackquack.casa.ui.theme.CasaTheme +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { CasaTheme { - // CasaScreen(models = casaModels) + CasaScreen(models = casaModels) } /*Preview { - var showIcons by remember { mutableStateOf(true) } - var enabled by remember { mutableStateOf(true) } - QuackButton( - modifier = Modifier.then(Modifier).applyIf(showIcons) { - icons( - leadingIcon = QuackIcon.Heart, - trailingIcon = QuackIcon.Heart, - ) - }, - enabled = enabled, - style = QuackButtonStyle.Large, - text = "Hello, World!", - ) { - toast("Hello, World!") - } - QuackButton( - style = QuackButtonStyle.Medium, - text = "enabled state: $enabled", - ) { - enabled = !enabled - } - QuackButton( - style = QuackButtonStyle.Small, - text = "showIcons state: $showIcons", - ) { - showIcons = !showIcons - } + // var showTrailingIcon by remember { mutableStateOf(true) } + var selected by remember { mutableStateOf(true) } + QuackTag( + modifier = Modifier + .then(Modifier) + .trailingIcon(QuackIcon.Heart) { toast("HI: $selected") }, + text = "QuackTagPreview", + style = QuackTagStyle.Filled, + selected = selected, + ) { + selected = !selected + } }*/ } } diff --git a/ui/src/main/kotlin/team/duckie/quackquack/ui/sugar/tag.kt b/ui/src/main/kotlin/team/duckie/quackquack/ui/sugar/tag.kt new file mode 100644 index 000000000..71f02684d --- /dev/null +++ b/ui/src/main/kotlin/team/duckie/quackquack/ui/sugar/tag.kt @@ -0,0 +1,368 @@ +// This file was automatically generated by sugar-processor. +// Do not modify it manually. +// @formatter:off +@file:Suppress("NoConsecutiveBlankLines", "PackageDirectoryMismatch", "Wrapping", + "TrailingCommaOnCallSite", "ArgumentListWrapping", "RedundantVisibilityModifier", + "UnusedImport", "NoUnusedImports", "SpacingAroundParens", "Indentation", "NoUnitReturn", + "RedundantUnitReturnType", "ModifierParameter", "KDocUnresolvedReference", "NoTrailingSpaces", + "NoMultipleSpaces", "ktlint") +@file:OptIn(SugarCompilerApi::class, SugarGeneratorUsage::class) +@file:SugarGeneratedFile + +package team.duckie.quackquack.ui.sugar + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.NonRestartableComposable +import androidx.compose.ui.Modifier +import kotlin.Boolean +import kotlin.Function0 +import kotlin.OptIn +import kotlin.String +import kotlin.Suppress +import kotlin.Unit +import team.duckie.quackquack.casa.`annotation`.Casa +import team.duckie.quackquack.casa.`annotation`.CasaValue +import team.duckie.quackquack.casa.`annotation`.SugarGeneratorUsage +import team.duckie.quackquack.sugar.material.SugarCompilerApi +import team.duckie.quackquack.sugar.material.SugarGeneratedFile +import team.duckie.quackquack.sugar.material.SugarRefer +import team.duckie.quackquack.sugar.material.sugar +import team.duckie.quackquack.ui.QuackTag +import team.duckie.quackquack.ui.QuackTagStyle +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi + +/** + * 태그를 그립니다. + * + * - 이 컴포넌트는 자체의 패딩 정책을 구현합니다. + * - [스타일][style]별로 사용 가능한 데코레이터가 달라집니다. + * + * ### 패딩 정책 + * + * 1. [태그의 스타일][QuackTagStyle]에서 [contentPadding][QuackTagStyle.contentPadding] 옵션을 + * 별도로 제공하고 있습니다. 이는 [Modifier.padding]과 다른 패딩 정책을 사용합니다. [Modifier.padding]은 + * 태그의 루트 레이아웃을 기준으로 패딩이 적용되지만, [QuackTagStyle.contentPadding]은 태그의 + * 텍스트와 후행 아이콘을 기준으로 적용됩니다. 태그 컴포넌트는 [trailingIcon][Modifier.trailingIcon] 데코레이터로 + * 후행 아이콘을 추가할 수 있고, 후행 아이콘 여부에 따라 패딩 정책이 결정됩니다. 후행 아이콘이 있다면 세로와 + * 가로에 따라 패딩을 적용하는 방식이 달라집니다. 세로의 경우는 태그 텍스트를 기준으로 적용되고, 가로의 경우는 + * 후행 아이콘의 터치 영역을 증가시키는 식으로 적용됩니다. 기본적으로 후행 아이콘은 16px의 사이즈를 갖습니다. + * 유저 입장에서 16px의 터치 영역은 좋은 경험을 제공하지 못할 것으로 예상하여, [전체 가로 패딩][QuackPadding.vertical]의 + * 오른쪽 영역을 후행 아이콘의 오른쪽 패딩으로 적용합니다. 이때, [전체 가로 패딩][QuackPadding.vertical]의 오른쪽 + * 영역을 그대로 적용하는 게 아니라 해당 값에서 [텍스트와 후행 아이콘 사이 공간][QuackTagStyle.iconSpacedBy]을 뺀 + * 값을 적용합니다. 이는 디자인 가이드라인에 의거합니다. 그리고 [텍스트와 후행 아이콘 사이 공간][QuackTagStyle.iconSpacedBy]의 + * 반을 후행 아이콘의 왼쪽 패딩으로 적용합니다. [텍스트와 후행 아이콘 사이 공간][QuackTagStyle.iconSpacedBy] 반의 + * 나머지 부분은 태그 텍스트의 오른쪽 패딩으로 적용됩니다. 후행 아이콘이 없다면 단순히 태그 텍스트를 기준으로 패딩이 + * 적용됩니다. + * 2. [LayoutModifier]를 사용하여 컴포넌트의 사이즈가 명시됐다면 [QuackTagStyle.contentPadding] + * 옵션은 무시됩니다. [contentPadding][QuackTagStyle.contentPadding]은 컴포넌트 사이즈 하드코딩을 + * 대체하는 용도로 제공됩니다. 하지만 컴포넌트 사이즈가 하드코딩됐다면 [contentPadding][QuackTagStyle.contentPadding]을 + * 제공하는 의미가 없어집니다. 따라서 컴포넌트의 사이즈가 하드코딩됐다면 개발자의 의도를 존중한다는 원칙하에 + * 컴포넌트의 사이즈가 중첩으로 확장되는 일을 예방하고자 [contentPadding][QuackTagStyle.contentPadding] + * 옵션을 무시합니다. 예를 들어 `Modifier.height(10.dp)`로 컴포넌트 높이를 명시했고, + * [contentPadding][QuackTagStyle.contentPadding]으로 + * `QuackPadding(vertical=10.dp)`을 제공했다고 해봅시다. 이런 경우에는 + * [contentPadding][QuackTagStyle.contentPadding]이 + * 무시되고 태그의 높이가 10dp로 적용됩니다. 컴포넌트 사이즈를 명시하면서 패딩을 적용하고 싶다면 + * [contentPadding][QuackTagStyle.contentPadding] 대신에 [Modifier.padding]을 사용하세요. + * [LayoutModifier]를 사용하는 흔한 [Modifier]로는 [Modifier.size], [Modifier.height], [Modifier.width] 등이 + * 있습니다. [LayoutModifierNode]를 사용하는 [Modifier]는 [contentPadding][QuackTagStyle.contentPadding] 무시 + * 옵션이 아직 지원되지 않습니다. ([#636](https://github.com/duckie-team/quack-quack-android/issues/636)) + * + * ### 사용 가능 데코레이터 + * + * | style | [trailingIcon][Modifier.trailingIcon] + * | description | + * |:------------------------------------------------------:|:-------------------------------------:|:----------------------------------:| + * | [Outlined][QuackOutlinedTagDefaults] | ⭕ + * | | + * | [Filled][QuackFilledTagDefaults] | ⭕ + * | | + * | [GrayscaleFlat][QuackGrayscaleFlatTagDefaults] | ❌ + * | 태그의 너비가 좁기에 아이콘 데코레이터를 사용할 수 없습니다. | + * | [GrayscaleOutlined][QuackGrayscaleOutlinedTagDefaults] | ⭕ + * | | + * + * This component uses [QuackTagStyle.Outlined] as the token value for `style`. + * + * This document was automatically generated by [QuackTag]. + * If any contents are broken, please check the original document. + * + * @param text 중앙에 표시할 텍스트 + * @param selected 선택 상태 여부 + * @param rippleEnabled 클릭했을 때 리플 애니메이션을 적용할지 여부 + * @param onClick 클릭했을 때 실행할 람다식. 태그는 토글이 자유로워야 하므로 [selected]와 관계 없이 + * 항상 클릭 가능합니다. + */ +@Casa +@Composable +@NonRestartableComposable +@ExperimentalQuackQuackApi +@SugarRefer("team.duckie.quackquack.ui.QuackTag") +public fun QuackOutlinedTag( + @CasaValue("\"QuackTagPreview\"") text: String, + modifier: Modifier = sugar(), + selected: Boolean = sugar(), + rippleEnabled: Boolean = sugar(), + @CasaValue("{}") onClick: () -> Unit, +): Unit { + QuackTag( + text = text, + style = QuackTagStyle.Outlined, + modifier = modifier, + selected = selected, + rippleEnabled = rippleEnabled, + onClick = onClick, + ) +} + +/** + * 태그를 그립니다. + * + * - 이 컴포넌트는 자체의 패딩 정책을 구현합니다. + * - [스타일][style]별로 사용 가능한 데코레이터가 달라집니다. + * + * ### 패딩 정책 + * + * 1. [태그의 스타일][QuackTagStyle]에서 [contentPadding][QuackTagStyle.contentPadding] 옵션을 + * 별도로 제공하고 있습니다. 이는 [Modifier.padding]과 다른 패딩 정책을 사용합니다. [Modifier.padding]은 + * 태그의 루트 레이아웃을 기준으로 패딩이 적용되지만, [QuackTagStyle.contentPadding]은 태그의 + * 텍스트와 후행 아이콘을 기준으로 적용됩니다. 태그 컴포넌트는 [trailingIcon][Modifier.trailingIcon] 데코레이터로 + * 후행 아이콘을 추가할 수 있고, 후행 아이콘 여부에 따라 패딩 정책이 결정됩니다. 후행 아이콘이 있다면 세로와 + * 가로에 따라 패딩을 적용하는 방식이 달라집니다. 세로의 경우는 태그 텍스트를 기준으로 적용되고, 가로의 경우는 + * 후행 아이콘의 터치 영역을 증가시키는 식으로 적용됩니다. 기본적으로 후행 아이콘은 16px의 사이즈를 갖습니다. + * 유저 입장에서 16px의 터치 영역은 좋은 경험을 제공하지 못할 것으로 예상하여, [전체 가로 패딩][QuackPadding.vertical]의 + * 오른쪽 영역을 후행 아이콘의 오른쪽 패딩으로 적용합니다. 이때, [전체 가로 패딩][QuackPadding.vertical]의 오른쪽 + * 영역을 그대로 적용하는 게 아니라 해당 값에서 [텍스트와 후행 아이콘 사이 공간][QuackTagStyle.iconSpacedBy]을 뺀 + * 값을 적용합니다. 이는 디자인 가이드라인에 의거합니다. 그리고 [텍스트와 후행 아이콘 사이 공간][QuackTagStyle.iconSpacedBy]의 + * 반을 후행 아이콘의 왼쪽 패딩으로 적용합니다. [텍스트와 후행 아이콘 사이 공간][QuackTagStyle.iconSpacedBy] 반의 + * 나머지 부분은 태그 텍스트의 오른쪽 패딩으로 적용됩니다. 후행 아이콘이 없다면 단순히 태그 텍스트를 기준으로 패딩이 + * 적용됩니다. + * 2. [LayoutModifier]를 사용하여 컴포넌트의 사이즈가 명시됐다면 [QuackTagStyle.contentPadding] + * 옵션은 무시됩니다. [contentPadding][QuackTagStyle.contentPadding]은 컴포넌트 사이즈 하드코딩을 + * 대체하는 용도로 제공됩니다. 하지만 컴포넌트 사이즈가 하드코딩됐다면 [contentPadding][QuackTagStyle.contentPadding]을 + * 제공하는 의미가 없어집니다. 따라서 컴포넌트의 사이즈가 하드코딩됐다면 개발자의 의도를 존중한다는 원칙하에 + * 컴포넌트의 사이즈가 중첩으로 확장되는 일을 예방하고자 [contentPadding][QuackTagStyle.contentPadding] + * 옵션을 무시합니다. 예를 들어 `Modifier.height(10.dp)`로 컴포넌트 높이를 명시했고, + * [contentPadding][QuackTagStyle.contentPadding]으로 + * `QuackPadding(vertical=10.dp)`을 제공했다고 해봅시다. 이런 경우에는 + * [contentPadding][QuackTagStyle.contentPadding]이 + * 무시되고 태그의 높이가 10dp로 적용됩니다. 컴포넌트 사이즈를 명시하면서 패딩을 적용하고 싶다면 + * [contentPadding][QuackTagStyle.contentPadding] 대신에 [Modifier.padding]을 사용하세요. + * [LayoutModifier]를 사용하는 흔한 [Modifier]로는 [Modifier.size], [Modifier.height], [Modifier.width] 등이 + * 있습니다. [LayoutModifierNode]를 사용하는 [Modifier]는 [contentPadding][QuackTagStyle.contentPadding] 무시 + * 옵션이 아직 지원되지 않습니다. ([#636](https://github.com/duckie-team/quack-quack-android/issues/636)) + * + * ### 사용 가능 데코레이터 + * + * | style | [trailingIcon][Modifier.trailingIcon] + * | description | + * |:------------------------------------------------------:|:-------------------------------------:|:----------------------------------:| + * | [Outlined][QuackOutlinedTagDefaults] | ⭕ + * | | + * | [Filled][QuackFilledTagDefaults] | ⭕ + * | | + * | [GrayscaleFlat][QuackGrayscaleFlatTagDefaults] | ❌ + * | 태그의 너비가 좁기에 아이콘 데코레이터를 사용할 수 없습니다. | + * | [GrayscaleOutlined][QuackGrayscaleOutlinedTagDefaults] | ⭕ + * | | + * + * This component uses [QuackTagStyle.Filled] as the token value for `style`. + * + * This document was automatically generated by [QuackTag]. + * If any contents are broken, please check the original document. + * + * @param text 중앙에 표시할 텍스트 + * @param selected 선택 상태 여부 + * @param rippleEnabled 클릭했을 때 리플 애니메이션을 적용할지 여부 + * @param onClick 클릭했을 때 실행할 람다식. 태그는 토글이 자유로워야 하므로 [selected]와 관계 없이 + * 항상 클릭 가능합니다. + */ +@Casa +@Composable +@NonRestartableComposable +@ExperimentalQuackQuackApi +@SugarRefer("team.duckie.quackquack.ui.QuackTag") +public fun QuackFilledTag( + @CasaValue("\"QuackTagPreview\"") text: String, + modifier: Modifier = sugar(), + selected: Boolean = sugar(), + rippleEnabled: Boolean = sugar(), + @CasaValue("{}") onClick: () -> Unit, +): Unit { + QuackTag( + text = text, + style = QuackTagStyle.Filled, + modifier = modifier, + selected = selected, + rippleEnabled = rippleEnabled, + onClick = onClick, + ) +} + +/** + * 태그를 그립니다. + * + * - 이 컴포넌트는 자체의 패딩 정책을 구현합니다. + * - [스타일][style]별로 사용 가능한 데코레이터가 달라집니다. + * + * ### 패딩 정책 + * + * 1. [태그의 스타일][QuackTagStyle]에서 [contentPadding][QuackTagStyle.contentPadding] 옵션을 + * 별도로 제공하고 있습니다. 이는 [Modifier.padding]과 다른 패딩 정책을 사용합니다. [Modifier.padding]은 + * 태그의 루트 레이아웃을 기준으로 패딩이 적용되지만, [QuackTagStyle.contentPadding]은 태그의 + * 텍스트와 후행 아이콘을 기준으로 적용됩니다. 태그 컴포넌트는 [trailingIcon][Modifier.trailingIcon] 데코레이터로 + * 후행 아이콘을 추가할 수 있고, 후행 아이콘 여부에 따라 패딩 정책이 결정됩니다. 후행 아이콘이 있다면 세로와 + * 가로에 따라 패딩을 적용하는 방식이 달라집니다. 세로의 경우는 태그 텍스트를 기준으로 적용되고, 가로의 경우는 + * 후행 아이콘의 터치 영역을 증가시키는 식으로 적용됩니다. 기본적으로 후행 아이콘은 16px의 사이즈를 갖습니다. + * 유저 입장에서 16px의 터치 영역은 좋은 경험을 제공하지 못할 것으로 예상하여, [전체 가로 패딩][QuackPadding.vertical]의 + * 오른쪽 영역을 후행 아이콘의 오른쪽 패딩으로 적용합니다. 이때, [전체 가로 패딩][QuackPadding.vertical]의 오른쪽 + * 영역을 그대로 적용하는 게 아니라 해당 값에서 [텍스트와 후행 아이콘 사이 공간][QuackTagStyle.iconSpacedBy]을 뺀 + * 값을 적용합니다. 이는 디자인 가이드라인에 의거합니다. 그리고 [텍스트와 후행 아이콘 사이 공간][QuackTagStyle.iconSpacedBy]의 + * 반을 후행 아이콘의 왼쪽 패딩으로 적용합니다. [텍스트와 후행 아이콘 사이 공간][QuackTagStyle.iconSpacedBy] 반의 + * 나머지 부분은 태그 텍스트의 오른쪽 패딩으로 적용됩니다. 후행 아이콘이 없다면 단순히 태그 텍스트를 기준으로 패딩이 + * 적용됩니다. + * 2. [LayoutModifier]를 사용하여 컴포넌트의 사이즈가 명시됐다면 [QuackTagStyle.contentPadding] + * 옵션은 무시됩니다. [contentPadding][QuackTagStyle.contentPadding]은 컴포넌트 사이즈 하드코딩을 + * 대체하는 용도로 제공됩니다. 하지만 컴포넌트 사이즈가 하드코딩됐다면 [contentPadding][QuackTagStyle.contentPadding]을 + * 제공하는 의미가 없어집니다. 따라서 컴포넌트의 사이즈가 하드코딩됐다면 개발자의 의도를 존중한다는 원칙하에 + * 컴포넌트의 사이즈가 중첩으로 확장되는 일을 예방하고자 [contentPadding][QuackTagStyle.contentPadding] + * 옵션을 무시합니다. 예를 들어 `Modifier.height(10.dp)`로 컴포넌트 높이를 명시했고, + * [contentPadding][QuackTagStyle.contentPadding]으로 + * `QuackPadding(vertical=10.dp)`을 제공했다고 해봅시다. 이런 경우에는 + * [contentPadding][QuackTagStyle.contentPadding]이 + * 무시되고 태그의 높이가 10dp로 적용됩니다. 컴포넌트 사이즈를 명시하면서 패딩을 적용하고 싶다면 + * [contentPadding][QuackTagStyle.contentPadding] 대신에 [Modifier.padding]을 사용하세요. + * [LayoutModifier]를 사용하는 흔한 [Modifier]로는 [Modifier.size], [Modifier.height], [Modifier.width] 등이 + * 있습니다. [LayoutModifierNode]를 사용하는 [Modifier]는 [contentPadding][QuackTagStyle.contentPadding] 무시 + * 옵션이 아직 지원되지 않습니다. ([#636](https://github.com/duckie-team/quack-quack-android/issues/636)) + * + * ### 사용 가능 데코레이터 + * + * | style | [trailingIcon][Modifier.trailingIcon] + * | description | + * |:------------------------------------------------------:|:-------------------------------------:|:----------------------------------:| + * | [Outlined][QuackOutlinedTagDefaults] | ⭕ + * | | + * | [Filled][QuackFilledTagDefaults] | ⭕ + * | | + * | [GrayscaleFlat][QuackGrayscaleFlatTagDefaults] | ❌ + * | 태그의 너비가 좁기에 아이콘 데코레이터를 사용할 수 없습니다. | + * | [GrayscaleOutlined][QuackGrayscaleOutlinedTagDefaults] | ⭕ + * | | + * + * This component uses [QuackTagStyle.GrayscaleFlat] as the token value for `style`. + * + * This document was automatically generated by [QuackTag]. + * If any contents are broken, please check the original document. + * + * @param text 중앙에 표시할 텍스트 + * @param selected 선택 상태 여부 + * @param rippleEnabled 클릭했을 때 리플 애니메이션을 적용할지 여부 + * @param onClick 클릭했을 때 실행할 람다식. 태그는 토글이 자유로워야 하므로 [selected]와 관계 없이 + * 항상 클릭 가능합니다. + */ +@Casa +@Composable +@NonRestartableComposable +@ExperimentalQuackQuackApi +@SugarRefer("team.duckie.quackquack.ui.QuackTag") +public fun QuackGrayscaleFlatTag( + @CasaValue("\"QuackTagPreview\"") text: String, + modifier: Modifier = sugar(), + selected: Boolean = sugar(), + rippleEnabled: Boolean = sugar(), + @CasaValue("{}") onClick: () -> Unit, +): Unit { + QuackTag( + text = text, + style = QuackTagStyle.GrayscaleFlat, + modifier = modifier, + selected = selected, + rippleEnabled = rippleEnabled, + onClick = onClick, + ) +} + +/** + * 태그를 그립니다. + * + * - 이 컴포넌트는 자체의 패딩 정책을 구현합니다. + * - [스타일][style]별로 사용 가능한 데코레이터가 달라집니다. + * + * ### 패딩 정책 + * + * 1. [태그의 스타일][QuackTagStyle]에서 [contentPadding][QuackTagStyle.contentPadding] 옵션을 + * 별도로 제공하고 있습니다. 이는 [Modifier.padding]과 다른 패딩 정책을 사용합니다. [Modifier.padding]은 + * 태그의 루트 레이아웃을 기준으로 패딩이 적용되지만, [QuackTagStyle.contentPadding]은 태그의 + * 텍스트와 후행 아이콘을 기준으로 적용됩니다. 태그 컴포넌트는 [trailingIcon][Modifier.trailingIcon] 데코레이터로 + * 후행 아이콘을 추가할 수 있고, 후행 아이콘 여부에 따라 패딩 정책이 결정됩니다. 후행 아이콘이 있다면 세로와 + * 가로에 따라 패딩을 적용하는 방식이 달라집니다. 세로의 경우는 태그 텍스트를 기준으로 적용되고, 가로의 경우는 + * 후행 아이콘의 터치 영역을 증가시키는 식으로 적용됩니다. 기본적으로 후행 아이콘은 16px의 사이즈를 갖습니다. + * 유저 입장에서 16px의 터치 영역은 좋은 경험을 제공하지 못할 것으로 예상하여, [전체 가로 패딩][QuackPadding.vertical]의 + * 오른쪽 영역을 후행 아이콘의 오른쪽 패딩으로 적용합니다. 이때, [전체 가로 패딩][QuackPadding.vertical]의 오른쪽 + * 영역을 그대로 적용하는 게 아니라 해당 값에서 [텍스트와 후행 아이콘 사이 공간][QuackTagStyle.iconSpacedBy]을 뺀 + * 값을 적용합니다. 이는 디자인 가이드라인에 의거합니다. 그리고 [텍스트와 후행 아이콘 사이 공간][QuackTagStyle.iconSpacedBy]의 + * 반을 후행 아이콘의 왼쪽 패딩으로 적용합니다. [텍스트와 후행 아이콘 사이 공간][QuackTagStyle.iconSpacedBy] 반의 + * 나머지 부분은 태그 텍스트의 오른쪽 패딩으로 적용됩니다. 후행 아이콘이 없다면 단순히 태그 텍스트를 기준으로 패딩이 + * 적용됩니다. + * 2. [LayoutModifier]를 사용하여 컴포넌트의 사이즈가 명시됐다면 [QuackTagStyle.contentPadding] + * 옵션은 무시됩니다. [contentPadding][QuackTagStyle.contentPadding]은 컴포넌트 사이즈 하드코딩을 + * 대체하는 용도로 제공됩니다. 하지만 컴포넌트 사이즈가 하드코딩됐다면 [contentPadding][QuackTagStyle.contentPadding]을 + * 제공하는 의미가 없어집니다. 따라서 컴포넌트의 사이즈가 하드코딩됐다면 개발자의 의도를 존중한다는 원칙하에 + * 컴포넌트의 사이즈가 중첩으로 확장되는 일을 예방하고자 [contentPadding][QuackTagStyle.contentPadding] + * 옵션을 무시합니다. 예를 들어 `Modifier.height(10.dp)`로 컴포넌트 높이를 명시했고, + * [contentPadding][QuackTagStyle.contentPadding]으로 + * `QuackPadding(vertical=10.dp)`을 제공했다고 해봅시다. 이런 경우에는 + * [contentPadding][QuackTagStyle.contentPadding]이 + * 무시되고 태그의 높이가 10dp로 적용됩니다. 컴포넌트 사이즈를 명시하면서 패딩을 적용하고 싶다면 + * [contentPadding][QuackTagStyle.contentPadding] 대신에 [Modifier.padding]을 사용하세요. + * [LayoutModifier]를 사용하는 흔한 [Modifier]로는 [Modifier.size], [Modifier.height], [Modifier.width] 등이 + * 있습니다. [LayoutModifierNode]를 사용하는 [Modifier]는 [contentPadding][QuackTagStyle.contentPadding] 무시 + * 옵션이 아직 지원되지 않습니다. ([#636](https://github.com/duckie-team/quack-quack-android/issues/636)) + * + * ### 사용 가능 데코레이터 + * + * | style | [trailingIcon][Modifier.trailingIcon] + * | description | + * |:------------------------------------------------------:|:-------------------------------------:|:----------------------------------:| + * | [Outlined][QuackOutlinedTagDefaults] | ⭕ + * | | + * | [Filled][QuackFilledTagDefaults] | ⭕ + * | | + * | [GrayscaleFlat][QuackGrayscaleFlatTagDefaults] | ❌ + * | 태그의 너비가 좁기에 아이콘 데코레이터를 사용할 수 없습니다. | + * | [GrayscaleOutlined][QuackGrayscaleOutlinedTagDefaults] | ⭕ + * | | + * + * This component uses [QuackTagStyle.GrayscaleOutlined] as the token value for `style`. + * + * This document was automatically generated by [QuackTag]. + * If any contents are broken, please check the original document. + * + * @param text 중앙에 표시할 텍스트 + * @param selected 선택 상태 여부 + * @param rippleEnabled 클릭했을 때 리플 애니메이션을 적용할지 여부 + * @param onClick 클릭했을 때 실행할 람다식. 태그는 토글이 자유로워야 하므로 [selected]와 관계 없이 + * 항상 클릭 가능합니다. + */ +@Casa +@Composable +@NonRestartableComposable +@ExperimentalQuackQuackApi +@SugarRefer("team.duckie.quackquack.ui.QuackTag") +public fun QuackGrayscaleOutlinedTag( + @CasaValue("\"QuackTagPreview\"") text: String, + modifier: Modifier = sugar(), + selected: Boolean = sugar(), + rippleEnabled: Boolean = sugar(), + @CasaValue("{}") onClick: () -> Unit, +): Unit { + QuackTag( + text = text, + style = QuackTagStyle.GrayscaleOutlined, + modifier = modifier, + selected = selected, + rippleEnabled = rippleEnabled, + onClick = onClick, + ) +} diff --git a/ui/src/main/kotlin/team/duckie/quackquack/ui/tag.kt b/ui/src/main/kotlin/team/duckie/quackquack/ui/tag.kt new file mode 100644 index 000000000..e7decb5d9 --- /dev/null +++ b/ui/src/main/kotlin/team/duckie/quackquack/ui/tag.kt @@ -0,0 +1,831 @@ +/* + * 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 + +import android.annotation.SuppressLint +import androidx.annotation.VisibleForTesting +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.NonRestartableComposable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.currentComposer +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.paint +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.LayoutModifier +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.node.LayoutModifierNode +import androidx.compose.ui.platform.InspectorInfo +import androidx.compose.ui.platform.debugInspectorInfo +import androidx.compose.ui.platform.inspectable +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.constrainHeight +import androidx.compose.ui.unit.constrainWidth +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastFirstOrNull +import team.duckie.quackquack.aide.annotation.DecorateModifier +import team.duckie.quackquack.casa.annotation.CasaValue +import team.duckie.quackquack.material.QuackBorder +import team.duckie.quackquack.material.QuackColor +import team.duckie.quackquack.material.QuackIcon +import team.duckie.quackquack.material.QuackPadding +import team.duckie.quackquack.material.QuackTypography +import team.duckie.quackquack.material.quackClickable +import team.duckie.quackquack.material.quackSurface +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.util.ExperimentalQuackQuackApi +import team.duckie.quackquack.ui.util.QuackDsl +import team.duckie.quackquack.ui.util.asLoose +import team.duckie.quackquack.ui.util.fontScaleAwareIconSize +import team.duckie.quackquack.ui.util.wrappedDebugInspectable +import team.duckie.quackquack.util.applyIf +import team.duckie.quackquack.util.fastFirstIsInstanceOrNull + +/** + * 태그의 디자인 스펙을 나타냅니다. + * + * @see QuackTagStyle + */ +@QuackDsl +public interface TagStyleMarker + +/** 기본으로 제공되는 [TagStyleMarker]의 스펙들에서 공통되는 필드를 나타냅니다. */ +@Immutable +public interface QuackTagStyle { + /** 사용할 색상들 */ + public val colors: QuackTagColors + + /** 모서리의 둥글기 정도 */ + public val radius: Dp + + /** 컨텐츠 주변에 들어갈 패딩 */ + public val contentPadding: QuackPadding + + /** 배치되는 아이콘과 텍스트 사이의 간격 */ + public val iconSpacedBy: Dp + + /** 테두리의 굵기 */ + public val borderThickness: Dp + + /** 선택 상태에서 표시될 텍스트의 타이포그래피 */ + public val typography: QuackTypography + + /** 비선택 상태에서 표시될 텍스트의 타이포그래피 */ + public val unselectedTypography: QuackTypography + + /** 디자인 스펙을 변경하는 람다 */ + @Stable + public operator fun invoke(styleBuilder: T.() -> Unit): T + + public companion object { + /** 태그 디자인 가이드의 `outlined` 디자인 스펙을 가져옵니다. */ + @Stable + public val Outlined: QuackTagStyle + get() = QuackOutlinedTagDefaults() + + /** 태그 디자인 가이드의 `filled` 디자인 스펙을 가져옵니다. */ + @Stable + public val Filled: QuackTagStyle + get() = QuackFilledTagDefaults() + + /** 태그 디자인 가이드의 `grayscale, flat` 디자인 스펙을 가져옵니다. */ + @Stable + public val GrayscaleFlat: QuackTagStyle + get() = QuackGrayscaleFlatTagDefaults() + + /** 태그 디자인 가이드의 `grayscale, outlined` 디자인 스펙을 가져옵니다. */ + @Stable + public val GrayscaleOutlined: QuackTagStyle + get() = QuackGrayscaleOutlinedTagDefaults() + } +} + +/** [QuackTagStyle]의 필드들을 [InspectorInfo]로 기록합니다. */ +@SuppressLint("ModifierFactoryExtensionFunction") +@Stable +public fun QuackTagStyle<*>.wrappedDebugInspectable(baseline: Modifier): Modifier = + baseline.wrappedDebugInspectable { + name = toString() + properties["colors"] = colors + properties["radius"] = radius + properties["contentPadding"] = contentPadding + properties["iconSpacedBy"] = iconSpacedBy + properties["borderThickness"] = borderThickness + properties["typography"] = typography + properties["unselectedTypography"] = unselectedTypography + } + +/** + * 태그에서 사용할 색상들을 정의합니다. + * + * @param backgroundColor 선택 상태의 배경 색상 + * @param unselectedBackgroundColor 비선택 상태의 배경 색상 + * @param contentColor 선택 상태의 컨텐츠 색상 (아이콘 색상은 [iconColor]로 + * 관리되며, 컨텐츠라 하면 태그의 텍스트를 의미합니다.) + * @param unselectedContentColor 비선택 상태의 컨텐츠 색상 + * @param borderColor 선택 상태의 테두리 색상 + * @param unselectedBorderColor 비선택 상태의 테두리 색상 + * @param iconColor 선택 상태의 아이콘 색상 + * @param unselectedIconColor 비선택 상태의 아이콘 색상 + * @param rippleColor 선택 상태에 관계 없이 항상 사용할 리플 색상 + */ +@Immutable +public class QuackTagColors internal constructor( + internal val backgroundColor: QuackColor, + internal val unselectedBackgroundColor: QuackColor, + internal val contentColor: QuackColor, + internal val unselectedContentColor: QuackColor, + internal val borderColor: QuackColor, + internal val unselectedBorderColor: QuackColor, + internal val iconColor: QuackColor, + internal val unselectedIconColor: QuackColor, + internal val rippleColor: QuackColor, +) { + /** 기존 색상에서 일부 값만 변경하여 새로운 인스턴스를 반환합니다. */ + @Stable + public fun copy( + backgroundColor: QuackColor = this.backgroundColor, + unselectedBackgroundColor: QuackColor = this.unselectedBackgroundColor, + contentColor: QuackColor = this.contentColor, + unselectedContentColor: QuackColor = this.unselectedContentColor, + borderColor: QuackColor = this.borderColor, + unselectedBorderColor: QuackColor = this.unselectedBorderColor, + iconColor: QuackColor = this.iconColor, + unselectedIconColor: QuackColor = this.unselectedIconColor, + rippleColor: QuackColor = this.rippleColor, + ): QuackTagColors = + QuackTagColors( + backgroundColor = backgroundColor, + unselectedBackgroundColor = unselectedBackgroundColor, + contentColor = contentColor, + unselectedContentColor = unselectedContentColor, + borderColor = borderColor, + unselectedBorderColor = unselectedBorderColor, + iconColor = iconColor, + unselectedIconColor = unselectedIconColor, + rippleColor = rippleColor, + ) + + @Suppress("RedundantIf") + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is QuackTagColors) return false + + if (backgroundColor != other.backgroundColor) return false + if (unselectedBackgroundColor != other.unselectedBackgroundColor) return false + if (contentColor != other.contentColor) return false + if (unselectedContentColor != other.unselectedContentColor) return false + if (borderColor != other.borderColor) return false + if (unselectedBorderColor != other.unselectedBorderColor) return false + if (iconColor != other.iconColor) return false + if (unselectedIconColor != other.unselectedIconColor) return false + if (rippleColor != other.rippleColor) return false + + return true + } + + override fun hashCode(): Int { + var result = backgroundColor.hashCode() + result = 31 * result + unselectedBackgroundColor.hashCode() + result = 31 * result + contentColor.hashCode() + result = 31 * result + unselectedContentColor.hashCode() + result = 31 * result + borderColor.hashCode() + result = 31 * result + unselectedBorderColor.hashCode() + result = 31 * result + iconColor.hashCode() + result = 31 * result + unselectedIconColor.hashCode() + result = 31 * result + rippleColor.hashCode() + return result + } + + override fun toString(): String = + "QuackTagColors(" + + "backgroundColor=$backgroundColor, " + + "unselectedBackgroundColor=$unselectedBackgroundColor, " + + "contentColor=$contentColor, " + + "unselectedContentColor=$unselectedContentColor, " + + "borderColor=$borderColor, " + + "unselectedBorderColor=$unselectedBorderColor, " + + "iconColor=$iconColor, " + + "unselectedIconColor=$unselectedIconColor, " + + "rippleColor=$rippleColor" + + ")" +} + +/** 태그 디자인 가이드의 `outlined` 디자인 스펙을 정의합니다. */ +@Immutable +public class QuackOutlinedTagDefaults internal constructor() : + QuackTagStyle, TagStyleMarker { + + override var colors: QuackTagColors = tagColors() + + override var radius: Dp = 30.dp + + override var contentPadding: QuackPadding = + QuackPadding( + horizontal = 12.dp, + vertical = 8.dp, + ) + + override var iconSpacedBy: Dp = 4.dp + + override var borderThickness: Dp = 1.dp + + override var typography: QuackTypography = QuackTypography.Title2 + override var unselectedTypography: QuackTypography = typography + + @Stable + public fun tagColors( + backgroundColor: QuackColor = QuackColor.White, + unselectedBackgroundColor: QuackColor = backgroundColor, + contentColor: QuackColor = QuackColor.DuckieOrange, + unselectedContentColor: QuackColor = QuackColor.Black, + borderColor: QuackColor = QuackColor.DuckieOrange, + unselectedBorderColor: QuackColor = QuackColor.Gray3, + iconColor: QuackColor = QuackColor.DuckieOrange, + unselectedIconColor: QuackColor = QuackColor.Gray2, + rippleColor: QuackColor = QuackColor.Unspecified, + ): QuackTagColors = + QuackTagColors( + backgroundColor = backgroundColor, + unselectedBackgroundColor = unselectedBackgroundColor, + contentColor = contentColor, + unselectedContentColor = unselectedContentColor, + borderColor = borderColor, + unselectedBorderColor = unselectedBorderColor, + iconColor = iconColor, + unselectedIconColor = unselectedIconColor, + rippleColor = rippleColor, + ) + + override fun invoke(styleBuilder: QuackOutlinedTagDefaults.() -> Unit): QuackOutlinedTagDefaults = + apply(styleBuilder) + + override fun toString(): String = this::class.simpleName!! +} + +/** 태그 디자인 가이드의 `filled` 디자인 스펙을 정의합니다. */ +@Immutable +public class QuackFilledTagDefaults internal constructor() : + QuackTagStyle, TagStyleMarker { + + override var colors: QuackTagColors = tagColors() + + override var radius: Dp = 30.dp + + override var contentPadding: QuackPadding = + QuackPadding( + horizontal = 16.dp, + vertical = 8.dp, + ) + + override var iconSpacedBy: Dp = 4.dp + + override var borderThickness: Dp = 1.dp + + override var typography: QuackTypography = QuackTypography.Title2 + override var unselectedTypography: QuackTypography = typography + + @Stable + public fun tagColors( + backgroundColor: QuackColor = QuackColor.DuckieOrange, + unselectedBackgroundColor: QuackColor = QuackColor.White, + contentColor: QuackColor = QuackColor.White, + unselectedContentColor: QuackColor = QuackColor.Black, + borderColor: QuackColor = QuackColor.DuckieOrange, + unselectedBorderColor: QuackColor = QuackColor.Gray3, + iconColor: QuackColor = QuackColor.White, + unselectedIconColor: QuackColor = QuackColor.Gray2, + rippleColor: QuackColor = QuackColor.Unspecified, + ): QuackTagColors = + QuackTagColors( + backgroundColor = backgroundColor, + unselectedBackgroundColor = unselectedBackgroundColor, + contentColor = contentColor, + unselectedContentColor = unselectedContentColor, + borderColor = borderColor, + unselectedBorderColor = unselectedBorderColor, + iconColor = iconColor, + unselectedIconColor = unselectedIconColor, + rippleColor = rippleColor, + ) + + override fun invoke(styleBuilder: QuackFilledTagDefaults.() -> Unit): QuackFilledTagDefaults = + apply(styleBuilder) + + override fun toString(): String = this::class.simpleName!! +} + +// TODO(3): 데코레이터 사용 불가능 린트 제공 +/** 태그 디자인 가이드의 `grayscale, flat` 디자인 스펙을 정의합니다. */ +@Immutable +public class QuackGrayscaleFlatTagDefaults internal constructor() : + QuackTagStyle, TagStyleMarker { + + override var colors: QuackTagColors = tagColors() + + override var radius: Dp = 30.dp + + override var contentPadding: QuackPadding = + QuackPadding( + horizontal = 8.dp, + vertical = 4.dp, + ) + + override var iconSpacedBy: Dp = 4.dp + + override var borderThickness: Dp = 1.dp + + override var typography: QuackTypography = QuackTypography.Body2 + override var unselectedTypography: QuackTypography = typography + + @Stable + public fun tagColors( + backgroundColor: QuackColor = QuackColor.Gray4, + unselectedBackgroundColor: QuackColor = QuackColor.Unspecified, + contentColor: QuackColor = QuackColor.Gray1, + unselectedContentColor: QuackColor = QuackColor.Unspecified, + borderColor: QuackColor = QuackColor.Gray4, + unselectedBorderColor: QuackColor = QuackColor.Unspecified, + iconColor: QuackColor = QuackColor.Unspecified, + unselectedIconColor: QuackColor = QuackColor.Unspecified, + rippleColor: QuackColor = QuackColor.Unspecified, + ): QuackTagColors = + QuackTagColors( + backgroundColor = backgroundColor, + unselectedBackgroundColor = unselectedBackgroundColor, + contentColor = contentColor, + unselectedContentColor = unselectedContentColor, + borderColor = borderColor, + unselectedBorderColor = unselectedBorderColor, + iconColor = iconColor, + unselectedIconColor = unselectedIconColor, + rippleColor = rippleColor, + ) + + override fun invoke(styleBuilder: QuackGrayscaleFlatTagDefaults.() -> Unit): QuackGrayscaleFlatTagDefaults = + apply(styleBuilder) + + override fun toString(): String = this::class.simpleName!! +} + +/** 태그 디자인 가이드의 `grayscale, outlined` 디자인 스펙을 정의합니다. */ +@Immutable +public class QuackGrayscaleOutlinedTagDefaults internal constructor() : + QuackTagStyle, TagStyleMarker { + + override var colors: QuackTagColors = tagColors() + + override var radius: Dp = 30.dp + + override var contentPadding: QuackPadding = + QuackPadding( + horizontal = 12.dp, + vertical = 8.dp, + ) + + override var iconSpacedBy: Dp = 4.dp + + override var borderThickness: Dp = 1.dp + + override var typography: QuackTypography = QuackTypography.Title2 + override var unselectedTypography: QuackTypography = typography + + @Stable + public fun tagColors( + backgroundColor: QuackColor = QuackColor.White, + unselectedBackgroundColor: QuackColor = backgroundColor, + contentColor: QuackColor = QuackColor.DuckieOrange, + unselectedContentColor: QuackColor = QuackColor.Gray2, + borderColor: QuackColor = QuackColor.DuckieOrange, + unselectedBorderColor: QuackColor = QuackColor.Gray3, + iconColor: QuackColor = QuackColor.DuckieOrange, + unselectedIconColor: QuackColor = QuackColor.Gray2, + rippleColor: QuackColor = QuackColor.Unspecified, + ): QuackTagColors = + QuackTagColors( + backgroundColor = backgroundColor, + unselectedBackgroundColor = unselectedBackgroundColor, + contentColor = contentColor, + unselectedContentColor = unselectedContentColor, + borderColor = borderColor, + unselectedBorderColor = unselectedBorderColor, + iconColor = iconColor, + unselectedIconColor = unselectedIconColor, + rippleColor = rippleColor, + ) + + override fun invoke(styleBuilder: QuackGrayscaleOutlinedTagDefaults.() -> Unit): QuackGrayscaleOutlinedTagDefaults = + apply(styleBuilder) + + override fun toString(): String = this::class.simpleName!! +} + +/** + * 태그에 표시할 후행 아이콘의 정보를 저장합니다. + * + * @see Modifier.trailingIcon + */ +// TODO: leading도 지원해야 하나? +@Stable +private data class TagTrailingIconData( + val icon: QuackIcon, + val size: Dp, + val onClick: () -> Unit, +) : QuackDataModifierModel + +/** + * 태그에 후행 아이콘을 표시합니다. + * + * @param icon 표시할 아이콘 + * @param iconSize 표시할 아이콘의 사이즈 + * @param onClick 후행 아이콘 영역을 클릭했을 때 실행될 람다. + * "후행 아이콘 영역"은 [iconSize] 사이즈의 영역이 아닙니다. 이는 자체 정책으로 결정됩니다. + * 자세한 내용은 [QuackTag] 문서를 참고하세요. + */ +@DecorateModifier +@Stable +public fun Modifier.trailingIcon( + icon: QuackIcon, + iconSize: Dp = 16.dp, + onClick: () -> Unit, +): Modifier = + inspectable( + inspectorInfo = debugInspectorInfo { + name = "trailingIcon" + properties["icon"] = icon + properties["iconSize"] = iconSize + properties["onClick"] = onClick + }, + ) { + TagTrailingIconData( + icon = icon, + size = iconSize, + onClick = onClick, + ) + } + +@VisibleForTesting +internal object QuackTagErrors { + const val GrayscaleFlatStyleUnselectedState = "Per design guidelines, " + + "the GrayscaleFlat design specification does not allow an unselected state." +} + +/** + * 태그를 그립니다. + * + * - 이 컴포넌트는 자체의 패딩 정책을 구현합니다. + * - [스타일][style]별로 사용 가능한 데코레이터가 달라집니다. + * + * ### 패딩 정책 + * + * 1. [태그의 스타일][QuackTagStyle]에서 [contentPadding][QuackTagStyle.contentPadding] 옵션을 + * 별도로 제공하고 있습니다. 이는 [Modifier.padding]과 다른 패딩 정책을 사용합니다. [Modifier.padding]은 + * 태그의 루트 레이아웃을 기준으로 패딩이 적용되지만, [QuackTagStyle.contentPadding]은 태그의 + * 텍스트와 후행 아이콘을 기준으로 적용됩니다. 태그 컴포넌트는 [trailingIcon][Modifier.trailingIcon] 데코레이터로 + * 후행 아이콘을 추가할 수 있고, 후행 아이콘 여부에 따라 패딩 정책이 결정됩니다. 후행 아이콘이 있다면 세로와 + * 가로에 따라 패딩을 적용하는 방식이 달라집니다. 세로의 경우는 태그 텍스트를 기준으로 적용되고, 가로의 경우는 + * 후행 아이콘의 터치 영역을 증가시키는 식으로 적용됩니다. 기본적으로 후행 아이콘은 16px의 사이즈를 갖습니다. + * 유저 입장에서 16px의 터치 영역은 좋은 경험을 제공하지 못할 것으로 예상하여, [전체 가로 패딩][QuackPadding.vertical]의 + * 오른쪽 영역을 후행 아이콘의 오른쪽 패딩으로 적용합니다. 이때, [전체 가로 패딩][QuackPadding.vertical]의 오른쪽 + * 영역을 그대로 적용하는 게 아니라 해당 값에서 [텍스트와 후행 아이콘 사이 공간][QuackTagStyle.iconSpacedBy]을 뺀 + * 값을 적용합니다. 이는 디자인 가이드라인에 의거합니다. 그리고 [텍스트와 후행 아이콘 사이 공간][QuackTagStyle.iconSpacedBy]의 + * 반을 후행 아이콘의 왼쪽 패딩으로 적용합니다. [텍스트와 후행 아이콘 사이 공간][QuackTagStyle.iconSpacedBy] 반의 + * 나머지 부분은 태그 텍스트의 오른쪽 패딩으로 적용됩니다. 후행 아이콘이 없다면 단순히 태그 텍스트를 기준으로 패딩이 + * 적용됩니다. + * 2. [LayoutModifier]를 사용하여 컴포넌트의 사이즈가 명시됐다면 [QuackTagStyle.contentPadding] + * 옵션은 무시됩니다. [contentPadding][QuackTagStyle.contentPadding]은 컴포넌트 사이즈 하드코딩을 + * 대체하는 용도로 제공됩니다. 하지만 컴포넌트 사이즈가 하드코딩됐다면 [contentPadding][QuackTagStyle.contentPadding]을 + * 제공하는 의미가 없어집니다. 따라서 컴포넌트의 사이즈가 하드코딩됐다면 개발자의 의도를 존중한다는 원칙하에 + * 컴포넌트의 사이즈가 중첩으로 확장되는 일을 예방하고자 [contentPadding][QuackTagStyle.contentPadding] + * 옵션을 무시합니다. 예를 들어 `Modifier.height(10.dp)`로 컴포넌트 높이를 명시했고, [contentPadding][QuackTagStyle.contentPadding]으로 + * `QuackPadding(vertical=10.dp)`을 제공했다고 해봅시다. 이런 경우에는 [contentPadding][QuackTagStyle.contentPadding]이 + * 무시되고 태그의 높이가 10dp로 적용됩니다. 컴포넌트 사이즈를 명시하면서 패딩을 적용하고 싶다면 + * [contentPadding][QuackTagStyle.contentPadding] 대신에 [Modifier.padding]을 사용하세요. + * [LayoutModifier]를 사용하는 흔한 [Modifier]로는 [Modifier.size], [Modifier.height], [Modifier.width] 등이 + * 있습니다. [LayoutModifierNode]를 사용하는 [Modifier]는 [contentPadding][QuackTagStyle.contentPadding] 무시 + * 옵션이 아직 지원되지 않습니다. ([#636](https://github.com/duckie-team/quack-quack-android/issues/636)) + * + * ### 사용 가능 데코레이터 + * + * | style | [trailingIcon][Modifier.trailingIcon] | description | + * |:------------------------------------------------------:|:-------------------------------------:|:----------------------------------:| + * | [Outlined][QuackOutlinedTagDefaults] | ⭕ | | + * | [Filled][QuackFilledTagDefaults] | ⭕ | | + * | [GrayscaleFlat][QuackGrayscaleFlatTagDefaults] | ❌ | 태그의 너비가 좁기에 아이콘 데코레이터를 사용할 수 없습니다. | + * | [GrayscaleOutlined][QuackGrayscaleOutlinedTagDefaults] | ⭕ | | + * + * + * @param text 중앙에 표시할 텍스트 + * @param style 적용할 스타일. 사전 정의 스타일은 [QuackTagStyle.Companion] 필드를 참고하세요. + * @param selected 선택 상태 여부 + * @param rippleEnabled 클릭했을 때 리플 애니메이션을 적용할지 여부 + * @param onClick 클릭했을 때 실행할 람다식. 태그는 토글이 자유로워야 하므로 [selected]와 관계 없이 + * 항상 클릭 가능합니다. + */ +@ExperimentalQuackQuackApi +@NonRestartableComposable +@Composable +public fun QuackTag( + @CasaValue("\"QuackTagPreview\"") text: String, + @SugarToken @CasaValue("QuackTagStyle.Outlined") style: QuackTagStyle, + modifier: Modifier = Modifier, + selected: Boolean = true, + rippleEnabled: Boolean = true, + @CasaValue("{}") onClick: () -> Unit, +) { + val isGrayscaleFlat = style is QuackGrayscaleFlatTagDefaults + if (isGrayscaleFlat) { + check(selected) { QuackTagErrors.GrayscaleFlatStyleUnselectedState } + } + + var isSizeSpecified = false + val (composeModifier, quackDataModels) = currentComposer.quackMaterializeOf(modifier) { currentModifier -> + if (!isSizeSpecified) { + isSizeSpecified = currentModifier is LayoutModifier + } + } + val trailingIconData = remember(quackDataModels) { + quackDataModels.fastFirstIsInstanceOrNull().takeUnless { isGrayscaleFlat } + } + + val backgroundColor = style.colors.backgroundColor + val unselectedBackgroundColor = style.colors.unselectedBackgroundColor + val currentBackgroundColor = if (selected) backgroundColor else unselectedBackgroundColor + + val contentColor = style.colors.contentColor + val unselectedContentColor = style.colors.unselectedContentColor + val currentContentColor = if (selected) contentColor else unselectedContentColor + + val borderThickness = style.borderThickness + val borderColor = style.colors.borderColor + val unselectedBorderColor = style.colors.unselectedBorderColor + val currentBorder = remember( + selected, + borderThickness, + borderColor, + unselectedBorderColor, + ) { + QuackBorder( + thickness = borderThickness, + color = if (selected) borderColor else unselectedBorderColor, + ) + } + + val rippleColor = style.colors.rippleColor + val currentRippleEnabled = if (selected) rippleEnabled else false + + val radius = style.radius + + val shape = remember(radius) { + RoundedCornerShape(size = radius) + } + + val contentPadding = style.contentPadding + val currentContentPadding = if (isSizeSpecified) null else contentPadding + + val iconSpacedBy = style.iconSpacedBy + + val typography = style.typography + val unselectedTypography = style.unselectedTypography + val currentTypography = remember( + selected, + typography, + unselectedTypography, + currentContentColor, + ) { + (if (selected) typography else unselectedTypography).change(color = currentContentColor) + } + + val iconColor = style.colors.iconColor + val unselectedIconColor = style.colors.unselectedIconColor + val currentIconColor = if (selected) iconColor else unselectedIconColor + + val trailingIcon = trailingIconData?.icon + val trailingIconSize = trailingIconData?.size + val trailingIconOnClick = trailingIconData?.onClick + + val inspectableModifier = style + .wrappedDebugInspectable(composeModifier) + .wrappedDebugInspectable { + name = "QuackTag" + properties["text"] = text + properties["backgroundColor"] = currentBackgroundColor + properties["rippleColor"] = rippleColor + properties["rippleEnabled"] = currentRippleEnabled + properties["shape"] = shape + properties["border"] = currentBorder + properties["typography"] = currentTypography + properties["contentPadding"] = currentContentPadding + properties["iconSpacedBy"] = iconSpacedBy + properties["iconColor"] = currentIconColor + properties["trailingIcon"] = trailingIcon + properties["trailingIconSize"] = trailingIconSize + properties["trailingIconOnClick"] = trailingIconOnClick + properties["onClick"] = onClick + } + + QuackBaseTag( + modifier = inspectableModifier, + text = text, + backgroundColor = currentBackgroundColor, + rippleColor = rippleColor, + rippleEnabled = currentRippleEnabled, + shape = shape, + border = currentBorder, + typography = currentTypography, + contentPadding = currentContentPadding, + iconSpacedBy = iconSpacedBy, + iconColor = currentIconColor, + trailingIcon = trailingIcon, + trailingIconSize = trailingIconSize, + trailingIconOnClick = trailingIconOnClick, + onClick = onClick, + ) +} + +private const val TextLayoutId = "QuackBaseTagText" +private const val TrailingIconContainerLayoutId = "QuackBaseTagTrailingIconContainer" +private const val FakeTrailingIconLayoutId = "QuackBaseTagFakeTrailingIcon" + +/** + * 고유한 배치 정책으로 태그를 그립니다. 배치 정책의 자세한 정보는 [QuackTag] 문서를 참고하세요. + * + * 이 컴포넌트는 [QuackTagStyle]의 필드를 개별 인자로 받습니다. + */ +@ExperimentalQuackQuackApi +@NoSugar +@Composable +public fun QuackBaseTag( + modifier: Modifier, + text: String, + backgroundColor: QuackColor, + rippleColor: QuackColor, + rippleEnabled: Boolean, + shape: Shape, + border: QuackBorder, + typography: QuackTypography, + contentPadding: QuackPadding?, + iconSpacedBy: Dp, + iconColor: QuackColor?, + trailingIcon: QuackIcon?, + trailingIconSize: Dp?, + trailingIconOnClick: (() -> Unit)?, + onClick: (() -> Unit)?, +) { + if (trailingIcon != null) requireNotNull(trailingIconSize) + + val currentIconColorFilter = remember(iconColor) { + ColorFilter.tint(iconColor?.value ?: Color.Unspecified) + } + + Layout( + modifier = modifier + .testTag("tag") + .quackSurface( + shape = shape, + backgroundColor = backgroundColor, + border = border, + role = Role.Tab, + rippleEnabled = rippleEnabled, + rippleColor = rippleColor, + onClick = onClick, + ), + content = { + QuackText( + modifier = Modifier + .testTag("text") + .layoutId(TextLayoutId), + text = text, + typography = typography, + singleLine = true, + softWrap = false, + ) + if (trailingIcon != null) { + Layout( + modifier = Modifier + .testTag("trailingIconContainer") + .layoutId(TrailingIconContainerLayoutId) + .quackClickable( + role = Role.Tab, + rippleEnabled = false, + onClick = trailingIconOnClick, + ), + content = { + Box( + Modifier + .testTag("trailingIcon") + .fontScaleAwareIconSize(baseline = trailingIconSize!!) + .paint( + painter = trailingIcon.asPainter(), + colorFilter = currentIconColorFilter, + contentScale = ContentScale.Fit, + ), + ) + }, + ) { measurables, constraints -> + val measurable = measurables.single() + + val halfIconSpacedByPx = (iconSpacedBy / 2).roundToPx() + val placeable = measurable.measure(constraints.asLoose(width = true, height = true)) + + layout(width = constraints.minWidth, height = constraints.minHeight) { + placeable.place( + x = halfIconSpacedByPx, + y = Alignment.CenterVertically.align( + size = placeable.height, + space = constraints.minHeight, + ), + ) + } + } + Box( + Modifier + .layoutId(FakeTrailingIconLayoutId) + .fontScaleAwareIconSize(baseline = trailingIconSize!!), + ) + } + }, + ) { measurables, constraints -> + val textMeasurable = measurables.fastFirstOrNull { measurable -> + measurable.layoutId == TextLayoutId + }!! + val trailingIconContainerMeasurable = measurables.fastFirstOrNull { measurable -> + measurable.layoutId == TrailingIconContainerLayoutId + } + val fakeTrailingIconMeasurable = measurables.fastFirstOrNull { measurable -> + measurable.layoutId == FakeTrailingIconLayoutId + } + + val looseConstraints = constraints.asLoose(width = true, height = true) + + val iconSpacedByPx = iconSpacedBy.roundToPx() + val halfIconSpacedByPx = iconSpacedByPx / 2 + + val startHorizontalPadding = contentPadding?.horizontal?.roundToPx() ?: 0 + val trailingIconAwareEndHorizontalPadding = startHorizontalPadding + .applyIf(trailingIcon != null) { minus(iconSpacedByPx) } + val verticalPadding = (contentPadding?.vertical?.times(2)?.roundToPx()) ?: 0 + + val textPlaceable = textMeasurable.measure(looseConstraints) + val fakeTrailingIconPlaceable = fakeTrailingIconMeasurable?.measure(looseConstraints) + + val height = constraints.constrainHeight(textPlaceable.height + verticalPadding) + + val trailingIconContainerConstraints = fakeTrailingIconPlaceable?.let { baseline -> + Constraints.fixed( + width = halfIconSpacedByPx + baseline.measuredWidth + trailingIconAwareEndHorizontalPadding, + height = height, + ) + } + val trailingIconContainerPlaceable = trailingIconContainerMeasurable?.measure(trailingIconContainerConstraints!!) + + val width = constraints.constrainWidth( + 0 + .plus(startHorizontalPadding) + .plus(textPlaceable.width) + .applyIf(trailingIcon != null) { plus(halfIconSpacedByPx) } + .plus(trailingIconContainerConstraints?.minWidth ?: trailingIconAwareEndHorizontalPadding), + ) + + layout(width = width, height = height) { + textPlaceable.place( + x = startHorizontalPadding, + y = Alignment.CenterVertically.align( + size = textPlaceable.height, + space = height, + ), + ) + + trailingIconContainerPlaceable?.place( + x = startHorizontalPadding + textPlaceable.width + halfIconSpacedByPx, + y = Alignment.CenterVertically.align( + size = trailingIconContainerPlaceable.height, + space = height, + ), + ) + } + } +} diff --git a/ui/src/test/kotlin/team/duckie/quackquack/ui/TagSnapshot.kt b/ui/src/test/kotlin/team/duckie/quackquack/ui/TagSnapshot.kt new file mode 100644 index 000000000..36fd85e4b --- /dev/null +++ b/ui/src/test/kotlin/team/duckie/quackquack/ui/TagSnapshot.kt @@ -0,0 +1,153 @@ +/* + * 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 + +import androidx.activity.ComponentActivity +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.github.takahirom.roborazzi.RoborazziRule +import java.io.File +import org.junit.Ignore +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import team.duckie.quackquack.material.QuackIcon +import team.duckie.quackquack.ui.sugar.QuackFilledTag +import team.duckie.quackquack.ui.sugar.QuackGrayscaleFlatTag +import team.duckie.quackquack.ui.sugar.QuackGrayscaleOutlinedTag +import team.duckie.quackquack.ui.sugar.QuackOutlinedTag +import team.duckie.quackquack.ui.util.BaseSnapshotPath +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi +import team.duckie.quackquack.ui.util.setQuackContent +import team.duckie.quackquack.ui.util.withIncreaseFontScale + +@RunWith(AndroidJUnit4::class) +@Config(manifest = Config.NONE) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class TagSnapshot { + private val testNameToSelectState = mutableMapOf() + + private fun stateMarkedTestName(testName: String): String { + val selected = testNameToSelectState.getOrPut(testName) { true } + return (testName + (if (selected) "_selected" else "_unselected")) + .also { testNameToSelectState[testName] = false } + } + + @get:Rule + val compose = createAndroidComposeRule() + + @get:Rule + val roborazzi = RoborazziRule( + composeRule = compose, + captureRoot = compose.onRoot(), + options = RoborazziRule.Options( + captureType = RoborazziRule.CaptureType.AllImage, + outputFileProvider = { description, _, fileExtension -> + val testName = "$BaseSnapshotPath/tag/${description.methodName}" + val countedTestName = stateMarkedTestName(testName) + File("$countedTestName.$fileExtension") + }, + ), + ) + + @Test + fun Outlined() { + compose.setQuackContent { + var selected by remember { mutableStateOf(true) } + QuackOutlinedTag( + modifier = Modifier.trailingIcon(), + text = "QuackOutlinedTag", + selected = selected, + ) { + selected = !selected + } + } + toggle() + } + + @Test + fun Filled() { + compose.setQuackContent { + var selected by remember { mutableStateOf(true) } + QuackFilledTag( + modifier = Modifier.trailingIcon(), + text = "QuackFilledTag", + selected = selected, + ) { + selected = !selected + } + } + toggle() + } + + @Ignore("QuackText에 fontscale 대응 필요함") + @Test + fun FilledX4() { + compose.setQuackContent { + var selected by remember { mutableStateOf(true) } + + withIncreaseFontScale(4f) { + QuackFilledTag( + modifier = Modifier.trailingIcon(), + text = "QuackFilledTag", + selected = selected, + ) { + selected = !selected + } + } + } + toggle() + } + + @Test + fun GrayscaleFlat() { + // no toggable + // no decoratable + compose.setQuackContent { + QuackGrayscaleFlatTag( + modifier = Modifier.trailingIcon(), + text = "QuackGrayscaleFlatTag", + onClick = {}, + ) + } + } + + @Test + fun GrayscaleOutlined() { + compose.setQuackContent { + var selected by remember { mutableStateOf(true) } + QuackGrayscaleOutlinedTag( + modifier = Modifier.trailingIcon(), + text = "QuackGrayscaleOutlinedTag", + selected = selected, + ) { + selected = !selected + } + } + toggle() + } + + private fun toggle() { + compose.onNodeWithTag("tag").performClick() + } + + private fun Modifier.trailingIcon() = + trailingIcon(icon = QuackIcon.FilledHeart, onClick = {}) +} diff --git a/ui/src/test/kotlin/team/duckie/quackquack/ui/TagTest.kt b/ui/src/test/kotlin/team/duckie/quackquack/ui/TagTest.kt new file mode 100644 index 000000000..5eeb7e862 --- /dev/null +++ b/ui/src/test/kotlin/team/duckie/quackquack/ui/TagTest.kt @@ -0,0 +1,41 @@ +/* + * 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 + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import team.duckie.quackquack.ui.sugar.QuackGrayscaleFlatTag +import team.duckie.quackquack.ui.util.ExperimentalQuackQuackApi +import team.duckie.quackquack.ui.util.setQuackContent + +@RunWith(AndroidJUnit4::class) +class TagTest { + @get:Rule + val compose = createAndroidComposeRule() + + @Test + fun GrayscaleFlatStyleDotAllowUnselectedState() { + val result = runCatching { + compose.setQuackContent { + QuackGrayscaleFlatTag("", selected = false) {} + } + } + val exception = result.exceptionOrNull() + + assertTrue(exception is IllegalStateException) + assertEquals(QuackTagErrors.GrayscaleFlatStyleUnselectedState, exception?.message) + } +} diff --git a/ui/src/test/kotlin/team/duckie/quackquack/ui/util/ComposeContentTestRule.kt b/ui/src/test/kotlin/team/duckie/quackquack/ui/util/ComposeContentTestRule.kt new file mode 100644 index 000000000..1974200f4 --- /dev/null +++ b/ui/src/test/kotlin/team/duckie/quackquack/ui/util/ComposeContentTestRule.kt @@ -0,0 +1,16 @@ +/* + * 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.util + +import androidx.compose.runtime.Composable +import androidx.compose.ui.test.junit4.ComposeContentTestRule +import team.duckie.quackquack.material.theme.QuackTheme + +fun ComposeContentTestRule.setQuackContent(content: @Composable () -> Unit) { + setContent { QuackTheme(content) } +} diff --git a/ui/src/test/kotlin/team/duckie/quackquack/ui/util/SnapshotPathGenerator.kt b/ui/src/test/kotlin/team/duckie/quackquack/ui/util/SnapshotPathGenerator.kt index 5e03ff1a0..897ca2858 100644 --- a/ui/src/test/kotlin/team/duckie/quackquack/ui/util/SnapshotPathGenerator.kt +++ b/ui/src/test/kotlin/team/duckie/quackquack/ui/util/SnapshotPathGenerator.kt @@ -11,10 +11,11 @@ package team.duckie.quackquack.ui.util import java.io.File -inline fun snapshotPath(domain: String): File { - return File("src/test/snapshots/$domain/${getCurrentMethodName()}.png").also { file -> - file.parentFile?.mkdirs() - } +const val BaseSnapshotPath = "src/test/snapshots" + +inline fun snapshotPath(domain: String, isGif: Boolean = false): File { + return File("$BaseSnapshotPath/$domain/${getCurrentMethodName()}.${if (isGif) "gif" else "png"}") + .also { file -> file.parentFile?.mkdirs() } } inline fun getCurrentMethodName(): String { diff --git a/ui/src/test/kotlin/team/duckie/quackquack/ui/util/UxTesting.kt b/ui/src/test/kotlin/team/duckie/quackquack/ui/util/UxTesting.kt new file mode 100644 index 000000000..96c080e9d --- /dev/null +++ b/ui/src/test/kotlin/team/duckie/quackquack/ui/util/UxTesting.kt @@ -0,0 +1,26 @@ +/* + * 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.util + +import android.annotation.SuppressLint +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalConfiguration + +@SuppressLint("ComposableNaming") +@Composable +fun withIncreaseFontScale(fontScale: Float, content: @Composable () -> Unit) { + val testConfiguration = LocalConfiguration.current.apply { + this.fontScale = fontScale + } + + CompositionLocalProvider( + LocalConfiguration provides testConfiguration, + content = content, + ) +} diff --git a/ui/src/test/snapshots/tag/Filled_selected.png b/ui/src/test/snapshots/tag/Filled_selected.png new file mode 100644 index 000000000..feec57bc3 Binary files /dev/null and b/ui/src/test/snapshots/tag/Filled_selected.png differ diff --git a/ui/src/test/snapshots/tag/Filled_unselected.png b/ui/src/test/snapshots/tag/Filled_unselected.png new file mode 100644 index 000000000..00ec3715a Binary files /dev/null and b/ui/src/test/snapshots/tag/Filled_unselected.png differ diff --git a/ui/src/test/snapshots/tag/GrayscaleFlat_selected.png b/ui/src/test/snapshots/tag/GrayscaleFlat_selected.png new file mode 100644 index 000000000..d6f23a40d Binary files /dev/null and b/ui/src/test/snapshots/tag/GrayscaleFlat_selected.png differ diff --git a/ui/src/test/snapshots/tag/GrayscaleOutlined_selected.png b/ui/src/test/snapshots/tag/GrayscaleOutlined_selected.png new file mode 100644 index 000000000..de7e3122f Binary files /dev/null and b/ui/src/test/snapshots/tag/GrayscaleOutlined_selected.png differ diff --git a/ui/src/test/snapshots/tag/GrayscaleOutlined_unselected.png b/ui/src/test/snapshots/tag/GrayscaleOutlined_unselected.png new file mode 100644 index 000000000..c71bdf1a3 Binary files /dev/null and b/ui/src/test/snapshots/tag/GrayscaleOutlined_unselected.png differ diff --git a/ui/src/test/snapshots/tag/Outlined_selected.png b/ui/src/test/snapshots/tag/Outlined_selected.png new file mode 100644 index 000000000..c999ca8b2 Binary files /dev/null and b/ui/src/test/snapshots/tag/Outlined_selected.png differ diff --git a/ui/src/test/snapshots/tag/Outlined_unselected.png b/ui/src/test/snapshots/tag/Outlined_unselected.png new file mode 100644 index 000000000..1abc497aa Binary files /dev/null and b/ui/src/test/snapshots/tag/Outlined_unselected.png differ diff --git a/ui/src/test/snapshots/text/ModifierSpan.png b/ui/src/test/snapshots/text/ModifierSpan.png index 23949273c..fc2170e63 100644 Binary files a/ui/src/test/snapshots/text/ModifierSpan.png and b/ui/src/test/snapshots/text/ModifierSpan.png differ diff --git a/ui/version.txt b/ui/version.txt index 3bf51efdd..0a374f042 100644 --- a/ui/version.txt +++ b/ui/version.txt @@ -1 +1 @@ -2.0.0-alpha02 +2.0.0-alpha03 diff --git a/website/docs/releases.mdx b/website/docs/releases.mdx index c783cc393..183973d99 100644 --- a/website/docs/releases.mdx +++ b/website/docs/releases.mdx @@ -5,6 +5,28 @@ sidebar_label: Releases # Releases (Changelog) +## 2.0.0-alpha03 + +*2023-05-17* + +*Target: BOM, ui* + +### New + +**BOM** + +- Bump the BOM delivery library. + +**ui** + +- Add a `QuackTag` component. + +### Change + +**BOM** + +- Changes a BOM library versioning style. + ## 2.0.0-alpha02 *2023-05-09* @@ -15,18 +37,53 @@ util, util-backend, util-backend-kotlinc* ### New -- [BOM] Update the BOM delivery libraries version. -- [runtime] Improve `QuackComposedModifier` support. -- [material] Improve documentation. -- [animation] Improve documentation. -- [ui] Add a new `QuackButton` component. -- [aide-annotation] Improve documentation. -- [casa-annotation] Improve documentation. -- [sugar-material] Improve documentation. -- [sugar-processor] Add functional type support. -- [util] Improve documentation. -- [util-backend] Initial release. -- [util-backend-kotlinc] Initial release. +**BOM** + +- Bump the BOM delivery libraries. + +**runtime** + +- Improve `QuackComposedModifier` support. + +**material** + +- Improve documentation. + +**animation** + +- Improve documentation. + +**ui** + +- Add a `QuackButton` component. + +**aide-annotation** + +- Improve documentation. + +**casa-annotation** + +- Improve documentation. + +**sugar-material** + +- Improve documentation. + +**sugar-processor** + +- Add functional type support. + +**util** + +- Improve documentation. + +**util-backend** + +- Initial release. + +**util-backend-kotlinc** + +- Initial release. ## 2.0.0-alpha01 @@ -36,9 +93,26 @@ util, util-backend, util-backend-kotlinc* ### New -- [BOM] Initial release. -- [runtime] Initial release. -- [material] Initial release. -- [animation] Initial release. -- [ui] Initial release with `QuackText`. -- [util] Initial release. +**BOM** + +- Initial release. + +**runtime** + +- Initial release. + +**material** + +- Initial release. + +**animation** + +- Initial release. + +**ui** + +- Initial release. + +**util** + +- Initial release.