diff --git a/android-design-system/design-system-internal/src/main/java/com/duckduckgo/common/ui/internal/ui/component/ComponentViewHolder.kt b/android-design-system/design-system-internal/src/main/java/com/duckduckgo/common/ui/internal/ui/component/ComponentViewHolder.kt index 64ff4d6428c8..c2714081931f 100644 --- a/android-design-system/design-system-internal/src/main/java/com/duckduckgo/common/ui/internal/ui/component/ComponentViewHolder.kt +++ b/android-design-system/design-system-internal/src/main/java/com/duckduckgo/common/ui/internal/ui/component/ComponentViewHolder.kt @@ -37,6 +37,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -91,6 +92,7 @@ import com.duckduckgo.common.ui.compose.progress.DaxProgressSpinner import com.duckduckgo.common.ui.compose.radiobutton.DaxRadioButton import com.duckduckgo.common.ui.compose.skeleton.DaxSkeletonListItem import com.duckduckgo.common.ui.compose.skeleton.DaxSkeletonSectionHeader +import com.duckduckgo.common.ui.compose.slider.DaxSlider import com.duckduckgo.common.ui.compose.snackbar.DaxSnackbar import com.duckduckgo.common.ui.compose.switch.DaxSwitch import com.duckduckgo.common.ui.compose.text.DaxText @@ -295,8 +297,36 @@ sealed class ComponentViewHolder(val view: View) : RecyclerView.ViewHolder(view) } } - class SliderComponentViewHolder(parent: ViewGroup) : - ComponentViewHolder(inflate(parent, R.layout.component_slider)) + class SliderComponentViewHolder( + parent: ViewGroup, + private val isDarkTheme: Boolean, + ) : ComponentViewHolder(inflate(parent, R.layout.component_slider)) { + + init { + view.setupThemedComposeView(R.id.composeDaxSlider, isDarkTheme) { + var continuousValue by remember { mutableFloatStateOf(0.3f) } + var steppedValue by remember { mutableFloatStateOf(100f) } + var disabledValue by remember { mutableFloatStateOf(0.3f) } + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + DaxSlider( + value = continuousValue, + onValueChange = { continuousValue = it }, + ) + DaxSlider( + value = steppedValue, + onValueChange = { steppedValue = it }, + valueRange = 70f..170f, + steps = 9, + ) + DaxSlider( + value = disabledValue, + onValueChange = { disabledValue = it }, + enabled = false, + ) + } + } + } + } class InfoPanelComponentViewHolder( parent: ViewGroup, @@ -1037,7 +1067,7 @@ sealed class ComponentViewHolder(val view: View) : RecyclerView.ViewHolder(view) Component.SWITCH -> SwitchComponentViewHolder(parent, isDarkTheme) Component.RADIO_BUTTON -> RadioButtonComponentViewHolder(parent, isDarkTheme) Component.CHECKBOX -> CheckboxComponentViewHolder(parent, isDarkTheme) - Component.SLIDER -> SliderComponentViewHolder(parent) + Component.SLIDER -> SliderComponentViewHolder(parent, isDarkTheme) Component.SNACKBAR -> SnackbarComponentViewHolder(parent, isDarkTheme) Component.INFO_PANEL -> InfoPanelComponentViewHolder(parent, isDarkTheme) Component.REMOTE_MESSAGE -> RemoteMessageComponentViewHolder(parent, isDarkTheme) diff --git a/android-design-system/design-system-internal/src/main/res/layout/component_slider.xml b/android-design-system/design-system-internal/src/main/res/layout/component_slider.xml index 614632b312d8..413b92c6996d 100644 --- a/android-design-system/design-system-internal/src/main/res/layout/component_slider.xml +++ b/android-design-system/design-system-internal/src/main/res/layout/component_slider.xml @@ -26,6 +26,13 @@ android:layout_height="wrap_content" app:primaryText="Slider" /> + + - \ No newline at end of file + + + + + diff --git a/android-design-system/design-system/src/main/java/com/duckduckgo/common/ui/compose/slider/DaxSlider.kt b/android-design-system/design-system/src/main/java/com/duckduckgo/common/ui/compose/slider/DaxSlider.kt new file mode 100644 index 000000000000..8837a97de4f2 --- /dev/null +++ b/android-design-system/design-system/src/main/java/com/duckduckgo/common/ui/compose/slider/DaxSlider.kt @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2026 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalMaterial3Api::class) + +package com.duckduckgo.common.ui.compose.slider + +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderColors +import androidx.compose.material3.SliderDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.duckduckgo.common.ui.compose.theme.DuckDuckGoTheme +import com.duckduckgo.common.ui.compose.tools.PreviewBox +import com.duckduckgo.mobile.android.R + +/** + * DuckDuckGo design system composable slider component. + * + * Wraps Material3 [Slider] with DuckDuckGo theme colors. Value labels, tick marks, stop indicators + * and the thumb press halo are not drawn. + * + * @param value the current value of the slider, coerced into [valueRange] + * @param onValueChange callback invoked continuously as the slider is dragged + * @param modifier the [Modifier] to apply + * @param enabled whether the slider is enabled + * @param valueRange the inclusive range of values this slider can take + * @param steps the number of discrete values between the ends of [valueRange], or 0 for a continuous slider + * @param onValueChangeFinished callback invoked when the drag gesture ends, for committing the settled value + * @param interactionSource the [MutableInteractionSource] representing the stream of interactions for this slider + * + * Asana Task: https://app.asana.com/1/137249556945/project/1202857801505092/task/1218311474562583?focus=true + * Figma reference: https://www.figma.com/design/BOHDESHODUXK7wSRNBOHdu/%F0%9F%A4%96-Android-Components?node-id=3116-4290&m=dev + */ +@Composable +fun DaxSlider( + value: Float, + onValueChange: (Float) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + valueRange: ClosedFloatingPointRange = 0f..1f, + steps: Int = 0, + onValueChangeFinished: (() -> Unit)? = null, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val colors = DaxSliderDefaults.colors() + Slider( + value = value, + onValueChange = onValueChange, + modifier = modifier.alpha(if (enabled) 1f else DaxSliderDefaults.DisabledAlpha), + enabled = enabled, + valueRange = valueRange, + steps = steps, + onValueChangeFinished = onValueChangeFinished, + interactionSource = interactionSource, + colors = colors, + thumb = { + Box( + modifier = Modifier + .size(DaxSliderDefaults.ThumbSize) + .clip(CircleShape) + .background(DuckDuckGoTheme.colors.brand.accentBlue), + ) + }, + track = { sliderState -> + SliderDefaults.Track( + sliderState = sliderState, + modifier = Modifier.height(DaxSliderDefaults.TrackHeight), + enabled = enabled, + colors = colors, + drawStopIndicator = null, + drawTick = { _, _ -> }, + thumbTrackGapSize = 0.dp, + ) + }, + ) +} + +private object DaxSliderDefaults { + + const val DisabledAlpha = 0.4f + + val ThumbSize = 20.dp + + val TrackHeight: Dp + @Composable + get() = dimensionResource(R.dimen.sliderTrackHeight) + + @Composable + fun colors(): SliderColors = SliderDefaults.colors( + thumbColor = DuckDuckGoTheme.colors.brand.accentBlue, + activeTrackColor = DuckDuckGoTheme.colors.brand.accentBlue, + inactiveTrackColor = DuckDuckGoTheme.colors.system.sliderTrackInactive, + disabledThumbColor = DuckDuckGoTheme.colors.brand.accentBlue, + disabledActiveTrackColor = DuckDuckGoTheme.colors.brand.accentBlue, + disabledInactiveTrackColor = DuckDuckGoTheme.colors.system.sliderTrackInactive, + ) +} + +@PreviewLightDark +@Composable +private fun DaxSliderAllStatesPreview() { + PreviewBox { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + DaxSliderPreviewState(value = 0f) + DaxSliderPreviewState(value = 0.3f) + DaxSliderPreviewState(value = 1f) + DaxSliderPreviewState(value = 0.3f, enabled = false) + DaxSliderPreviewState(value = 100f, valueRange = 70f..170f, steps = 9) + } + } +} + +@Composable +private fun DaxSliderPreviewState( + value: Float, + enabled: Boolean = true, + valueRange: ClosedFloatingPointRange = 0f..1f, + steps: Int = 0, +) { + var sliderValue by remember { mutableFloatStateOf(value) } + DaxSlider( + value = sliderValue, + onValueChange = { sliderValue = it }, + enabled = enabled, + valueRange = valueRange, + steps = steps, + ) +} diff --git a/lint-rules/src/main/java/com/duckduckgo/lint/registry/DuckDuckGoIssueRegistry.kt b/lint-rules/src/main/java/com/duckduckgo/lint/registry/DuckDuckGoIssueRegistry.kt index 4ed54fcf9876..1e46b01e0934 100644 --- a/lint-rules/src/main/java/com/duckduckgo/lint/registry/DuckDuckGoIssueRegistry.kt +++ b/lint-rules/src/main/java/com/duckduckgo/lint/registry/DuckDuckGoIssueRegistry.kt @@ -55,6 +55,7 @@ import com.duckduckgo.lint.ui.DaxListItemContentDetector.Companion.INVALID_DAX_L import com.duckduckgo.lint.ui.DaxTextColorUsageDetector.Companion.INVALID_DAX_TEXT_COLOR_USAGE import com.duckduckgo.lint.ui.NoRawM3AlertDialogUsageDetector.Companion.NO_RAW_M3_ALERT_DIALOG_USAGE import com.duckduckgo.lint.ui.NoRawM3ButtonUsageDetector.Companion.NO_RAW_M3_BUTTON_USAGE +import com.duckduckgo.lint.ui.NoRawM3SliderUsageDetector.Companion.NO_RAW_M3_SLIDER_USAGE import com.duckduckgo.lint.ui.NoRawM3SnackbarUsageDetector.Companion.NO_RAW_M3_SNACKBAR_USAGE import com.duckduckgo.lint.ui.NoRawM3SurfaceUsageDetector.Companion.NO_RAW_M3_SURFACE_USAGE import com.duckduckgo.lint.ui.DaxTextFieldTrailingIconDetector.Companion.INVALID_DAX_TEXT_FIELD_TRAILING_ICON_USAGE @@ -144,6 +145,7 @@ class DuckDuckGoIssueRegistry : IssueRegistry() { NO_RAW_M3_ALERT_DIALOG_USAGE, NO_RAW_M3_SURFACE_USAGE, NO_RAW_M3_SNACKBAR_USAGE, + NO_RAW_M3_SLIDER_USAGE, ).plus(WebViewCompatApisUsageDetector.issues) diff --git a/lint-rules/src/main/java/com/duckduckgo/lint/ui/NoRawM3SliderUsageDetector.kt b/lint-rules/src/main/java/com/duckduckgo/lint/ui/NoRawM3SliderUsageDetector.kt new file mode 100644 index 000000000000..b8aa87ce1bf7 --- /dev/null +++ b/lint-rules/src/main/java/com/duckduckgo/lint/ui/NoRawM3SliderUsageDetector.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2026 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.lint.ui + +import com.android.tools.lint.client.api.UElementHandler +import com.android.tools.lint.detector.api.Category.Companion.CUSTOM_LINT_CHECKS +import com.android.tools.lint.detector.api.Detector +import com.android.tools.lint.detector.api.Implementation +import com.android.tools.lint.detector.api.Issue +import com.android.tools.lint.detector.api.JavaContext +import com.android.tools.lint.detector.api.Scope +import com.android.tools.lint.detector.api.Severity +import com.android.tools.lint.detector.api.SourceCodeScanner +import com.android.tools.lint.detector.api.TextFormat +import org.jetbrains.uast.UCallExpression +import java.util.EnumSet + +@Suppress("UnstableApiUsage") +class NoRawM3SliderUsageDetector : Detector(), SourceCodeScanner { + + override fun getApplicableUastTypes() = listOf(UCallExpression::class.java) + + override fun createUastHandler(context: JavaContext): UElementHandler { + return RawM3SliderCallHandler(context) + } + + internal class RawM3SliderCallHandler(private val context: JavaContext) : UElementHandler() { + + override fun visitCallExpression(node: UCallExpression) { + if (isInDesignSystemModule(context.project.name)) return + + val methodName = node.methodName ?: return + if (methodName != RAW_M3_SLIDER_NAME) return + + val resolved = node.resolve() ?: return + val qualifiedName = resolved.containingClass?.qualifiedName ?: return + if (qualifiedName.startsWith("androidx.compose.material3")) { + context.report( + issue = NO_RAW_M3_SLIDER_USAGE, + location = context.getLocation(node), + message = NO_RAW_M3_SLIDER_USAGE.getExplanation(TextFormat.RAW), + ) + } + } + + private fun isInDesignSystemModule(projectName: String): Boolean { + return projectName.contains("design-system") + } + } + + companion object { + private const val RAW_M3_SLIDER_NAME = "Slider" + + val NO_RAW_M3_SLIDER_USAGE = Issue + .create( + id = "NoRawM3SliderUsage", + briefDescription = "Use DaxSlider instead of raw Material3 Slider", + explanation = """ + Use the DuckDuckGo design system `DaxSlider` (com.duckduckgo.common.ui.compose.slider.DaxSlider) \ + instead of the raw Material3 `Slider` composable. + + Raw M3 Slider bypasses the design system's color and dimension tokens, and draws value labels, \ + tick marks and stop indicators that DuckDuckGo sliders do not use. Using `DaxSlider` ensures \ + visual consistency across the app. + """.trimIndent(), + moreInfo = "", + category = CUSTOM_LINT_CHECKS, + priority = 6, + severity = Severity.ERROR, + androidSpecific = true, + implementation = Implementation( + NoRawM3SliderUsageDetector::class.java, + EnumSet.of(Scope.JAVA_FILE, Scope.TEST_SOURCES), + ), + ) + } +} diff --git a/lint-rules/src/test/java/com/duckduckgo/lint/ui/NoRawM3SliderUsageDetectorTest.kt b/lint-rules/src/test/java/com/duckduckgo/lint/ui/NoRawM3SliderUsageDetectorTest.kt new file mode 100644 index 000000000000..311e264792bf --- /dev/null +++ b/lint-rules/src/test/java/com/duckduckgo/lint/ui/NoRawM3SliderUsageDetectorTest.kt @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2026 DuckDuckGo + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.duckduckgo.lint.ui + +import com.android.tools.lint.checks.infrastructure.TestFiles +import com.android.tools.lint.checks.infrastructure.TestLintTask.lint +import com.duckduckgo.lint.ui.NoRawM3SliderUsageDetector.Companion.NO_RAW_M3_SLIDER_USAGE +import org.junit.Test + +class NoRawM3SliderUsageDetectorTest { + + private val m3SliderStub = TestFiles.kotlin( + """ + package androidx.compose.material3 + + import androidx.compose.runtime.Composable + + @Composable + fun Slider(value: Float, onValueChange: (Float) -> Unit) {} + """.trimIndent() + ).indented() + + private val composableStub = TestFiles.kotlin( + """ + package androidx.compose.runtime + + annotation class Composable + """.trimIndent() + ).indented() + + @Test + fun whenRawM3SliderUsedThenError() { + lint() + .files( + TestFiles.kt( + """ + package com.example.feature + + import androidx.compose.material3.Slider + import androidx.compose.runtime.Composable + + @Composable + fun MyScreen() { + Slider(value = 0f, onValueChange = {}) + } + """.trimIndent() + ).indented(), + m3SliderStub, + composableStub, + ) + .allowCompilationErrors() + .issues(NO_RAW_M3_SLIDER_USAGE) + .run() + .expectErrorCount(1) + } + + @Test + fun whenDaxSliderUsedThenNoError() { + lint() + .files( + TestFiles.kt( + """ + package com.example.feature + + import androidx.compose.runtime.Composable + + @Composable + fun DaxSlider(value: Float, onValueChange: (Float) -> Unit) {} + + @Composable + fun MyScreen() { + DaxSlider(value = 0f, onValueChange = {}) + } + """.trimIndent() + ).indented(), + composableStub, + ) + .issues(NO_RAW_M3_SLIDER_USAGE) + .run() + .expectClean() + } + + @Test + fun whenNonM3FunctionNamedSliderThenNoError() { + lint() + .files( + TestFiles.kt( + """ + package com.example.feature + + import androidx.compose.runtime.Composable + + @Composable + fun Slider(value: Float, onValueChange: (Float) -> Unit) {} + + @Composable + fun MyScreen() { + Slider(value = 0f, onValueChange = {}) + } + """.trimIndent() + ).indented(), + composableStub, + ) + .issues(NO_RAW_M3_SLIDER_USAGE) + .run() + .expectClean() + } +}