diff --git a/android-design-system/design-system-internal/src/main/java/com/duckduckgo/common/ui/internal/ui/component/textinput/ComponentTextInputFragment.kt b/android-design-system/design-system-internal/src/main/java/com/duckduckgo/common/ui/internal/ui/component/textinput/ComponentTextInputFragment.kt index 676442db58da..77fd28f8951e 100644 --- a/android-design-system/design-system-internal/src/main/java/com/duckduckgo/common/ui/internal/ui/component/textinput/ComponentTextInputFragment.kt +++ b/android-design-system/design-system-internal/src/main/java/com/duckduckgo/common/ui/internal/ui/component/textinput/ComponentTextInputFragment.kt @@ -18,20 +18,57 @@ package com.duckduckgo.common.ui.internal.ui.component.textinput import android.annotation.SuppressLint import android.os.Bundle +import android.text.Editable import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.text.input.selectAll +import androidx.compose.material3.Button +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp import androidx.fragment.app.Fragment +import com.duckduckgo.common.ui.compose.text.DaxText +import com.duckduckgo.common.ui.compose.textfield.DaxSecureTextField +import com.duckduckgo.common.ui.compose.textfield.DaxTextField +import com.duckduckgo.common.ui.compose.textfield.DaxTextFieldDefaults +import com.duckduckgo.common.ui.compose.textfield.DaxTextFieldInputMode +import com.duckduckgo.common.ui.compose.textfield.DaxTextFieldLineLimits +import com.duckduckgo.common.ui.compose.textfield.DaxTextFieldTrailingIconScope import com.duckduckgo.common.ui.internal.databinding.ComponentTextInputViewBinding +import com.duckduckgo.common.ui.internal.ui.appComponentsViewModel +import com.duckduckgo.common.ui.internal.ui.setupThemedComposeView import com.duckduckgo.common.ui.view.text.TextInput.Action +import com.duckduckgo.common.utils.text.TextChangedWatcher import com.duckduckgo.mobile.android.R import com.google.android.material.snackbar.Snackbar +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import com.duckduckgo.common.ui.DuckDuckGoTheme as AppTheme @SuppressLint("NoFragment") // we don't use DI here class ComponentTextInputFragment : Fragment() { + private val appComponentsViewModel by appComponentsViewModel() private lateinit var binding: ComponentTextInputViewBinding + private var textChangedWatcher: TextChangedWatcher? = null + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -41,6 +78,7 @@ class ComponentTextInputFragment : Fragment() { return binding.root } + @Suppress("DenyListedApi") override fun onViewCreated( view: View, savedInstanceState: Bundle?, @@ -57,7 +95,37 @@ class ComponentTextInputFragment : Fragment() { binding.outlinedinputtext31.onAction { toastOnClick(it) } binding.outlinedinputtext32.onAction { toastOnClick(it) } binding.outlinedinputtext33.onAction { toastOnClick(it) } + binding.outlinedinputtext41.onAction { toastOnClick(it) } binding.outlinedinputtext21.error = "This is an error" + binding.outlinedinputtext41.error = "This is an error" + + textChangedWatcher = object : TextChangedWatcher() { + override fun afterTextChanged(editable: Editable) { + binding.outlinedinputtext27.error = if (editable.toString() != "Compose") { + "Text must be 'Compose'" + } else { + null + } + } + }.also { + binding.outlinedinputtext27.addTextChangedListener(it) + } + binding.outlinedinputtext28.setSelectAllOnFocus(true) + + binding.button1.setOnClickListener { + binding.outlinedinputtext29.requestFocus() + } + + val isDarkTheme = runBlocking { appComponentsViewModel.themeFlow.first() } == AppTheme.DARK + setupComposeViews(view, isDarkTheme) + } + + override fun onDestroyView() { + textChangedWatcher?.let { + binding.outlinedinputtext27.removeTextChangedListener(it) + } + textChangedWatcher = null + super.onDestroyView() } private fun toastOnClick(action: Action) = when (action) { @@ -65,4 +133,421 @@ class ComponentTextInputFragment : Fragment() { Snackbar.make(binding.root, "Element clicked", Snackbar.LENGTH_SHORT).show() } } + + @Suppress("LongMethod") + private fun setupComposeViews( + view: View, + isDarkTheme: Boolean, + ) { + // Hint text + view.setupThemedComposeView(id = com.duckduckgo.common.ui.internal.R.id.compose_text_input_1, isDarkTheme = isDarkTheme) { + val state = rememberTextFieldState() + DaxTextField( + state = state, + label = "Hint text", + ) + } + + // Single line editable text + view.setupThemedComposeView(id = com.duckduckgo.common.ui.internal.R.id.compose_text_input_3, isDarkTheme = isDarkTheme) { + val state = + rememberTextFieldState( + "This is an editable text! It has a very long text to show how it behaves when " + + "the text is too long to fit in a single line.\n\nIt is restricted to a single line.", + ) + DaxTextField( + state = state, + label = "Single line editable text", + lineLimits = DaxTextFieldLineLimits.SingleLine, + ) + } + + // Multi line editable text + view.setupThemedComposeView(id = com.duckduckgo.common.ui.internal.R.id.compose_text_input_2, isDarkTheme = isDarkTheme) { + val state = + rememberTextFieldState( + "This is an editable text! It has a very long text to show how it behaves when " + + "the text is too long to fit in a single line.\n\nIt can include multiline text.", + ) + DaxTextField( + state = state, + label = "Multi line editable text", + lineLimits = DaxTextFieldLineLimits.MultiLine, + ) + } + + // Form mode editable text + view.setupThemedComposeView(id = com.duckduckgo.common.ui.internal.R.id.compose_text_input_40, isDarkTheme = isDarkTheme) { + val state = + rememberTextFieldState( + "This is an editable text! It has a very long text to show how it behaves when " + + "the text is too long to fit in a single line.\n\nIt can include multiline text. Form mode is 3 lines minimum", + ) + DaxTextField( + state = state, + label = "Form mode editable text", + lineLimits = DaxTextFieldLineLimits.Form, + ) + } + + // Non-editable text full click listener with end icon + view.setupThemedComposeView(id = com.duckduckgo.common.ui.internal.R.id.compose_text_input_30, isDarkTheme = isDarkTheme) { + val state = rememberTextFieldState("Non-editable text full click listener with end icon.") + + val interactionSource = remember { MutableInteractionSource() } + val pressed by interactionSource.collectIsPressedAsState() + + LaunchedEffect(pressed) { + if (pressed) { + toastOnClick(Action.PerformEndAction) + } + } + + DaxTextField( + state = state, + label = "Non-editable text full click listener with end icon", + trailingIcon = { + DaxTextFieldTrailingIconScope.DaxTextFieldTrailingIcon( + painter = painterResource(R.drawable.ic_copy_24), + contentDescription = "Copy", + onClick = { + toastOnClick(Action.PerformEndAction) + }, + ) + }, + inputMode = DaxTextFieldInputMode.ReadOnly, + lineLimits = DaxTextFieldLineLimits.SingleLine, + interactionSource = interactionSource, + ) + } + + // Non-editable text full click listener + view.setupThemedComposeView(id = com.duckduckgo.common.ui.internal.R.id.compose_text_input_31, isDarkTheme = isDarkTheme) { + val state = rememberTextFieldState("Non-editable text full click listener.") + + val interactionSource = remember { MutableInteractionSource() } + val pressed by interactionSource.collectIsPressedAsState() + + LaunchedEffect(pressed) { + if (pressed) { + toastOnClick(Action.PerformEndAction) + } + } + + DaxTextField( + state = state, + label = "Non-editable text full click listener", + inputMode = DaxTextFieldInputMode.ReadOnly, + lineLimits = DaxTextFieldLineLimits.SingleLine, + interactionSource = interactionSource, + ) + } + + // Non-editable text with line truncation and end icon + view.setupThemedComposeView(id = com.duckduckgo.common.ui.internal.R.id.compose_text_input_32, isDarkTheme = isDarkTheme) { + val state = + rememberTextFieldState( + "Non-editable text with line truncation and end icon. It has a very long text to " + + "show how it behaves when the text is too long to fit in a single line.", + ) + DaxTextField( + state = state, + label = "Non-editable text with line truncation and end icon", + trailingIcon = { + DaxTextFieldTrailingIconScope.DaxTextFieldTrailingIcon( + painter = painterResource(R.drawable.ic_copy_24), + contentDescription = "Copy", + onClick = { + toastOnClick(Action.PerformEndAction) + }, + ) + }, + inputMode = DaxTextFieldInputMode.ReadOnly, + lineLimits = DaxTextFieldLineLimits.SingleLine, + ) + } + + // Non-editable text with line truncation + view.setupThemedComposeView(id = com.duckduckgo.common.ui.internal.R.id.compose_text_input_33, isDarkTheme = isDarkTheme) { + val state = + rememberTextFieldState( + "Non-editable text with line truncation. It has a very long text to show how it " + + "behaves when the text is too long to fit in a single line.", + ) + + DaxTextField( + state = state, + label = "Non-editable text with line truncation", + inputMode = DaxTextFieldInputMode.ReadOnly, + lineLimits = DaxTextFieldLineLimits.SingleLine, + ) + } + + // Non-editable text with end icon + view.setupThemedComposeView(id = com.duckduckgo.common.ui.internal.R.id.compose_text_input_4, isDarkTheme = isDarkTheme) { + val state = rememberTextFieldState("This is not editable.") + DaxTextField( + state = state, + label = "Non-editable text with end icon", + trailingIcon = { + DaxTextFieldTrailingIconScope.DaxTextFieldTrailingIcon( + painter = painterResource(R.drawable.ic_copy_24), + contentDescription = "Copy", + onClick = { + toastOnClick(Action.PerformEndAction) + }, + ) + }, + inputMode = DaxTextFieldInputMode.ReadOnly, + lineLimits = DaxTextFieldLineLimits.SingleLine, + ) + } + + // Non-editable text without end icon + view.setupThemedComposeView(id = com.duckduckgo.common.ui.internal.R.id.compose_text_input_5, isDarkTheme = isDarkTheme) { + val state = + rememberTextFieldState( + "This is not editable and has no icon. Lorem ipsum dolor sit amet, consectetur " + + "adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + ) + DaxTextField( + state = state, + label = "Non-editable text without end icon", + inputMode = DaxTextFieldInputMode.ReadOnly, + ) + } + + // Editable password that fits in one line + view.setupThemedComposeView(id = com.duckduckgo.common.ui.internal.R.id.compose_text_input_6, isDarkTheme = isDarkTheme) { + val state = rememberTextFieldState("Loremipsumolor") + + DaxSecureTextField( + state = state, + label = "Editable password that fits in one line", + ) + } + + // Editable password that doesn't fit in one line + view.setupThemedComposeView(id = com.duckduckgo.common.ui.internal.R.id.compose_text_input_9, isDarkTheme = isDarkTheme) { + val state = rememberTextFieldState( + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do " + + "eiusmod tempor incididunt ut labore.", + ) + + DaxSecureTextField( + state = state, + label = "Editable password that doesn't fit in one line", + ) + } + + // Non-editable password + view.setupThemedComposeView(id = com.duckduckgo.common.ui.internal.R.id.compose_text_input_8, isDarkTheme = isDarkTheme) { + val state = rememberTextFieldState( + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do " + + "eiusmod tempor incididunt ut labore.", + ) + + DaxSecureTextField( + state = state, + label = "Non-editable password", + inputMode = DaxTextFieldInputMode.ReadOnly, + ) + } + + // Non-editable password with icon + view.setupThemedComposeView(id = com.duckduckgo.common.ui.internal.R.id.compose_text_input_20, isDarkTheme = isDarkTheme) { + val state = rememberTextFieldState( + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do " + + "eiusmod tempor incididunt ut labore.", + ) + + DaxSecureTextField( + state = state, + label = "Non-editable password with icon", + trailingIcon = { + DaxTextFieldTrailingIconScope.DaxTextFieldTrailingIcon( + painter = painterResource(R.drawable.ic_copy_24), + contentDescription = "Copy", + onClick = { + toastOnClick(Action.PerformEndAction) + }, + ) + }, + inputMode = DaxTextFieldInputMode.ReadOnly, + ) + } + + // Error + view.setupThemedComposeView(id = com.duckduckgo.common.ui.internal.R.id.compose_text_input_21, isDarkTheme = isDarkTheme) { + val state = rememberTextFieldState("This is an error") + DaxTextField( + state = state, + label = "Error", + error = "This is an error", + lineLimits = DaxTextFieldLineLimits.SingleLine, + ) + } + + // Non-editable text with end icon in error state + view.setupThemedComposeView(id = com.duckduckgo.common.ui.internal.R.id.compose_text_input_41, isDarkTheme = isDarkTheme) { + val state = rememberTextFieldState("Non-editable text with end icon in error state") + + DaxTextField( + state = state, + label = "Non-editable text full click listener with end icon", + error = "This is an error", + trailingIcon = { + DaxTextFieldTrailingIconScope.DaxTextFieldTrailingIcon( + painter = painterResource(R.drawable.ic_copy_24), + contentDescription = "Copy", + onClick = { + toastOnClick(Action.PerformEndAction) + }, + ) + }, + inputMode = DaxTextFieldInputMode.ReadOnly, + lineLimits = DaxTextFieldLineLimits.SingleLine, + ) + } + + // Disabled text input + view.setupThemedComposeView(id = com.duckduckgo.common.ui.internal.R.id.compose_text_input_22, isDarkTheme = isDarkTheme) { + val state = rememberTextFieldState("This input is disabled") + DaxTextField( + state = state, + label = "Disabled text input", + inputMode = DaxTextFieldInputMode.Disabled, + lineLimits = DaxTextFieldLineLimits.SingleLine, + ) + } + + // Disabled multi line input + view.setupThemedComposeView(id = com.duckduckgo.common.ui.internal.R.id.compose_text_input_23, isDarkTheme = isDarkTheme) { + val state = rememberTextFieldState("This input is disabled") + DaxTextField( + state = state, + label = "Disabled multi line input", + inputMode = DaxTextFieldInputMode.Disabled, + lineLimits = DaxTextFieldLineLimits.MultiLine, + ) + } + + // Disabled password + view.setupThemedComposeView(id = com.duckduckgo.common.ui.internal.R.id.compose_text_input_24, isDarkTheme = isDarkTheme) { + val state = rememberTextFieldState("This password input is disabled") + + DaxSecureTextField( + state = state, + label = "Disabled password", + inputMode = DaxTextFieldInputMode.Disabled, + ) + } + + // IP Address + view.setupThemedComposeView(id = com.duckduckgo.common.ui.internal.R.id.compose_text_input_25, isDarkTheme = isDarkTheme) { + val state = rememberTextFieldState("192.168.1.1") + DaxTextField( + state = state, + label = "IP Address", + keyboardOptions = DaxTextFieldDefaults.IpAddressKeyboardOptions, + inputTransformation = DaxTextFieldDefaults.IpAddressInputTransformation(), + lineLimits = DaxTextFieldLineLimits.SingleLine, + ) + } + + // URL + view.setupThemedComposeView(id = com.duckduckgo.common.ui.internal.R.id.compose_text_input_26, isDarkTheme = isDarkTheme) { + val state = rememberTextFieldState("https://www.duckduckgo.com") + DaxTextField( + state = state, + label = "URL", + keyboardOptions = DaxTextFieldDefaults.UrlKeyboardOptions, + lineLimits = DaxTextFieldLineLimits.SingleLine, + ) + } + + // Observable text - option 1 + view.setupThemedComposeView(id = com.duckduckgo.common.ui.internal.R.id.compose_text_input_27a, isDarkTheme = isDarkTheme) { + val state = rememberTextFieldState("") + + DaxTextField( + state = state, + label = "Observable text - option 1", + error = if (state.text.isNotEmpty() && state.text != "Compose") { + "Text must be 'Compose'" + } else { + null + }, + lineLimits = DaxTextFieldLineLimits.SingleLine, + ) + } + + // Observable text - option 2 + view.setupThemedComposeView(id = com.duckduckgo.common.ui.internal.R.id.compose_text_input_27b, isDarkTheme = isDarkTheme) { + val state = rememberTextFieldState("") + var error by remember { mutableStateOf(null) } + + // provides a way to observe text changes as recommended by + // https://developer.android.com/develop/ui/compose/text/migrate-state-based#conforming-approach + LaunchedEffect(state) { + snapshotFlow { state.text.toString() }.collectLatest { + // can call a view model function here with the new text value + // for demo purposes, we'll show an error + error = if (it.isNotEmpty() && it != "Compose") { + "Text must be 'Compose'" + } else { + null + } + } + } + + DaxTextField( + state = state, + label = "Observable text - option 2", + error = error, + lineLimits = DaxTextFieldLineLimits.SingleLine, + ) + } + + // Autoselect text on focus + view.setupThemedComposeView(id = com.duckduckgo.common.ui.internal.R.id.compose_text_input_28, isDarkTheme = isDarkTheme) { + val state = rememberTextFieldState("Tap to focus and select all text") + + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + LaunchedEffect(isFocused) { + if (isFocused) { + state.edit { + selectAll() + } + } + } + + DaxTextField( + state = state, + label = "Autoselect text on focus", + interactionSource = interactionSource, + lineLimits = DaxTextFieldLineLimits.SingleLine, + ) + } + + // Focus programmatically + view.setupThemedComposeView(id = com.duckduckgo.common.ui.internal.R.id.compose_text_input_29, isDarkTheme = isDarkTheme) { + val focusRequester = remember { FocusRequester() } + val state = rememberTextFieldState("") + + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Button(onClick = { focusRequester.requestFocus() }) { + DaxText(text = "Click to focus the input below") + } + + DaxTextField( + state = state, + label = "Programmatically focusable text input", + modifier = Modifier.focusRequester(focusRequester), + lineLimits = DaxTextFieldLineLimits.SingleLine, + ) + } + } + } } diff --git a/android-design-system/design-system-internal/src/main/res/layout/component_text_input_view.xml b/android-design-system/design-system-internal/src/main/res/layout/component_text_input_view.xml index bfb34341fb41..69cc0f211fda 100644 --- a/android-design-system/design-system-internal/src/main/res/layout/component_text_input_view.xml +++ b/android-design-system/design-system-internal/src/main/res/layout/component_text_input_view.xml @@ -29,185 +29,807 @@ android:layout_height="match_parent" android:orientation="vertical"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android-design-system/design-system/src/main/java/com/duckduckgo/common/ui/compose/textfield/DaxSecureTextField.kt b/android-design-system/design-system/src/main/java/com/duckduckgo/common/ui/compose/textfield/DaxSecureTextField.kt new file mode 100644 index 000000000000..0a0187cc1d84 --- /dev/null +++ b/android-design-system/design-system/src/main/java/com/duckduckgo/common/ui/compose/textfield/DaxSecureTextField.kt @@ -0,0 +1,476 @@ +/* + * Copyright (c) 2025 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.common.ui.compose.textfield + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicSecureTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.TextObfuscationMode +import androidx.compose.foundation.text.selection.LocalTextSelectionColors +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldLabelPosition +import androidx.compose.material3.Typography +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +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.draw.alpha +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.error +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewFontScale +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.sp +import com.duckduckgo.common.ui.compose.text.DaxText +import com.duckduckgo.common.ui.compose.theme.DuckDuckGoTheme +import com.duckduckgo.common.ui.compose.theme.asTextStyle +import com.duckduckgo.common.ui.compose.tools.PreviewBox +import com.duckduckgo.mobile.android.R + +/** + * Text field component for the DuckDuckGo design system for entering passwords and other sensitive information. + * It's a single line text field that obscures the input by default, with an option to toggle visibility. + * + * @param state The state of the text field that is used to read and write the text and selection. + * @param modifier Optional [Modifier] for this text field. Can be used request focus via [Modifier.focusRequester] for example. + * @param label Optional label/hint text to display inside the text field when it's empty or above the text field when it has text or is focused. + * @param inputMode Input mode for the text field, such as editable, read-only or disabled. See [DaxTextFieldInputMode] for details. + * @param error Optional error message to display below the text field. If provided, the text field will be styled to indicate an error. + * @param keyboardOptions Software keyboard options that contains configuration such as [KeyboardType] and [ImeAction]. + * See [DaxTextFieldDefaults.TextKeyboardOptions], [DaxTextFieldDefaults.IpAddressKeyboardOptions] and + * [DaxTextFieldDefaults.UrlKeyboardOptions] for examples. + * @param interactionSource Optional interaction source for observing and emitting interaction events. + * You can use this to observe focus, pressed, hover and drag events. + * @param trailingIcon Optional trailing icon composable to display at the end of the text field. + * Use [DaxTextFieldTrailingIconScope.DaxTextFieldTrailingIcon] to create the icon. + * + * Asana Task: https://app.asana.com/1/137249556945/project/1202857801505092/task/1212213756433276?focus=true + * Figma reference: https://www.figma.com/design/BOHDESHODUXK7wSRNBOHdu/%F0%9F%A4%96-Android-Components?m=auto&node-id=3202-5150 + */ +@Composable +fun DaxSecureTextField( + state: TextFieldState, + modifier: Modifier = Modifier, + label: String? = null, + inputMode: DaxTextFieldInputMode = DaxTextFieldInputMode.Editable, + error: String? = null, + keyboardOptions: KeyboardOptions = DaxTextFieldDefaults.PasswordKeyboardOptions, + interactionSource: MutableInteractionSource? = null, + trailingIcon: (@Composable DaxTextFieldTrailingIconScope.() -> Unit)? = null, +) { + var isPasswordVisible by remember { mutableStateOf(false) } + + DaxSecureTextField( + state = state, + isPasswordVisible = isPasswordVisible, + onShowHidePasswordIconClick = { + isPasswordVisible = !isPasswordVisible + }, + modifier = modifier, + label = label, + inputMode = inputMode, + error = error, + keyboardOptions = keyboardOptions, + interactionSource = interactionSource, + trailingIcon = trailingIcon, + ) +} + +/** + * Text field component for the DuckDuckGo design system for entering passwords and other sensitive information. + * It's a single line text field that obscures the input by default, with an option to toggle visibility. + * + * @param state The state of the text field that is used to read and write the text and selection. + * @param isPasswordVisible Boolean flag indicating whether the password is currently visible or obscured. + * You should manage this state and update it accordingly when [onShowHidePasswordIconClick] is called. + * @param onShowHidePasswordIconClick Callback for when the show/hide password icon is clicked by the user. + * You should update the [isPasswordVisible] state accordingly. + * @param modifier Optional [Modifier] for this text field. Can be used request focus via [Modifier.focusRequester] for example. + * @param label Optional label/hint text to display inside the text field when it's empty or above the text field when it has text or is focused. + * @param inputMode Input mode for the text field, such as editable, read-only or disabled. See [DaxTextFieldInputMode] for details. + * @param error Optional error message to display below the text field. If provided, the text field will be styled to indicate an error. + * @param keyboardOptions Software keyboard options that contains configuration such as [KeyboardType] and [ImeAction]. + * See [DaxTextFieldDefaults.TextKeyboardOptions], [DaxTextFieldDefaults.IpAddressKeyboardOptions] and + * [DaxTextFieldDefaults.UrlKeyboardOptions] for examples. + * @param interactionSource Optional interaction source for observing and emitting interaction events. + * You can use this to observe focus, pressed, hover and drag events. + * @param trailingIcon Optional trailing icon composable to display at the end of the text field. + * Use [DaxTextFieldTrailingIconScope.DaxTextFieldTrailingIcon] to create the icon. + * + * Asana Task: https://app.asana.com/1/137249556945/project/1202857801505092/task/1212213756433276?focus=true + * Figma reference: https://www.figma.com/design/BOHDESHODUXK7wSRNBOHdu/%F0%9F%A4%96-Android-Components?m=auto&node-id=3202-5150 + */ +@Composable +internal fun DaxSecureTextField( + state: TextFieldState, + isPasswordVisible: Boolean, + onShowHidePasswordIconClick: () -> Unit, + modifier: Modifier = Modifier, + label: String? = null, + inputMode: DaxTextFieldInputMode = DaxTextFieldInputMode.Editable, + error: String? = null, + keyboardOptions: KeyboardOptions = DaxTextFieldDefaults.PasswordKeyboardOptions, + interactionSource: MutableInteractionSource? = null, + trailingIcon: (@Composable DaxTextFieldTrailingIconScope.() -> Unit)? = null, +) { + // needed by the OutlinedTextField container + val internalInteractionSource = interactionSource ?: remember { MutableInteractionSource() } + val daxTextFieldColors = daxTextFieldColors() + + // combine the password visibility toggle icon with any provided trailing icon + val trailingIconCombined: @Composable (() -> Unit)? = { + Row { + DaxTextFieldTrailingIconScope.DaxTextFieldTrailingIcon( + painter = painterResource( + if (isPasswordVisible) { + R.drawable.ic_eye_closed_24 + } else { + R.drawable.ic_eye_24 + }, + ), + contentDescription = null, + onClick = onShowHidePasswordIconClick, + enabled = inputMode == DaxTextFieldInputMode.Editable || inputMode == DaxTextFieldInputMode.ReadOnly, + ) + + trailingIcon?.let { + DaxTextFieldTrailingIconScope.it() + } + } + } + + // override is needed as TextField uses MaterialTheme.typography internally for animating the label text style + MaterialTheme( + typography = Typography( + bodySmall = DuckDuckGoTheme.typography.caption.asTextStyle.copy( + fontFamily = FontFamily.Default, + color = DuckDuckGoTheme.colors.text.secondary, + ), + bodyLarge = DuckDuckGoTheme.typography.body1.asTextStyle.copy( + fontFamily = FontFamily.Default, + color = DuckDuckGoTheme.colors.text.secondary, + ), + ), + ) { + CompositionLocalProvider(LocalTextSelectionColors provides daxTextFieldColors.textSelectionColors) { + // need to use BasicSecureTextField over OutlinedSecureTextField as the latter does not support readOnly mode + BasicSecureTextField( + state = state, + modifier = + modifier + .alpha( + if (inputMode == DaxTextFieldInputMode.Editable || inputMode == DaxTextFieldInputMode.ReadOnly) { + DaxTextFieldDefaults.ALPHA_ENABLED + } else { + DaxTextFieldDefaults.ALPHA_DISABLED + }, + ) + .then( + if (label != null) { + Modifier + // Merge semantics at the beginning of the modifier chain to ensure + // padding is considered part of the text field. + .semantics(mergeDescendants = true) {} + .padding(top = minimizedLabelHalfHeight()) + } else { + Modifier + }, + ) + .then( + if (!error.isNullOrBlank()) { + Modifier.semantics { error(error) } + } else { + Modifier + }, + ) + .defaultMinSize( + minWidth = OutlinedTextFieldDefaults.MinWidth, + minHeight = OutlinedTextFieldDefaults.MinHeight, + ), + enabled = inputMode == DaxTextFieldInputMode.Editable || inputMode == DaxTextFieldInputMode.ReadOnly, + readOnly = inputMode == DaxTextFieldInputMode.ReadOnly || inputMode == DaxTextFieldInputMode.Disabled, + textStyle = DuckDuckGoTheme.typography.body1.asTextStyle.copy( + color = DuckDuckGoTheme.textColors.primary, + ), + cursorBrush = SolidColor(daxTextFieldColors.cursorColor), + keyboardOptions = keyboardOptions, + interactionSource = internalInteractionSource, + textObfuscationMode = if (isPasswordVisible) { + TextObfuscationMode.Visible + } else { + TextObfuscationMode.Hidden + }, + textObfuscationCharacter = DefaultObfuscationCharacter, + decorator = + OutlinedTextFieldDefaults.decorator( + state = state, + enabled = inputMode == DaxTextFieldInputMode.Editable || inputMode == DaxTextFieldInputMode.ReadOnly, + lineLimits = TextFieldLineLimits.SingleLine, + outputTransformation = null, + interactionSource = internalInteractionSource, + labelPosition = TextFieldLabelPosition.Attached(), + label = if (!label.isNullOrBlank()) { + { + // can't use DaxText here as TextField applies its own style to label and the Text style needs to be Unspecified + Text( + text = label, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + color = LocalContentColor.current, + ) + } + } else { + null + }, + trailingIcon = trailingIconCombined, + supportingText = if (!error.isNullOrBlank()) { + { + DaxText( + text = error, + style = DuckDuckGoTheme.typography.caption, + color = DuckDuckGoTheme.textColors.destructive, + ) + } + } else { + null + }, + isError = !error.isNullOrBlank(), + colors = daxTextFieldColors, + contentPadding = OutlinedTextFieldDefaults.contentPadding(), + container = { + OutlinedTextFieldDefaults.Container( + enabled = inputMode == DaxTextFieldInputMode.Editable || inputMode == DaxTextFieldInputMode.ReadOnly, + isError = !error.isNullOrBlank(), + interactionSource = internalInteractionSource, + colors = daxTextFieldColors, + shape = DuckDuckGoTheme.shapes.small, + ) + }, + ), + ) + } + } +} + +@Composable +private fun minimizedLabelHalfHeight(): Dp { + val compositionLocalValue = MaterialTheme.typography.bodySmall.lineHeight + val fallbackValue = 16.sp + val value = if (compositionLocalValue.isSp) compositionLocalValue else fallbackValue + return with(LocalDensity.current) { value.toDp() / 2 } +} + +private const val DefaultObfuscationCharacter: Char = '\u2022' + +@PreviewLightDark +@Composable +private fun DaxSecureTextFieldEmptyPreview() { + DaxSecureTextFieldPreviewBox { + DaxSecureTextField( + state = TextFieldState(), + label = "Enter password", + isPasswordVisible = false, + onShowHidePasswordIconClick = {}, + ) + } +} + +@PreviewFontScale +@PreviewLightDark +@Composable +private fun DaxSecureTextFieldWithPlainTextPreview() { + DaxSecureTextFieldPreviewBox { + DaxSecureTextField( + state = TextFieldState(initialText = "SecurePassword123"), + label = "Enter password", + isPasswordVisible = true, + onShowHidePasswordIconClick = {}, + ) + } +} + +@PreviewFontScale +@PreviewLightDark +@Composable +private fun DaxSecureTextFieldWithObscureTextPreview() { + DaxSecureTextFieldPreviewBox { + DaxSecureTextField( + state = TextFieldState(initialText = "SecurePassword123"), + label = "Enter password", + isPasswordVisible = false, + onShowHidePasswordIconClick = {}, + ) + } +} + +@PreviewLightDark +@Composable +private fun DaxSecureTextFieldNoLabelPreview() { + DaxSecureTextFieldPreviewBox { + DaxSecureTextField( + state = TextFieldState(initialText = "SecurePassword123"), + isPasswordVisible = false, + onShowHidePasswordIconClick = {}, + ) + } +} + +@PreviewLightDark +@Composable +private fun DaxSecureTextFieldEditablePreview() { + DaxSecureTextFieldPreviewBox { + DaxSecureTextField( + state = TextFieldState(initialText = "SecurePassword123"), + isPasswordVisible = false, + onShowHidePasswordIconClick = {}, + label = "Enter password", + inputMode = DaxTextFieldInputMode.Editable, + ) + } +} + +@PreviewLightDark +@Composable +private fun DaxSecureTextFieldDisabledPreview() { + DaxSecureTextFieldPreviewBox { + DaxSecureTextField( + state = TextFieldState(initialText = "SecurePassword123"), + isPasswordVisible = false, + onShowHidePasswordIconClick = {}, + label = "Enter password", + inputMode = DaxTextFieldInputMode.Disabled, + ) + } +} + +@PreviewLightDark +@Composable +private fun DaxSecureTextFieldNonEditablePreview() { + DaxSecureTextFieldPreviewBox { + DaxSecureTextField( + state = TextFieldState(initialText = "SecurePassword123"), + isPasswordVisible = false, + onShowHidePasswordIconClick = {}, + label = "Read only password", + inputMode = DaxTextFieldInputMode.ReadOnly, + ) + } +} + +@PreviewLightDark +@Composable +private fun DaxSecureTextFieldWithErrorPreview() { + DaxSecureTextFieldPreviewBox { + DaxSecureTextField( + state = TextFieldState(initialText = "weak"), + isPasswordVisible = false, + onShowHidePasswordIconClick = {}, + label = "Enter password", + error = "Password must be at least 8 characters", + ) + } +} + +@PreviewLightDark +@Composable +private fun DaxSecureTextFieldWithTrailingIconPreview() { + DaxSecureTextFieldPreviewBox { + DaxSecureTextField( + state = TextFieldState(initialText = "SecurePassword123"), + isPasswordVisible = false, + onShowHidePasswordIconClick = {}, + label = "Enter password", + trailingIcon = { + DaxTextFieldTrailingIconScope.DaxTextFieldTrailingIcon( + painter = painterResource(R.drawable.ic_copy_24), + contentDescription = "Copy", + onClick = {}, + ) + }, + ) + } +} + +@PreviewLightDark +@Composable +private fun DaxSecureTextFieldEmptyWithTrailingIconPreview() { + DaxSecureTextFieldPreviewBox { + DaxSecureTextField( + state = TextFieldState(), + isPasswordVisible = false, + onShowHidePasswordIconClick = {}, + label = "Enter password", + trailingIcon = { + DaxTextFieldTrailingIconScope.DaxTextFieldTrailingIcon( + painter = painterResource(R.drawable.ic_copy_24), + contentDescription = "Copy", + onClick = { }, + ) + }, + ) + } +} + +@PreviewLightDark +@Composable +private fun DaxSecureTextFieldErrorWithTrailingIconPreview() { + DaxSecureTextFieldPreviewBox { + DaxSecureTextField( + state = TextFieldState(initialText = "weak"), + isPasswordVisible = false, + onShowHidePasswordIconClick = {}, + label = "Enter password", + error = "Password must contain uppercase, lowercase, and numbers", + trailingIcon = { + DaxTextFieldTrailingIconScope.DaxTextFieldTrailingIcon( + painter = painterResource(R.drawable.ic_copy_24), + contentDescription = "Copy", + onClick = {}, + ) + }, + ) + } +} + +@Composable +private fun DaxSecureTextFieldPreviewBox( + content: @Composable () -> Unit, +) { + DuckDuckGoTheme { + PreviewBox { + content() + } + } +} diff --git a/android-design-system/design-system/src/main/java/com/duckduckgo/common/ui/compose/textfield/DaxTextField.kt b/android-design-system/design-system/src/main/java/com/duckduckgo/common/ui/compose/textfield/DaxTextField.kt new file mode 100644 index 000000000000..c58bd9375e44 --- /dev/null +++ b/android-design-system/design-system/src/main/java/com/duckduckgo/common/ui/compose/textfield/DaxTextField.kt @@ -0,0 +1,542 @@ +/* + * Copyright (c) 2025 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.common.ui.compose.textfield + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.foundation.text.input.TextFieldBuffer +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.selection.LocalTextSelectionColors +import androidx.compose.foundation.text.selection.TextSelectionColors +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldColors +import androidx.compose.material3.TextFieldLabelPosition +import androidx.compose.material3.Typography +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Stable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewFontScale +import androidx.compose.ui.tooling.preview.PreviewLightDark +import com.duckduckgo.common.ui.compose.text.DaxText +import com.duckduckgo.common.ui.compose.theme.DuckDuckGoTheme +import com.duckduckgo.common.ui.compose.theme.Transparent +import com.duckduckgo.common.ui.compose.theme.asTextStyle +import com.duckduckgo.common.ui.compose.tools.PreviewBox +import com.duckduckgo.mobile.android.R + +/** + * Base text field component for the DuckDuckGo design system that allows user input. + * See also [DaxSecureTextField] for entering sensitive information like passwords. + * + * @param state The state of the text field that is used to read and write the text and selection. + * @param modifier Optional [Modifier] for this text field. Can be used request focus via [Modifier.focusRequester] for example. + * @param label Optional label/hint text to display inside the text field when it's empty or above the text field when it has text or is focused. + * @param lineLimits Line limits configuration for the text field, such as single-line, multi-line or form. See [DaxTextFieldLineLimits] for details. + * @param inputMode Input mode for the text field, such as editable, read-only or disabled. See [DaxTextFieldInputMode] for details. + * @param error Optional error message to display below the text field. If provided, the text field will be styled to indicate an error. + * @param keyboardOptions Software keyboard options that contains configuration such as [KeyboardType] and [ImeAction]. + * See [DaxTextFieldDefaults.TextKeyboardOptions], [DaxTextFieldDefaults.IpAddressKeyboardOptions] and + * [DaxTextFieldDefaults.UrlKeyboardOptions] for examples. + * @param inputTransformation Optional transformation to apply to the input text before it's written to the state. Can be used for input filtering. + * @param interactionSource Optional interaction source for observing and emitting interaction events. + * You can use this to observe focus, pressed, hover and drag events. + * @param trailingIcon Optional trailing icon composable to display at the end of the text field. + * Use [DaxTextFieldTrailingIconScope.DaxTextFieldTrailingIcon] to create the icon. + * + * Asana Task: https://app.asana.com/1/137249556945/project/1202857801505092/task/1212213756433276?focus=true + * Figma reference: https://www.figma.com/design/BOHDESHODUXK7wSRNBOHdu/%F0%9F%A4%96-Android-Components?m=auto&node-id=2691-3327 + */ +@Composable +fun DaxTextField( + state: TextFieldState, + modifier: Modifier = Modifier, + label: String? = null, + lineLimits: DaxTextFieldLineLimits = DaxTextFieldLineLimits.MultiLine, + inputMode: DaxTextFieldInputMode = DaxTextFieldInputMode.Editable, + error: String? = null, + keyboardOptions: KeyboardOptions = DaxTextFieldDefaults.TextKeyboardOptions, + inputTransformation: InputTransformation? = null, + interactionSource: MutableInteractionSource? = null, + trailingIcon: (@Composable DaxTextFieldTrailingIconScope.() -> Unit)? = null, +) { + val daxTextFieldColors = daxTextFieldColors() + + // override is needed as TextField uses MaterialTheme.typography internally for animating the label text style + MaterialTheme( + typography = Typography( + bodySmall = DuckDuckGoTheme.typography.caption.asTextStyle.copy( + fontFamily = FontFamily.Default, + color = DuckDuckGoTheme.textColors.secondary, + ), + bodyLarge = DuckDuckGoTheme.typography.body1.asTextStyle.copy( + fontFamily = FontFamily.Default, + color = DuckDuckGoTheme.textColors.secondary, + ), + ), + ) { + CompositionLocalProvider(LocalTextSelectionColors provides daxTextFieldColors.textSelectionColors) { + OutlinedTextField( + state = state, + modifier = modifier + .fillMaxWidth() + .alpha( + if (inputMode == DaxTextFieldInputMode.Editable || inputMode == DaxTextFieldInputMode.ReadOnly) { + DaxTextFieldDefaults.ALPHA_ENABLED + } else { + DaxTextFieldDefaults.ALPHA_DISABLED + }, + ), + enabled = when (inputMode) { + DaxTextFieldInputMode.Editable -> true + DaxTextFieldInputMode.ReadOnly -> true + DaxTextFieldInputMode.Disabled -> false + }, + readOnly = when (inputMode) { + DaxTextFieldInputMode.Editable -> false + DaxTextFieldInputMode.ReadOnly -> true + DaxTextFieldInputMode.Disabled -> true + }, + label = if (!label.isNullOrBlank()) { + { + // can't use DaxText here as TextField applies its own style to label and the Text style needs to be Unspecified + Text( + text = label, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + color = LocalContentColor.current, + ) + } + } else { + null + }, + labelPosition = TextFieldLabelPosition.Attached(), + textStyle = DuckDuckGoTheme.typography.body1.asTextStyle, + trailingIcon = trailingIcon?.let { + { + DaxTextFieldTrailingIconScope.it() + } + }, + isError = !error.isNullOrBlank(), + shape = DuckDuckGoTheme.shapes.small, + supportingText = if (!error.isNullOrBlank()) { + { + DaxText( + text = error, + style = DuckDuckGoTheme.typography.caption, + color = DuckDuckGoTheme.textColors.destructive, + ) + } + } else { + null + }, + keyboardOptions = keyboardOptions, + lineLimits = lineLimits.toLineLimits(), + inputTransformation = inputTransformation, + interactionSource = interactionSource, + colors = daxTextFieldColors, + ) + } + } +} + +/** + * Predefined configurations and types for [DaxTextFieldDefaults]. + */ +object DaxTextFieldDefaults { + + internal const val ALPHA_ENABLED = 1f + internal const val ALPHA_DISABLED = 0.4f + + val TextKeyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Text, + imeAction = ImeAction.Default, + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = true, + ) + + val PasswordKeyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Password, + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + ) + + val IpAddressKeyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Decimal, + imeAction = ImeAction.Default, + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + ) + + val UrlKeyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Default, + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + ) + + /** + * Input transformation that only allows digits and dots to be entered. + * Can be used for IP address input fields. + */ + class IpAddressInputTransformation : InputTransformation { + override fun TextFieldBuffer.transformInput() { + val filtered = asCharSequence().filter { it.isDigit() || it == '.' } + if (filtered.length != length) { + replace(0, length, filtered) + } + } + } +} + +object DaxTextFieldTrailingIconScope { + /** + * Represents a trailing icon for the text field. + * + * @param painter Painter for the icon to display. + * @param contentDescription Optional content description for accessibility. + * @param modifier Optional [Modifier] for this icon button. + * @param enabled Whether the icon button is enabled or disabled. + * @param onClick Optional callback that is triggered when the icon button is clicked. + */ + @Composable + fun DaxTextFieldTrailingIcon( + painter: Painter, + contentDescription: String?, + modifier: Modifier = Modifier, + enabled: Boolean = true, + onClick: (() -> Unit)? = null, + ) { + IconButton( + onClick = { onClick?.invoke() }, + enabled = enabled, + modifier = modifier, + ) { + Icon( + painter = painter, + contentDescription = contentDescription, + tint = DuckDuckGoTheme.iconColors.primary, + ) + } + } +} + +@Stable +enum class DaxTextFieldLineLimits { + /** + * The TextField will take up a single line and will scroll horizontally if the text is too long. + */ + SingleLine, + + /** + * The TextField will start with a one line height and then expand vertically as needed to accommodate the text. + */ + MultiLine, + + /** + * The TextField will always take at least 3 lines of height and then expand vertically as needed based on the input. + */ + Form, + + ; + + /** + * Converts this [DaxTextFieldLineLimits] to a [TextFieldLineLimits] used by the underlying TextField. + */ + fun toLineLimits(): TextFieldLineLimits = when (this) { + SingleLine -> TextFieldLineLimits.SingleLine + MultiLine -> TextFieldLineLimits.MultiLine(minHeightInLines = 1) + Form -> TextFieldLineLimits.MultiLine(minHeightInLines = 3) + } +} + +@Stable +enum class DaxTextFieldInputMode { + /** + * The TextField is editable by the user. + */ + Editable, + + /** + * The TextField is read-only and cannot be edited by the user. + */ + ReadOnly, + + /** + * The TextField is disabled and does not allow any interaction. + */ + Disabled, +} + +@Composable +internal fun daxTextFieldColors(): TextFieldColors = OutlinedTextFieldDefaults.colors( + focusedTextColor = DuckDuckGoTheme.textColors.primary, + unfocusedTextColor = DuckDuckGoTheme.textColors.primary, + disabledTextColor = DuckDuckGoTheme.textColors.primary, + errorTextColor = DuckDuckGoTheme.textColors.primary, + focusedContainerColor = Transparent, + unfocusedContainerColor = Transparent, + disabledContainerColor = Transparent, + errorContainerColor = Transparent, + focusedBorderColor = DuckDuckGoTheme.colors.brand.accentBlue, + unfocusedBorderColor = DuckDuckGoTheme.colors.textField.borders, + disabledBorderColor = DuckDuckGoTheme.colors.textField.borders, + errorBorderColor = DuckDuckGoTheme.textColors.destructive, + focusedLabelColor = DuckDuckGoTheme.colors.brand.accentBlue, + unfocusedLabelColor = DuckDuckGoTheme.textColors.secondary, + disabledLabelColor = DuckDuckGoTheme.textColors.secondary, + errorLabelColor = DuckDuckGoTheme.textColors.destructive, + focusedTrailingIconColor = DuckDuckGoTheme.iconColors.primary, + unfocusedTrailingIconColor = DuckDuckGoTheme.iconColors.primary, + disabledTrailingIconColor = DuckDuckGoTheme.iconColors.primary, + errorTrailingIconColor = DuckDuckGoTheme.textColors.destructive, + focusedSupportingTextColor = DuckDuckGoTheme.textColors.destructive, + unfocusedSupportingTextColor = DuckDuckGoTheme.textColors.destructive, + disabledSupportingTextColor = DuckDuckGoTheme.textColors.destructive, + errorSupportingTextColor = DuckDuckGoTheme.textColors.destructive, + cursorColor = DuckDuckGoTheme.colors.brand.accentBlue, + errorCursorColor = DuckDuckGoTheme.textColors.primary, + selectionColors = TextSelectionColors( + handleColor = DuckDuckGoTheme.colors.brand.accentBlue, + backgroundColor = DuckDuckGoTheme.colors.brand.accentBlue.copy(alpha = DaxTextFieldDefaults.ALPHA_DISABLED), + ), + focusedLeadingIconColor = DuckDuckGoTheme.iconColors.primary, + unfocusedLeadingIconColor = DuckDuckGoTheme.iconColors.primary, + disabledLeadingIconColor = DuckDuckGoTheme.iconColors.primary, + errorLeadingIconColor = DuckDuckGoTheme.textColors.destructive, + focusedPrefixColor = DuckDuckGoTheme.textColors.secondary, + unfocusedPrefixColor = DuckDuckGoTheme.textColors.secondary, + disabledPrefixColor = DuckDuckGoTheme.textColors.secondary, + errorPrefixColor = DuckDuckGoTheme.textColors.secondary, + focusedSuffixColor = DuckDuckGoTheme.textColors.secondary, + unfocusedSuffixColor = DuckDuckGoTheme.textColors.secondary, + disabledSuffixColor = DuckDuckGoTheme.textColors.secondary, + errorSuffixColor = DuckDuckGoTheme.textColors.secondary, + focusedPlaceholderColor = DuckDuckGoTheme.textColors.secondary, + unfocusedPlaceholderColor = DuckDuckGoTheme.textColors.secondary, + disabledPlaceholderColor = DuckDuckGoTheme.textColors.secondary, + errorPlaceholderColor = DuckDuckGoTheme.textColors.secondary, +) + +@PreviewLightDark +@Composable +private fun DaxTextFieldEmptyPreview() { + DaxTextFieldPreviewBox { + DaxTextField( + state = TextFieldState(), + label = "Enter text", + ) + } +} + +@PreviewFontScale +@PreviewLightDark +@Composable +private fun DaxTextFieldWithTextPreview() { + DaxTextFieldPreviewBox { + DaxTextField( + state = TextFieldState(initialText = "Sample text content"), + label = "Enter text", + ) + } +} + +@PreviewLightDark +@Composable +private fun DaxTextFieldNoLabelPreview() { + DaxTextFieldPreviewBox { + DaxTextField( + state = TextFieldState(initialText = "Text without label"), + ) + } +} + +@PreviewLightDark +@Composable +private fun DaxTextFieldSingleLinePreview() { + DaxTextFieldPreviewBox { + DaxTextField( + state = TextFieldState(initialText = "Single line text field"), + label = "Enter single line", + lineLimits = DaxTextFieldLineLimits.SingleLine, + ) + } +} + +@PreviewLightDark +@Composable +private fun DaxTextFieldMultiLinePreview() { + DaxTextFieldPreviewBox { + DaxTextField( + state = TextFieldState(initialText = "Multi line text field\nwith multiple lines\nof content"), + label = "Enter multiple lines", + lineLimits = DaxTextFieldLineLimits.MultiLine, + ) + } +} + +@PreviewLightDark +@Composable +private fun DaxTextFieldFormPreview() { + DaxTextFieldPreviewBox { + DaxTextField( + state = TextFieldState(initialText = "Form text field\ntakes minimum 3 lines"), + label = "Enter form content", + lineLimits = DaxTextFieldLineLimits.Form, + ) + } +} + +@PreviewLightDark +@Composable +private fun DaxTextFieldEditablePreview() { + DaxTextFieldPreviewBox { + DaxTextField( + state = TextFieldState(initialText = "Editable text field"), + label = "Enter text", + inputMode = DaxTextFieldInputMode.Editable, + ) + } +} + +@PreviewLightDark +@Composable +private fun DaxTextFieldDisabledPreview() { + DaxTextFieldPreviewBox { + DaxTextField( + state = TextFieldState(initialText = "Disabled text field"), + label = "Enter text", + inputMode = DaxTextFieldInputMode.Disabled, + ) + } +} + +@PreviewLightDark +@Composable +private fun DaxTextFieldNonEditablePreview() { + DaxTextFieldPreviewBox { + DaxTextField( + state = TextFieldState(initialText = "Non-editable text field"), + label = "Read only", + inputMode = DaxTextFieldInputMode.ReadOnly, + ) + } +} + +@PreviewLightDark +@Composable +private fun DaxTextFieldWithErrorPreview() { + DaxTextFieldPreviewBox { + DaxTextField( + state = TextFieldState(initialText = "Invalid input"), + label = "Enter text", + error = "This field contains an error", + ) + } +} + +@PreviewLightDark +@Composable +private fun DaxTextFieldWithTrailingIconPreview() { + DaxTextFieldPreviewBox { + DaxTextField( + state = TextFieldState(initialText = "Text with icon"), + label = "Enter text", + trailingIcon = { + DaxTextFieldTrailingIconScope.DaxTextFieldTrailingIcon( + painter = painterResource(R.drawable.ic_copy_24), + contentDescription = "Copy", + ) + }, + ) + } +} + +@PreviewLightDark +@Composable +private fun DaxTextFieldEmptyWithTrailingIconPreview() { + DaxTextFieldPreviewBox { + DaxTextField( + state = TextFieldState(), + label = "Enter text", + trailingIcon = { + DaxTextFieldTrailingIconScope.DaxTextFieldTrailingIcon( + painter = painterResource(R.drawable.ic_copy_24), + contentDescription = "Copy", + ) + }, + ) + } +} + +@PreviewLightDark +@Composable +private fun DaxTextFieldErrorWithTrailingIconPreview() { + DaxTextFieldPreviewBox { + DaxTextField( + state = TextFieldState(initialText = "Invalid input"), + label = "Enter text", + error = "Enter at least 3 characters", + trailingIcon = { + DaxTextFieldTrailingIcon( + painter = painterResource(R.drawable.ic_copy_24), + contentDescription = "Copy", + ) + }, + ) + } +} + +@PreviewLightDark +@Composable +private fun DaxTextFieldDisabledWithTextPreview() { + DaxTextFieldPreviewBox { + DaxTextField( + state = TextFieldState(initialText = "Disabled with text"), + label = "Enter text", + inputMode = DaxTextFieldInputMode.Disabled, + ) + } +} + +@Composable +private fun DaxTextFieldPreviewBox( + content: @Composable () -> Unit, +) { + DuckDuckGoTheme { + PreviewBox { + content() + } + } +} diff --git a/android-design-system/design-system/src/main/java/com/duckduckgo/common/ui/compose/theme/Color.kt b/android-design-system/design-system/src/main/java/com/duckduckgo/common/ui/compose/theme/Color.kt index e7301c145a5f..d69d4d0ff3a9 100644 --- a/android-design-system/design-system/src/main/java/com/duckduckgo/common/ui/compose/theme/Color.kt +++ b/android-design-system/design-system/src/main/java/com/duckduckgo/common/ui/compose/theme/Color.kt @@ -37,6 +37,7 @@ data class DuckDuckGoColors( val brand: DuckDuckGoBrandColors, val icons: DuckDuckGoIconsColors, val infoPanel: DuckDuckGoInfoPanelColors, + val textField: DuckDuckGoTextFieldColors, val system: DuckDuckGoSystemColors, val isDark: Boolean, // TODO we'll need to do an exploration into using the app pref for Theme switching ) @@ -65,6 +66,11 @@ data class DuckDuckGoTextColors( val omnibarHighlight: Color, ) +@Immutable +data class DuckDuckGoTextFieldColors( + val borders: Color, +) + @Immutable data class DuckDuckGoBrandColors( val accentBlue: Color, @@ -122,6 +128,7 @@ val Black = Color(0xFF000000) //region White color variants val White84 = Color(0xD6FFFFFF) +val White78 = Color(0xC7FFFFFF) val White60 = Color(0x99FFFFFF) val White48 = Color(0x7AFFFFFF) val White40 = Color(0x66FFFFFF) diff --git a/android-design-system/design-system/src/main/java/com/duckduckgo/common/ui/compose/theme/Theme.kt b/android-design-system/design-system/src/main/java/com/duckduckgo/common/ui/compose/theme/Theme.kt index fabb891ae7e1..7654e4e4ca01 100644 --- a/android-design-system/design-system/src/main/java/com/duckduckgo/common/ui/compose/theme/Theme.kt +++ b/android-design-system/design-system/src/main/java/com/duckduckgo/common/ui/compose/theme/Theme.kt @@ -41,6 +41,11 @@ object DuckDuckGoTheme { @ReadOnlyComposable get() = colors.text + val iconColors: DuckDuckGoIconsColors + @Composable + @ReadOnlyComposable + get() = colors.icons + val shapes @Composable @ReadOnlyComposable @@ -93,6 +98,9 @@ fun DuckDuckGoTheme( logoTitle = Gray85, omnibarHighlight = colorResource(R.color.blue50_20), ), + textField = DuckDuckGoTextFieldColors( + borders = colorResource(R.color.black30), + ), brand = DuckDuckGoBrandColors( accentBlue = Blue50, accentYellow = Yellow50, @@ -142,6 +150,9 @@ fun DuckDuckGoTheme( logoTitle = White, omnibarHighlight = colorResource(R.color.blue30_20), ), + textField = DuckDuckGoTextFieldColors( + borders = colorResource(R.color.white30), + ), brand = DuckDuckGoBrandColors( accentBlue = Blue30, accentYellow = Yellow50, 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 0ca115d865e7..5d3d46b764e6 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 @@ -38,6 +38,8 @@ import com.duckduckgo.lint.strings.PlaceholderDetector.Companion.PLACEHOLDER_MIS import com.duckduckgo.lint.ui.ColorAttributeInXmlDetector.Companion.INVALID_COLOR_ATTRIBUTE import com.duckduckgo.lint.ui.DaxButtonStylingDetector.Companion.INVALID_DAX_BUTTON_PROPERTY import com.duckduckgo.lint.ui.DaxTextColorUsageDetector.Companion.INVALID_DAX_TEXT_COLOR_USAGE +import com.duckduckgo.lint.ui.DaxTextFieldTrailingIconDetector.Companion.INVALID_DAX_TEXT_FIELD_TRAILING_ICON_USAGE +import com.duckduckgo.lint.ui.DaxSecureTextFieldTrailingIconDetector.Companion.INVALID_DAX_SECURE_TEXT_FIELD_TRAILING_ICON_USAGE import com.duckduckgo.lint.ui.DaxTextViewStylingDetector.Companion.INVALID_DAX_TEXT_VIEW_PROPERTY import com.duckduckgo.lint.ui.DeprecatedAndroidWidgetsUsedInXmlDetector.Companion.DEPRECATED_WIDGET_IN_XML import com.duckduckgo.lint.ui.MissingDividerDetector.Companion.MISSING_HORIZONTAL_DIVIDER @@ -86,6 +88,8 @@ class DuckDuckGoIssueRegistry : IssueRegistry() { NO_COMPOSE_VIEW_USAGE, NO_SET_CONTENT_USAGE, INVALID_DAX_TEXT_COLOR_USAGE, + INVALID_DAX_TEXT_FIELD_TRAILING_ICON_USAGE, + INVALID_DAX_SECURE_TEXT_FIELD_TRAILING_ICON_USAGE, ).plus(WebViewCompatApisUsageDetector.issues) diff --git a/lint-rules/src/main/java/com/duckduckgo/lint/ui/DaxSecureTextFieldTrailingIconDetector.kt b/lint-rules/src/main/java/com/duckduckgo/lint/ui/DaxSecureTextFieldTrailingIconDetector.kt new file mode 100644 index 000000000000..a27ca42b3519 --- /dev/null +++ b/lint-rules/src/main/java/com/duckduckgo/lint/ui/DaxSecureTextFieldTrailingIconDetector.kt @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2025 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 org.jetbrains.uast.getParameterForArgument +import java.util.EnumSet + +@Suppress("UnstableApiUsage") +class DaxSecureTextFieldTrailingIconDetector : Detector(), SourceCodeScanner { + + override fun getApplicableUastTypes() = listOf(UCallExpression::class.java) + + override fun createUastHandler(context: JavaContext): UElementHandler = DaxSecureTextFieldCallHandler(context) + + internal class DaxSecureTextFieldCallHandler(private val context: JavaContext) : UElementHandler() { + + private val validTrailingIconMembers: List by lazy { + getTrailingIconScopeMembers() + } + + override fun visitCallExpression(node: UCallExpression) { + val methodName = node.methodName + + if (methodName == "DaxSecureTextField") { + checkTrailingIconParameter(node) + } + } + + private fun checkTrailingIconParameter(node: UCallExpression) { + // Find the 'trailingIcon' parameter + val trailingIconArgument = node.valueArguments.find { arg -> + val parameterName = node.getParameterForArgument(arg)?.name + parameterName == "trailingIcon" + } ?: return // No 'trailingIcon' parameter provided which is fine + + // Check if the trailingIcon uses an invalid composable + if (isInvalidComposable(trailingIconArgument)) { + reportInvalidComposableUsage(trailingIconArgument) + } + } + + private fun getTrailingIconScopeMembers(): List { + val scopeClass = context.evaluator.findClass(TRAILING_ICON_SCOPE_CLASS) + ?: return emptyList() + + return scopeClass.methods + .filter { !it.isConstructor } + .mapNotNull { it.name } + } + + private fun isInvalidComposable(argument: org.jetbrains.uast.UExpression): Boolean { + val source = argument.sourcePsi?.text ?: return false + + // Check if the source contains any of the valid trailing icon members + // If the scope class couldn't be resolved, fall back to allowing the usage + if (validTrailingIconMembers.isEmpty()) return false + + return validTrailingIconMembers.none { member -> source.contains(member) } + } + + private fun reportInvalidComposableUsage(arg: org.jetbrains.uast.UExpression) { + context.report( + issue = INVALID_DAX_SECURE_TEXT_FIELD_TRAILING_ICON_USAGE, + location = context.getLocation(arg), + message = INVALID_DAX_SECURE_TEXT_FIELD_TRAILING_ICON_USAGE.getExplanation(TextFormat.RAW), + ) + } + } + + companion object { + private const val TRAILING_ICON_SCOPE_CLASS = + "com.duckduckgo.common.ui.compose.textfield.DaxTextFieldTrailingIconScope" + + val INVALID_DAX_SECURE_TEXT_FIELD_TRAILING_ICON_USAGE = Issue + .create( + id = "InvalidDaxSecureTextFieldTrailingIconUsage", + briefDescription = "DaxSecureTextField trailingIcon parameter should only use composables from DaxTextFieldTrailingIconScope", + explanation = """ + Use composables from DaxTextFieldTrailingIconScope instead of arbitrary composables + for the trailingIcon parameter to maintain design system consistency. + + Example: + DaxSecureTextField( + state = state, + isPasswordVisible = isPasswordVisible, + onShowHidePasswordIconClick = { /* toggle visibility */ }, + trailingIcon = { + DaxTextFieldTrailingIcon( + painter = painterResource(R.drawable.ic_copy_24), + contentDescription = stringResource(R.string.icon_description) + ) + } + ) + + This ensures consistent styling, spacing, and behavior across all text field icons in the app. + """.trimIndent(), + moreInfo = "", + category = CUSTOM_LINT_CHECKS, + priority = 6, + severity = Severity.WARNING, + androidSpecific = true, + implementation = Implementation( + DaxSecureTextFieldTrailingIconDetector::class.java, + EnumSet.of(Scope.JAVA_FILE, Scope.TEST_SOURCES), + ), + ) + } +} diff --git a/lint-rules/src/main/java/com/duckduckgo/lint/ui/DaxTextFieldTrailingIconDetector.kt b/lint-rules/src/main/java/com/duckduckgo/lint/ui/DaxTextFieldTrailingIconDetector.kt new file mode 100644 index 000000000000..a4a09b98a1b6 --- /dev/null +++ b/lint-rules/src/main/java/com/duckduckgo/lint/ui/DaxTextFieldTrailingIconDetector.kt @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2025 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 org.jetbrains.uast.getParameterForArgument +import java.util.EnumSet + +@Suppress("UnstableApiUsage") +class DaxTextFieldTrailingIconDetector : Detector(), SourceCodeScanner { + + override fun getApplicableUastTypes() = listOf(UCallExpression::class.java) + + override fun createUastHandler(context: JavaContext): UElementHandler = DaxTextFieldCallHandler(context) + + internal class DaxTextFieldCallHandler(private val context: JavaContext) : UElementHandler() { + + private val validTrailingIconMembers: List by lazy { + getTrailingIconScopeMembers() + } + + override fun visitCallExpression(node: UCallExpression) { + val methodName = node.methodName + + if (methodName == "DaxTextField") { + checkTrailingIconParameter(node) + } + } + + private fun checkTrailingIconParameter(node: UCallExpression) { + // Find the 'trailingIcon' parameter + val trailingIconArgument = node.valueArguments.find { arg -> + val parameterName = node.getParameterForArgument(arg)?.name + parameterName == "trailingIcon" + } ?: return // No 'trailingIcon' parameter provided which is fine + + // Check if the trailingIcon uses an invalid composable + if (isInvalidComposable(trailingIconArgument)) { + reportInvalidComposableUsage(trailingIconArgument) + } + } + + private fun getTrailingIconScopeMembers(): List { + val scopeClass = context.evaluator.findClass(TRAILING_ICON_SCOPE_CLASS) + ?: return emptyList() + + return scopeClass.methods + .filter { !it.isConstructor } + .mapNotNull { it.name } + } + + private fun isInvalidComposable(argument: org.jetbrains.uast.UExpression): Boolean { + val source = argument.sourcePsi?.text ?: return false + + // Check if the source contains any of the valid trailing icon members + // If the scope class couldn't be resolved, fall back to allowing the usage + if (validTrailingIconMembers.isEmpty()) return false + + return validTrailingIconMembers.none { member -> source.contains(member) } + } + + private fun reportInvalidComposableUsage(arg: org.jetbrains.uast.UExpression) { + context.report( + issue = INVALID_DAX_TEXT_FIELD_TRAILING_ICON_USAGE, + location = context.getLocation(arg), + message = INVALID_DAX_TEXT_FIELD_TRAILING_ICON_USAGE.getExplanation(TextFormat.RAW), + ) + } + } + + companion object { + private const val TRAILING_ICON_SCOPE_CLASS = + "com.duckduckgo.common.ui.compose.textfield.DaxTextFieldTrailingIconScope" + + val INVALID_DAX_TEXT_FIELD_TRAILING_ICON_USAGE = Issue + .create( + id = "InvalidDaxTextFieldTrailingIconUsage", + briefDescription = "DaxTextField trailingIcon parameter should only use composables from DaxTextFieldTrailingIconScope", + explanation = """ + Use composables from DaxTextFieldTrailingIconScope instead of arbitrary composables + for the trailingIcon parameter to maintain design system consistency. + + Example: + DaxTextField( + state = state, + trailingIcon = { + DaxTextFieldTrailingIcon( + painter = painterResource(R.drawable.ic_copy_24), + contentDescription = stringResource(R.string.icon_description) + ) + } + ) + + This ensures consistent styling, spacing, and behavior across all text field icons in the app. + """.trimIndent(), + moreInfo = "", + category = CUSTOM_LINT_CHECKS, + priority = 6, + severity = Severity.WARNING, + androidSpecific = true, + implementation = Implementation( + DaxTextFieldTrailingIconDetector::class.java, + EnumSet.of(Scope.JAVA_FILE, Scope.TEST_SOURCES), + ), + ) + } +} diff --git a/lint-rules/src/test/java/com/duckduckgo/lint/ui/DaxSecureTextFieldTrailingIconDetectorTest.kt b/lint-rules/src/test/java/com/duckduckgo/lint/ui/DaxSecureTextFieldTrailingIconDetectorTest.kt new file mode 100644 index 000000000000..f0043fd1e562 --- /dev/null +++ b/lint-rules/src/test/java/com/duckduckgo/lint/ui/DaxSecureTextFieldTrailingIconDetectorTest.kt @@ -0,0 +1,609 @@ +/* + * Copyright (c) 2025 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.android.tools.lint.checks.infrastructure.TestMode +import com.duckduckgo.lint.ui.DaxSecureTextFieldTrailingIconDetector.Companion.INVALID_DAX_SECURE_TEXT_FIELD_TRAILING_ICON_USAGE +import org.junit.Test + +class DaxSecureTextFieldTrailingIconDetectorTest { + + private val composeStubs = TestFiles.kotlin( + """ + package androidx.compose.ui.graphics + + data class Color(val value: Long) + + package androidx.compose.ui.graphics.painter + + interface Painter + + package androidx.compose.runtime + + annotation class Composable + + package androidx.compose.ui + + class Modifier { + companion object : Modifier() + } + + package androidx.compose.material3 + + import androidx.compose.runtime.Composable + import androidx.compose.ui.Modifier + import androidx.compose.ui.graphics.painter.Painter + + @Composable + fun Icon( + painter: Painter, + contentDescription: String?, + modifier: Modifier = Modifier + ) {} + + @Composable + fun IconButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + content: @Composable () -> Unit + ) {} + """.trimIndent() + ).indented() + + private val textFieldStateStub = TestFiles.kotlin( + """ + package androidx.compose.foundation.text.input + + class TextFieldState(initialText: String = "") + """.trimIndent() + ).indented() + + private val daxSecureTextFieldStub = TestFiles.kotlin( + """ + package com.duckduckgo.common.ui.compose.textfield + + import androidx.compose.foundation.text.input.TextFieldState + import androidx.compose.runtime.Composable + import androidx.compose.ui.Modifier + import androidx.compose.ui.graphics.painter.Painter + + @Composable + fun DaxSecureTextField( + state: TextFieldState, + isPasswordVisible: Boolean, + onShowHidePasswordIconClick: () -> Unit, + modifier: Modifier = Modifier, + label: String? = null, + trailingIcon: (@Composable DaxTextFieldTrailingIconScope.() -> Unit)? = null + ) { + // Implementation + } + + object DaxTextFieldTrailingIconScope { + @Composable + fun DaxTextFieldTrailingIcon( + painter: Painter, + contentDescription: String?, + modifier: Modifier = Modifier, + enabled: Boolean = true, + onClick: (() -> Unit)? = null + ) { + // Implementation + } + + @Composable + fun SomeComposable(){ + // Implementation + } + } + """.trimIndent() + ).indented() + + private val painterStub = TestFiles.kotlin( + """ + package androidx.compose.ui.res + + import androidx.compose.runtime.Composable + import androidx.compose.ui.graphics.painter.Painter + + @Composable + fun painterResource(id: Int): Painter = object : Painter {} + """.trimIndent() + ).indented() + + @Test + fun whenDaxTextFieldTrailingIconUsedThenNoWarning() { + lint() + .files( + TestFiles.kt( + """ + package com.example.test + + import androidx.compose.foundation.text.input.TextFieldState + import androidx.compose.runtime.Composable + import androidx.compose.ui.res.painterResource + import com.duckduckgo.common.ui.compose.textfield.DaxSecureTextField + import com.duckduckgo.common.ui.compose.textfield.DaxTextFieldTrailingIconScope.DaxTextFieldTrailingIcon + + @Composable + fun TestScreen() { + DaxSecureTextField( + state = TextFieldState(), + isPasswordVisible = false, + onShowHidePasswordIconClick = {}, + label = "Enter password", + trailingIcon = { + DaxTextFieldTrailingIcon( + painter = painterResource(0), + contentDescription = "Copy" + ) + } + ) + } + """.trimIndent() + ).indented(), + composeStubs, + textFieldStateStub, + daxSecureTextFieldStub, + painterStub + ) + .allowCompilationErrors() + .issues(INVALID_DAX_SECURE_TEXT_FIELD_TRAILING_ICON_USAGE) + .skipTestModes(TestMode.WHITESPACE, TestMode.REORDER_ARGUMENTS) + .run() + .expectClean() + } + + @Test + fun whenComposableFromDaxTextFieldTrailingIconScopeUsedThenNoWarning() { + lint() + .files( + TestFiles.kt( + """ + package com.example.test + + import androidx.compose.foundation.text.input.TextFieldState + import androidx.compose.runtime.Composable + import androidx.compose.ui.res.painterResource + import com.duckduckgo.common.ui.compose.textfield.DaxSecureTextField + import com.duckduckgo.common.ui.compose.textfield.DaxTextFieldTrailingIconScope.DaxTextFieldTrailingIcon + + @Composable + fun TestScreen() { + DaxSecureTextField( + state = TextFieldState(), + isPasswordVisible = false, + onShowHidePasswordIconClick = {}, + label = "Enter password", + trailingIcon = { + SomeComposable() + } + ) + } + """.trimIndent() + ).indented(), + composeStubs, + textFieldStateStub, + daxSecureTextFieldStub, + painterStub + ) + .allowCompilationErrors() + .issues(INVALID_DAX_SECURE_TEXT_FIELD_TRAILING_ICON_USAGE) + .skipTestModes(TestMode.WHITESPACE, TestMode.REORDER_ARGUMENTS) + .run() + .expectClean() + } + + @Test + fun whenDaxTextFieldTrailingIconUsedWithoutScopePrefixThenNoWarning() { + lint() + .files( + TestFiles.kt( + """ + package com.example.test + + import androidx.compose.foundation.text.input.TextFieldState + import androidx.compose.runtime.Composable + import androidx.compose.ui.res.painterResource + import com.duckduckgo.common.ui.compose.textfield.DaxSecureTextField + + @Composable + fun TestScreen() { + DaxSecureTextField( + state = TextFieldState(), + isPasswordVisible = false, + onShowHidePasswordIconClick = {}, + label = "Enter password", + trailingIcon = { + DaxTextFieldTrailingIcon( + painter = painterResource(0), + contentDescription = "Copy" + ) + } + ) + } + """.trimIndent() + ).indented(), + composeStubs, + textFieldStateStub, + daxSecureTextFieldStub, + painterStub + ) + .allowCompilationErrors() + .issues(INVALID_DAX_SECURE_TEXT_FIELD_TRAILING_ICON_USAGE) + .skipTestModes(TestMode.WHITESPACE, TestMode.REORDER_ARGUMENTS) + .run() + .expectClean() + } + + @Test + fun whenNoTrailingIconProvidedThenNoWarning() { + lint() + .files( + TestFiles.kt( + """ + package com.example.test + + import androidx.compose.foundation.text.input.TextFieldState + import androidx.compose.runtime.Composable + import com.duckduckgo.common.ui.compose.textfield.DaxSecureTextField + + @Composable + fun TestScreen() { + DaxSecureTextField( + state = TextFieldState(), + isPasswordVisible = false, + onShowHidePasswordIconClick = {}, + label = "Enter password" + ) + } + """.trimIndent() + ).indented(), + composeStubs, + textFieldStateStub, + daxSecureTextFieldStub + ) + .allowCompilationErrors() + .issues(INVALID_DAX_SECURE_TEXT_FIELD_TRAILING_ICON_USAGE) + .run() + .expectClean() + } + + @Test + fun whenIconComposableUsedInsteadOfDaxTextFieldTrailingIconThenWarning() { + lint() + .files( + TestFiles.kt( + """ + package com.example.test + + import androidx.compose.foundation.text.input.TextFieldState + import androidx.compose.material3.Icon + import androidx.compose.runtime.Composable + import androidx.compose.ui.res.painterResource + import com.duckduckgo.common.ui.compose.textfield.DaxSecureTextField + + @Composable + fun TestScreen() { + DaxSecureTextField( + state = TextFieldState(), + isPasswordVisible = false, + onShowHidePasswordIconClick = {}, + label = "Enter password", + trailingIcon = { + Icon( + painter = painterResource(0), + contentDescription = "Copy" + ) + } + ) + } + """.trimIndent() + ).indented(), + composeStubs, + textFieldStateStub, + daxSecureTextFieldStub, + painterStub + ) + .allowCompilationErrors() + .issues(INVALID_DAX_SECURE_TEXT_FIELD_TRAILING_ICON_USAGE) + .run() + .expect( + """ + src/com/example/test/test.kt:16: Warning: Use composables from DaxTextFieldTrailingIconScope instead of arbitrary composables + for the trailingIcon parameter to maintain design system consistency. + + Example: + DaxSecureTextField( + state = state, + isPasswordVisible = isPasswordVisible, + onShowHidePasswordIconClick = { /* toggle visibility */ }, + trailingIcon = { + DaxTextFieldTrailingIcon( + painter = painterResource(R.drawable.ic_copy_24), + contentDescription = stringResource(R.string.icon_description) + ) + } + ) + + This ensures consistent styling, spacing, and behavior across all text field icons in the app. [InvalidDaxSecureTextFieldTrailingIconUsage] + trailingIcon = { + ^ + 0 errors, 1 warnings + """.trimIndent() + ) + } + + @Test + fun whenIconButtonUsedInsteadOfDaxTextFieldTrailingIconThenWarning() { + lint() + .files( + TestFiles.kt( + """ + package com.example.test + + import androidx.compose.foundation.text.input.TextFieldState + import androidx.compose.material3.Icon + import androidx.compose.material3.IconButton + import androidx.compose.runtime.Composable + import androidx.compose.ui.res.painterResource + import com.duckduckgo.common.ui.compose.textfield.DaxSecureTextField + + @Composable + fun TestScreen() { + DaxSecureTextField( + state = TextFieldState(), + isPasswordVisible = false, + onShowHidePasswordIconClick = {}, + label = "Enter password", + trailingIcon = { + IconButton(onClick = {}) { + Icon( + painter = painterResource(0), + contentDescription = "Copy" + ) + } + } + ) + } + """.trimIndent() + ).indented(), + composeStubs, + textFieldStateStub, + daxSecureTextFieldStub, + painterStub + ) + .allowCompilationErrors() + .issues(INVALID_DAX_SECURE_TEXT_FIELD_TRAILING_ICON_USAGE) + .run() + .expect( + """ + src/com/example/test/test.kt:17: Warning: Use composables from DaxTextFieldTrailingIconScope instead of arbitrary composables + for the trailingIcon parameter to maintain design system consistency. + + Example: + DaxSecureTextField( + state = state, + isPasswordVisible = isPasswordVisible, + onShowHidePasswordIconClick = { /* toggle visibility */ }, + trailingIcon = { + DaxTextFieldTrailingIcon( + painter = painterResource(R.drawable.ic_copy_24), + contentDescription = stringResource(R.string.icon_description) + ) + } + ) + + This ensures consistent styling, spacing, and behavior across all text field icons in the app. [InvalidDaxSecureTextFieldTrailingIconUsage] + trailingIcon = { + ^ + 0 errors, 1 warnings + """.trimIndent() + ) + } + + @Test + fun whenMultipleDaxSecureTextFieldCallsWithMixedIconsThenWarningsForInvalidOnes() { + lint() + .files( + TestFiles.kt( + """ + package com.example.test + + import androidx.compose.foundation.text.input.TextFieldState + import androidx.compose.material3.Icon + import androidx.compose.runtime.Composable + import androidx.compose.ui.res.painterResource + import com.duckduckgo.common.ui.compose.textfield.DaxSecureTextField + import com.duckduckgo.common.ui.compose.textfield.DaxTextFieldTrailingIconScope.DaxTextFieldTrailingIcon + + @Composable + fun TestScreen() { + DaxSecureTextField( + state = TextFieldState(), + isPasswordVisible = false, + onShowHidePasswordIconClick = {}, + label = "Valid field", + trailingIcon = { + DaxTextFieldTrailingIcon( + painter = painterResource(0), + contentDescription = "Copy" + ) + } + ) + + DaxSecureTextField( + state = TextFieldState(), + isPasswordVisible = false, + onShowHidePasswordIconClick = {}, + label = "Invalid field", + trailingIcon = { + Icon( + painter = painterResource(0), + contentDescription = "Copy" + ) + } + ) + + DaxSecureTextField( + state = TextFieldState(), + isPasswordVisible = false, + onShowHidePasswordIconClick = {}, + label = "Also valid field", + trailingIcon = { + DaxTextFieldTrailingIcon( + painter = painterResource(0), + contentDescription = "Clear" + ) + } + ) + } + """.trimIndent() + ).indented(), + composeStubs, + textFieldStateStub, + daxSecureTextFieldStub, + painterStub + ) + .allowCompilationErrors() + .issues(INVALID_DAX_SECURE_TEXT_FIELD_TRAILING_ICON_USAGE) + .skipTestModes(TestMode.WHITESPACE, TestMode.REORDER_ARGUMENTS) + .run() + .expect( + """ + src/com/example/test/test.kt:30: Warning: Use composables from DaxTextFieldTrailingIconScope instead of arbitrary composables + for the trailingIcon parameter to maintain design system consistency. + + Example: + DaxSecureTextField( + state = state, + isPasswordVisible = isPasswordVisible, + onShowHidePasswordIconClick = { /* toggle visibility */ }, + trailingIcon = { + DaxTextFieldTrailingIcon( + painter = painterResource(R.drawable.ic_copy_24), + contentDescription = stringResource(R.string.icon_description) + ) + } + ) + + This ensures consistent styling, spacing, and behavior across all text field icons in the app. [InvalidDaxSecureTextFieldTrailingIconUsage] + trailingIcon = { + ^ + 0 errors, 1 warnings + """.trimIndent() + ) + } + + @Test + fun whenDaxSecureTextFieldUsedWithoutTrailingIconParameterThenNoWarning() { + lint() + .files( + TestFiles.kt( + """ + package com.example.test + + import androidx.compose.foundation.text.input.TextFieldState + import androidx.compose.runtime.Composable + import com.duckduckgo.common.ui.compose.textfield.DaxSecureTextField + + @Composable + fun TestScreen() { + DaxSecureTextField( + state = TextFieldState(), + isPasswordVisible = false, + onShowHidePasswordIconClick = {} + ) + } + """.trimIndent() + ).indented(), + composeStubs, + textFieldStateStub, + daxSecureTextFieldStub + ) + .allowCompilationErrors() + .issues(INVALID_DAX_SECURE_TEXT_FIELD_TRAILING_ICON_USAGE) + .run() + .expectClean() + } + + @Test + fun whenArbitraryComposableUsedInTrailingIconThenWarning() { + lint() + .files( + TestFiles.kt( + """ + package com.example.test + + import androidx.compose.foundation.text.input.TextFieldState + import androidx.compose.runtime.Composable + import androidx.compose.ui.Modifier + import com.duckduckgo.common.ui.compose.textfield.DaxSecureTextField + + @Composable + fun Box(content: @Composable () -> Unit) {} + + @Composable + fun TestScreen() { + DaxSecureTextField( + state = TextFieldState(), + isPasswordVisible = false, + onShowHidePasswordIconClick = {}, + label = "Enter password", + trailingIcon = { + Box {} + } + ) + } + """.trimIndent() + ).indented(), + composeStubs, + textFieldStateStub, + daxSecureTextFieldStub + ) + .allowCompilationErrors() + .issues(INVALID_DAX_SECURE_TEXT_FIELD_TRAILING_ICON_USAGE) + .run() + .expect( + """ + src/com/example/test/test.kt:18: Warning: Use composables from DaxTextFieldTrailingIconScope instead of arbitrary composables + for the trailingIcon parameter to maintain design system consistency. + + Example: + DaxSecureTextField( + state = state, + isPasswordVisible = isPasswordVisible, + onShowHidePasswordIconClick = { /* toggle visibility */ }, + trailingIcon = { + DaxTextFieldTrailingIcon( + painter = painterResource(R.drawable.ic_copy_24), + contentDescription = stringResource(R.string.icon_description) + ) + } + ) + + This ensures consistent styling, spacing, and behavior across all text field icons in the app. [InvalidDaxSecureTextFieldTrailingIconUsage] + trailingIcon = { + ^ + 0 errors, 1 warnings + """.trimIndent() + ) + } +} diff --git a/lint-rules/src/test/java/com/duckduckgo/lint/ui/DaxTextFieldTrailingIconDetectorTest.kt b/lint-rules/src/test/java/com/duckduckgo/lint/ui/DaxTextFieldTrailingIconDetectorTest.kt new file mode 100644 index 000000000000..948d51a65af1 --- /dev/null +++ b/lint-rules/src/test/java/com/duckduckgo/lint/ui/DaxTextFieldTrailingIconDetectorTest.kt @@ -0,0 +1,575 @@ +/* + * Copyright (c) 2025 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.android.tools.lint.checks.infrastructure.TestMode +import com.duckduckgo.lint.ui.DaxTextFieldTrailingIconDetector.Companion.INVALID_DAX_TEXT_FIELD_TRAILING_ICON_USAGE +import org.junit.Test + +class DaxTextFieldTrailingIconDetectorTest { + + private val composeStubs = TestFiles.kotlin( + """ + package androidx.compose.ui.graphics + + data class Color(val value: Long) + + package androidx.compose.ui.graphics.painter + + interface Painter + + package androidx.compose.runtime + + annotation class Composable + + package androidx.compose.ui + + class Modifier { + companion object : Modifier() + } + + package androidx.compose.material3 + + import androidx.compose.runtime.Composable + import androidx.compose.ui.Modifier + import androidx.compose.ui.graphics.painter.Painter + + @Composable + fun Icon( + painter: Painter, + contentDescription: String?, + modifier: Modifier = Modifier + ) {} + + @Composable + fun IconButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + content: @Composable () -> Unit + ) {} + """.trimIndent(), + ).indented() + + private val textFieldStateStub = TestFiles.kotlin( + """ + package androidx.compose.foundation.text.input + + class TextFieldState(initialText: String = "") + """.trimIndent(), + ).indented() + + private val daxTextFieldStub = TestFiles.kotlin( + """ + package com.duckduckgo.common.ui.compose.textfield + + import androidx.compose.foundation.text.input.TextFieldState + import androidx.compose.runtime.Composable + import androidx.compose.ui.Modifier + import androidx.compose.ui.graphics.painter.Painter + + @Composable + fun DaxTextField( + state: TextFieldState, + modifier: Modifier = Modifier, + label: String? = null, + trailingIcon: (@Composable DaxTextFieldTrailingIconScope.() -> Unit)? = null + ) { + // Implementation + } + + object DaxTextFieldTrailingIconScope { + @Composable + fun DaxTextFieldTrailingIcon( + painter: Painter, + contentDescription: String?, + modifier: Modifier = Modifier, + enabled: Boolean = true, + onClick: (() -> Unit)? = null + ) { + // Implementation + } + + @Composable + fun SomeComposable(){ + // Implementation + } + } + """.trimIndent(), + ).indented() + + private val painterStub = TestFiles.kotlin( + """ + package androidx.compose.ui.res + + import androidx.compose.runtime.Composable + import androidx.compose.ui.graphics.painter.Painter + + @Composable + fun painterResource(id: Int): Painter = object : Painter {} + """.trimIndent(), + ).indented() + + @Test + fun whenDaxTextFieldTrailingIconUsedThenNoWarning() { + lint() + .files( + TestFiles.kt( + """ + package com.example.test + + import androidx.compose.foundation.text.input.TextFieldState + import androidx.compose.runtime.Composable + import androidx.compose.ui.res.painterResource + import com.duckduckgo.common.ui.compose.textfield.DaxTextField + import com.duckduckgo.common.ui.compose.textfield.DaxTextFieldTrailingIconScope.DaxTextFieldTrailingIcon + + @Composable + fun TestScreen() { + DaxTextField( + state = TextFieldState(), + label = "Enter text", + trailingIcon = { + DaxTextFieldTrailingIcon( + painter = painterResource(0), + contentDescription = "Copy" + ) + } + ) + } + """.trimIndent(), + ).indented(), + composeStubs, + textFieldStateStub, + daxTextFieldStub, + painterStub, + ) + .allowCompilationErrors() + .issues(INVALID_DAX_TEXT_FIELD_TRAILING_ICON_USAGE) + .skipTestModes(TestMode.WHITESPACE, TestMode.REORDER_ARGUMENTS) + .run() + .expectClean() + } + + @Test + fun whenValidComposableFromDaxTextFieldTrailingIconScopeUsedThenNoWarning() { + lint() + .files( + TestFiles.kt( + """ + package com.example.test + + import androidx.compose.foundation.text.input.TextFieldState + import androidx.compose.runtime.Composable + import androidx.compose.ui.res.painterResource + import com.duckduckgo.common.ui.compose.textfield.DaxTextField + import com.duckduckgo.common.ui.compose.textfield.DaxTextFieldTrailingIconScope.DaxTextFieldTrailingIcon + + @Composable + fun TestScreen() { + DaxTextField( + state = TextFieldState(), + label = "Enter text", + trailingIcon = { + SomeComposable() + } + ) + } + """.trimIndent(), + ).indented(), + composeStubs, + textFieldStateStub, + daxTextFieldStub, + painterStub, + ) + .allowCompilationErrors() + .issues(INVALID_DAX_TEXT_FIELD_TRAILING_ICON_USAGE) + .skipTestModes(TestMode.WHITESPACE, TestMode.REORDER_ARGUMENTS) + .run() + .expectClean() + } + + @Test + fun whenDaxTextFieldTrailingIconUsedWithoutScopePrefixThenNoWarning() { + lint() + .files( + TestFiles.kt( + """ + package com.example.test + + import androidx.compose.foundation.text.input.TextFieldState + import androidx.compose.runtime.Composable + import androidx.compose.ui.res.painterResource + import com.duckduckgo.common.ui.compose.textfield.DaxTextField + + @Composable + fun TestScreen() { + DaxTextField( + state = TextFieldState(), + label = "Enter text", + trailingIcon = { + DaxTextFieldTrailingIcon( + painter = painterResource(0), + contentDescription = "Copy" + ) + } + ) + } + """.trimIndent(), + ).indented(), + composeStubs, + textFieldStateStub, + daxTextFieldStub, + painterStub, + ) + .allowCompilationErrors() + .issues(INVALID_DAX_TEXT_FIELD_TRAILING_ICON_USAGE) + .skipTestModes(TestMode.WHITESPACE, TestMode.REORDER_ARGUMENTS) + .run() + .expectClean() + } + + @Test + fun whenNoTrailingIconProvidedThenNoWarning() { + lint() + .files( + TestFiles.kt( + """ + package com.example.test + + import androidx.compose.foundation.text.input.TextFieldState + import androidx.compose.runtime.Composable + import com.duckduckgo.common.ui.compose.textfield.DaxTextField + + @Composable + fun TestScreen() { + DaxTextField( + state = TextFieldState(), + label = "Enter text" + ) + } + """.trimIndent(), + ).indented(), + composeStubs, + textFieldStateStub, + daxTextFieldStub, + ) + .allowCompilationErrors() + .issues(INVALID_DAX_TEXT_FIELD_TRAILING_ICON_USAGE) + .run() + .expectClean() + } + + @Test + fun whenIconComposableUsedInsteadOfDaxTextFieldTrailingIconThenWarning() { + lint() + .files( + TestFiles.kt( + """ + package com.example.test + + import androidx.compose.foundation.text.input.TextFieldState + import androidx.compose.material3.Icon + import androidx.compose.runtime.Composable + import androidx.compose.ui.res.painterResource + import com.duckduckgo.common.ui.compose.textfield.DaxTextField + + @Composable + fun TestScreen() { + DaxTextField( + state = TextFieldState(), + label = "Enter text", + trailingIcon = { + Icon( + painter = painterResource(0), + contentDescription = "Copy" + ) + } + ) + } + """.trimIndent(), + ).indented(), + composeStubs, + textFieldStateStub, + daxTextFieldStub, + painterStub, + ) + .allowCompilationErrors() + .issues(INVALID_DAX_TEXT_FIELD_TRAILING_ICON_USAGE) + .run() + .expect( + """ + src/com/example/test/test.kt:14: Warning: Use composables from DaxTextFieldTrailingIconScope instead of arbitrary composables + for the trailingIcon parameter to maintain design system consistency. + + Example: + DaxTextField( + state = state, + trailingIcon = { + DaxTextFieldTrailingIcon( + painter = painterResource(R.drawable.ic_copy_24), + contentDescription = stringResource(R.string.icon_description) + ) + } + ) + + This ensures consistent styling, spacing, and behavior across all text field icons in the app. [InvalidDaxTextFieldTrailingIconUsage] + trailingIcon = { + ^ + 0 errors, 1 warnings + """.trimIndent(), + ) + } + + @Test + fun whenIconButtonUsedInsteadOfDaxTextFieldTrailingIconThenWarning() { + lint() + .files( + TestFiles.kt( + """ + package com.example.test + + import androidx.compose.foundation.text.input.TextFieldState + import androidx.compose.material3.Icon + import androidx.compose.material3.IconButton + import androidx.compose.runtime.Composable + import androidx.compose.ui.res.painterResource + import com.duckduckgo.common.ui.compose.textfield.DaxTextField + + @Composable + fun TestScreen() { + DaxTextField( + state = TextFieldState(), + label = "Enter text", + trailingIcon = { + IconButton(onClick = {}) { + Icon( + painter = painterResource(0), + contentDescription = "Copy" + ) + } + } + ) + } + """.trimIndent(), + ).indented(), + composeStubs, + textFieldStateStub, + daxTextFieldStub, + painterStub, + ) + .allowCompilationErrors() + .issues(INVALID_DAX_TEXT_FIELD_TRAILING_ICON_USAGE) + .run() + .expect( + """ + src/com/example/test/test.kt:15: Warning: Use composables from DaxTextFieldTrailingIconScope instead of arbitrary composables + for the trailingIcon parameter to maintain design system consistency. + + Example: + DaxTextField( + state = state, + trailingIcon = { + DaxTextFieldTrailingIcon( + painter = painterResource(R.drawable.ic_copy_24), + contentDescription = stringResource(R.string.icon_description) + ) + } + ) + + This ensures consistent styling, spacing, and behavior across all text field icons in the app. [InvalidDaxTextFieldTrailingIconUsage] + trailingIcon = { + ^ + 0 errors, 1 warnings + """.trimIndent(), + ) + } + + @Test + fun whenMultipleDaxTextFieldCallsWithMixedIconsThenWarningsForInvalidOnes() { + lint() + .files( + TestFiles.kt( + """ + package com.example.test + + import androidx.compose.foundation.text.input.TextFieldState + import androidx.compose.material3.Icon + import androidx.compose.runtime.Composable + import androidx.compose.ui.res.painterResource + import com.duckduckgo.common.ui.compose.textfield.DaxTextField + import com.duckduckgo.common.ui.compose.textfield.DaxTextFieldTrailingIconScope.DaxTextFieldTrailingIcon + + @Composable + fun TestScreen() { + DaxTextField( + state = TextFieldState(), + label = "Valid field", + trailingIcon = { + DaxTextFieldTrailingIcon( + painter = painterResource(0), + contentDescription = "Copy" + ) + } + ) + + DaxTextField( + state = TextFieldState(), + label = "Invalid field", + trailingIcon = { + Icon( + painter = painterResource(0), + contentDescription = "Copy" + ) + } + ) + + DaxTextField( + state = TextFieldState(), + label = "Also valid field", + trailingIcon = { + DaxTextFieldTrailingIcon( + painter = painterResource(0), + contentDescription = "Clear" + ) + } + ) + } + """.trimIndent(), + ).indented(), + composeStubs, + textFieldStateStub, + daxTextFieldStub, + painterStub, + ) + .allowCompilationErrors() + .issues(INVALID_DAX_TEXT_FIELD_TRAILING_ICON_USAGE) + .skipTestModes(TestMode.WHITESPACE, TestMode.REORDER_ARGUMENTS) + .run() + .expect( + """ + src/com/example/test/test.kt:26: Warning: Use composables from DaxTextFieldTrailingIconScope instead of arbitrary composables + for the trailingIcon parameter to maintain design system consistency. + + Example: + DaxTextField( + state = state, + trailingIcon = { + DaxTextFieldTrailingIcon( + painter = painterResource(R.drawable.ic_copy_24), + contentDescription = stringResource(R.string.icon_description) + ) + } + ) + + This ensures consistent styling, spacing, and behavior across all text field icons in the app. [InvalidDaxTextFieldTrailingIconUsage] + trailingIcon = { + ^ + 0 errors, 1 warnings + """.trimIndent(), + ) + } + + @Test + fun whenDaxTextFieldUsedWithoutTrailingIconParameterThenNoWarning() { + lint() + .files( + TestFiles.kt( + """ + package com.example.test + + import androidx.compose.foundation.text.input.TextFieldState + import androidx.compose.runtime.Composable + import com.duckduckgo.common.ui.compose.textfield.DaxTextField + + @Composable + fun TestScreen() { + DaxTextField(state = TextFieldState()) + } + """.trimIndent(), + ).indented(), + composeStubs, + textFieldStateStub, + daxTextFieldStub, + ) + .allowCompilationErrors() + .issues(INVALID_DAX_TEXT_FIELD_TRAILING_ICON_USAGE) + .run() + .expectClean() + } + + @Test + fun whenArbitraryComposableUsedInTrailingIconThenWarning() { + lint() + .files( + TestFiles.kt( + """ + package com.example.test + + import androidx.compose.foundation.text.input.TextFieldState + import androidx.compose.runtime.Composable + import androidx.compose.ui.Modifier + import com.duckduckgo.common.ui.compose.textfield.DaxTextField + + @Composable + fun Scaffold(content: @Composable () -> Unit) {} + + @Composable + fun TestScreen() { + DaxTextField( + state = TextFieldState(), + label = "Enter text", + trailingIcon = { + Scaffold {} + } + ) + } + """.trimIndent(), + ).indented(), + composeStubs, + textFieldStateStub, + daxTextFieldStub, + ) + .allowCompilationErrors() + .issues(INVALID_DAX_TEXT_FIELD_TRAILING_ICON_USAGE) + .run() + .expect( + """ + src/com/example/test/test.kt:16: Warning: Use composables from DaxTextFieldTrailingIconScope instead of arbitrary composables + for the trailingIcon parameter to maintain design system consistency. + + Example: + DaxTextField( + state = state, + trailingIcon = { + DaxTextFieldTrailingIcon( + painter = painterResource(R.drawable.ic_copy_24), + contentDescription = stringResource(R.string.icon_description) + ) + } + ) + + This ensures consistent styling, spacing, and behavior across all text field icons in the app. [InvalidDaxTextFieldTrailingIconUsage] + trailingIcon = { + ^ + 0 errors, 1 warnings + """.trimIndent(), + ) + } +}