Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,32 @@
android:layout_height="wrap_content"
app:primaryText="Slider" />

<com.duckduckgo.common.ui.internal.ui.PlatformLabelView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/keyline_1"
android:layout_marginBottom="@dimen/keyline_1"
app:platformType="view" />

<com.google.android.material.slider.Slider
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/keyline_4"
android:layout_marginEnd="@dimen/keyline_4"
app:labelBehavior="gone" />

</LinearLayout>
<com.duckduckgo.common.ui.internal.ui.PlatformLabelView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/keyline_2"
android:layout_marginBottom="@dimen/keyline_1"
app:platformType="compose" />

<androidx.compose.ui.platform.ComposeView
android:id="@+id/composeDaxSlider"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/keyline_4"
android:layout_marginEnd="@dimen/keyline_4" />

</LinearLayout>
Original file line number Diff line number Diff line change
@@ -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<Float> = 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<Float> = 0f..1f,
steps: Int = 0,
) {
var sliderValue by remember { mutableFloatStateOf(value) }
DaxSlider(
value = sliderValue,
onValueChange = { sliderValue = it },
enabled = enabled,
valueRange = valueRange,
steps = steps,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
@@ -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),
),
)
}
}
Loading
Loading