diff --git a/app/build.gradle b/app/build.gradle index e09f92e9414c..fccfddbc71d5 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -184,6 +184,14 @@ android { jvmArgs '-XX:+TieredCompilation', '-XX:TieredStopAtLevel=1' } } + + buildFeatures { + compose true + } + + composeOptions { + kotlinCompilerExtensionVersion = "_" + } } static def isValidSigningConfig(signingConfig) { @@ -424,6 +432,7 @@ dependencies { } implementation AndroidX.appCompat + implementation AndroidX.activity.compose implementation Google.android.material implementation AndroidX.constraintLayout implementation AndroidX.recyclerView diff --git a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt index dc76f22dca7a..c278f27c57f9 100644 --- a/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/browser/BrowserActivity.kt @@ -404,11 +404,11 @@ open class BrowserActivity : DuckDuckGoActivity() { super.onDestroy() } - override fun onNewIntent(intent: Intent?) { + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) logcat(INFO) { "onNewIntent: $intent" } - intent?.sanitize() + intent.sanitize() dataClearerForegroundAppRestartPixel.registerIntent(intent) @@ -782,7 +782,9 @@ open class BrowserActivity : DuckDuckGoActivity() { } fun launchBookmarks() { - startBookmarksActivityForResult.launch(globalActivityStarter.startIntent(this, BookmarksScreenNoParams)) + globalActivityStarter.startIntent(this, BookmarksScreenNoParams)?.let { intent -> + startBookmarksActivityForResult.launch(intent) + } ?: logcat(ERROR) { "Could not create intent to launch bookmarks" } } fun launchDownloads() { diff --git a/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchActivity.kt b/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchActivity.kt index 27e6e7947c5f..044c54ba9143 100644 --- a/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchActivity.kt +++ b/app/src/main/java/com/duckduckgo/app/systemsearch/SystemSearchActivity.kt @@ -163,14 +163,12 @@ class SystemSearchActivity : DuckDuckGoActivity() { } } - override fun onNewIntent(newIntent: Intent?) { - super.onNewIntent(newIntent) - dataClearerForegroundAppRestartPixel.registerIntent(newIntent) + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + dataClearerForegroundAppRestartPixel.registerIntent(intent) viewModel.resetViewState() - newIntent?.let { - sendLaunchPixels(it) - handleVoiceSearchLaunch(it) - } + sendLaunchPixels(intent) + handleVoiceSearchLaunch(intent) } private fun sendLaunchPixels(intent: Intent) { diff --git a/build.gradle b/build.gradle index ae9087d442a0..98534c33f453 100644 --- a/build.gradle +++ b/build.gradle @@ -189,4 +189,11 @@ fladle { } } +tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile) { + compilerOptions.freeCompilerArgs.addAll( + "-P", + "plugin:androidx.compose.compiler.plugins.kotlin:experimentalStrongSkipping=true", + ) +} + apply plugin: 'android-reporting' diff --git a/common/common-ui/build.gradle b/common/common-ui/build.gradle index 18d6730ddad5..3fb51ccecf9e 100644 --- a/common/common-ui/build.gradle +++ b/common/common-ui/build.gradle @@ -34,6 +34,14 @@ android { baseline file("lint-baseline.xml") abortOnError = !project.hasProperty("abortOnError") || project.property("abortOnError") != "false" } + + buildFeatures { + compose true + } + + composeOptions { + kotlinCompilerExtensionVersion = "_" + } } dependencies { @@ -57,6 +65,23 @@ dependencies { implementation "androidx.core:core-ktx:_" implementation "androidx.localbroadcastmanager:localbroadcastmanager:_" + api(platform("io.coil-kt.coil3:coil-bom:_")) + api("io.coil-kt.coil3:coil-compose") + api("io.coil-kt.coil3:coil-network-okhttp") + + // Compose + api(platform(AndroidX.compose.bom)) + api(AndroidX.compose.foundation) + api(AndroidX.compose.foundation.layout) + api(AndroidX.compose.material3) + api(AndroidX.compose.runtime) + api(AndroidX.compose.ui) + api(AndroidX.compose.ui.tooling) + + api("org.jetbrains.kotlinx:kotlinx-collections-immutable:_") + + api lintChecks("com.slack.lint.compose:compose-lint-checks:_") + // Lottie implementation "com.airbnb.android:lottie:_" diff --git a/common/common-ui/src/main/java/com/duckduckgo/common/ui/compose/component/core/button/DaxButton.kt b/common/common-ui/src/main/java/com/duckduckgo/common/ui/compose/component/core/button/DaxButton.kt new file mode 100644 index 000000000000..371f7a450ad8 --- /dev/null +++ b/common/common-ui/src/main/java/com/duckduckgo/common/ui/compose/component/core/button/DaxButton.kt @@ -0,0 +1,104 @@ +/* + * 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.component.core.button + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonColors +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.unit.dp +import com.duckduckgo.common.ui.compose.component.core.text.DaxTextPrimary +import com.duckduckgo.common.ui.compose.theme.DuckDuckGoTheme +import com.duckduckgo.mobile.android.R + +@Composable +internal fun DaxButton( + onClick: () -> Unit, + colors: ButtonColors, + modifier: Modifier = Modifier, + enabled: Boolean = true, + height: androidx.compose.ui.unit.Dp = dimensionResource(R.dimen.buttonSmallHeight), + contentPadding: PaddingValues = PaddingValues( + horizontal = dimensionResource(R.dimen.buttonSmallSidePadding), + vertical = dimensionResource(R.dimen.buttonSmallTopPadding), + ), + content: @Composable RowScope.() -> Unit, +) { + Button( + onClick = onClick, + modifier = modifier.height(height), + colors = colors, + enabled = enabled, + shape = DuckDuckGoTheme.shapes.small, + contentPadding = contentPadding, + content = content, + ) +} + +@Composable +internal fun DaxButtonLarge( + onClick: () -> Unit, + colors: ButtonColors, + modifier: Modifier = Modifier, + enabled: Boolean = true, + content: @Composable RowScope.() -> Unit, +) { + DaxButton( + onClick = onClick, + colors = colors, + modifier = modifier, + enabled = enabled, + height = dimensionResource(R.dimen.buttonLargeHeight), + contentPadding = PaddingValues( + horizontal = dimensionResource(R.dimen.buttonLargeSidePadding), + vertical = dimensionResource(R.dimen.buttonLargeTopPadding), + ), + content = content, + ) +} + +@Composable +internal fun DaxButtonText( + text: String, + modifier: Modifier = Modifier +) { + DaxTextPrimary( + text = text, + style = DuckDuckGoTheme.typography.button, + modifier = modifier, + ) +} + +@Composable +internal fun PreviewBox( + content: @Composable () -> Unit +) { + Box( + modifier = Modifier + .background(DuckDuckGoTheme.colors.background) + .padding(16.dp), + ) { + content() + } +} diff --git a/common/common-ui/src/main/java/com/duckduckgo/common/ui/compose/component/core/button/DaxButtonDestructive.kt b/common/common-ui/src/main/java/com/duckduckgo/common/ui/compose/component/core/button/DaxButtonDestructive.kt new file mode 100644 index 000000000000..ace0f0472508 --- /dev/null +++ b/common/common-ui/src/main/java/com/duckduckgo/common/ui/compose/component/core/button/DaxButtonDestructive.kt @@ -0,0 +1,149 @@ +/* + * 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.component.core.button + +import androidx.compose.material3.ButtonColors +import androidx.compose.material3.ButtonDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.PreviewLightDark +import com.duckduckgo.common.ui.compose.component.core.text.DaxTextPrimary +import com.duckduckgo.common.ui.compose.theme.DuckDuckGoTheme + +@Composable +fun DaxButtonDestructive( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + DaxButton( + onClick = onClick, + colors = destructiveColors(), + modifier = modifier, + enabled = enabled + ) { + DaxButtonText(text) + } +} + +@Composable +fun DaxButtonDestructiveLarge( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + DaxButtonLarge( + onClick = onClick, + colors = destructiveColors(), + modifier = modifier, + enabled = enabled + ) { + DaxButtonText(text) + } +} + +@Composable +fun DaxButtonGhostDestructive( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + DaxButton( + onClick = onClick, + colors = ghostDestructiveColors(), + modifier = modifier, + enabled = enabled + ) { + DaxButtonText(text) + } +} + +@Composable +fun DaxButtonGhostDestructiveLarge( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + DaxButtonLarge( + onClick = onClick, + colors = ghostDestructiveColors(), + modifier = modifier, + enabled = enabled + ) { + DaxButtonText(text) + } +} + +@Composable +private fun destructiveColors(): ButtonColors = ButtonDefaults.buttonColors( + containerColor = DuckDuckGoTheme.colors.destructive, + contentColor = DuckDuckGoTheme.colors.text.primaryInverted, + disabledContainerColor = DuckDuckGoTheme.colors.containerDisabled, + disabledContentColor = DuckDuckGoTheme.colors.textDisabled +) + +@Composable +private fun ghostDestructiveColors(): ButtonColors = ButtonDefaults.buttonColors( + containerColor = Color.Transparent, + contentColor = DuckDuckGoTheme.colors.destructive, + disabledContainerColor = Color.Transparent, + disabledContentColor = DuckDuckGoTheme.colors.textDisabled +) + +@PreviewLightDark +@Composable +private fun DaxButtonDestructivePreview() { + DuckDuckGoTheme { + PreviewBox { + DaxButtonDestructive( + text = "Destructive", + onClick = { } + ) + } + } +} + +@PreviewLightDark +@Composable +private fun DaxButtonDestructiveLargePreview() { + DuckDuckGoTheme { + PreviewBox { + DaxButtonDestructiveLarge( + text = "Destructive Large", + onClick = { } + ) + } + } +} + +@PreviewLightDark +@Composable +private fun DaxButtonGhostDestructivePreview() { + DuckDuckGoTheme { + PreviewBox { + DaxButtonGhostDestructive( + text = "Ghost Destructive", + onClick = { } + ) + } + } +} diff --git a/common/common-ui/src/main/java/com/duckduckgo/common/ui/compose/component/core/button/DaxButtonGhost.kt b/common/common-ui/src/main/java/com/duckduckgo/common/ui/compose/component/core/button/DaxButtonGhost.kt new file mode 100644 index 000000000000..e8969bb40c3c --- /dev/null +++ b/common/common-ui/src/main/java/com/duckduckgo/common/ui/compose/component/core/button/DaxButtonGhost.kt @@ -0,0 +1,162 @@ +/* + * 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.component.core.button + +import androidx.compose.material3.ButtonColors +import androidx.compose.material3.ButtonDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.PreviewLightDark +import com.duckduckgo.common.ui.compose.component.core.text.DaxTextPrimary +import com.duckduckgo.common.ui.compose.theme.DuckDuckGoTheme + +@Composable +fun DaxButtonGhost( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + DaxButton( + onClick = onClick, + colors = ghostColors(), + modifier = modifier, + enabled = enabled + ) { + DaxButtonText(text) + } +} + +@Composable +fun DaxButtonGhostLarge( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + DaxButtonLarge( + onClick = onClick, + colors = ghostColors(), + modifier = modifier, + enabled = enabled + ) { + DaxButtonText(text) + } +} + +@Composable +fun DaxButtonGhostAlt( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + DaxButton( + onClick = onClick, + colors = ghostAltColors(), + modifier = modifier, + enabled = enabled + ) { + DaxButtonText(text) + } +} + +@Composable +fun DaxButtonGhostAltLarge( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + DaxButtonLarge( + onClick = onClick, + colors = ghostAltColors(), + modifier = modifier, + enabled = enabled + ) { + DaxButtonText(text) + } +} + +@Composable +private fun ghostColors(): ButtonColors = ButtonDefaults.buttonColors( + containerColor = Color.Transparent, + contentColor = DuckDuckGoTheme.colors.accentBlue, + disabledContainerColor = Color.Transparent, + disabledContentColor = DuckDuckGoTheme.colors.textDisabled +) + +@Composable +private fun ghostAltColors(): ButtonColors = ButtonDefaults.buttonColors( + containerColor = Color.Transparent, + contentColor = DuckDuckGoTheme.colors.text.secondary, + disabledContainerColor = Color.Transparent, + disabledContentColor = DuckDuckGoTheme.colors.textDisabled +) + +@PreviewLightDark +@Composable +private fun DaxButtonGhostPreview() { + DuckDuckGoTheme { + PreviewBox { + DaxButtonGhost( + text = "Ghost", + onClick = { } + ) + } + } +} + +@PreviewLightDark +@Composable +private fun DaxButtonGhostLargePreview() { + DuckDuckGoTheme { + PreviewBox { + DaxButtonGhostLarge( + text = "Ghost Large", + onClick = { } + ) + } + } +} + +@PreviewLightDark +@Composable +private fun DaxButtonGhostAltPreview() { + DuckDuckGoTheme { + PreviewBox { + DaxButtonGhostAlt( + text = "Ghost Alt", + onClick = { } + ) + } + } +} + +@PreviewLightDark +@Composable +private fun DaxButtonGhostAltLargePreview() { + DuckDuckGoTheme { + PreviewBox { + DaxButtonGhostAltLarge( + text = "Ghost Alt", + onClick = { } + ) + } + } +} diff --git a/common/common-ui/src/main/java/com/duckduckgo/common/ui/compose/component/core/button/DaxButtonPrimary.kt b/common/common-ui/src/main/java/com/duckduckgo/common/ui/compose/component/core/button/DaxButtonPrimary.kt new file mode 100644 index 000000000000..1f42a8cde43d --- /dev/null +++ b/common/common-ui/src/main/java/com/duckduckgo/common/ui/compose/component/core/button/DaxButtonPrimary.kt @@ -0,0 +1,111 @@ +/* + * 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.component.core.button + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ButtonColors +import androidx.compose.material3.ButtonDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import com.duckduckgo.common.ui.compose.component.core.text.DaxTextPrimary +import com.duckduckgo.common.ui.compose.theme.DuckDuckGoTheme + +@Composable +fun DaxButtonPrimary( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + DaxButton( + onClick = onClick, + colors = primaryColors(), + modifier = modifier, + enabled = enabled + ) { + DaxButtonText(text) + } +} + +@Composable +fun DaxButtonPrimaryLarge( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + DaxButtonLarge( + onClick = onClick, + colors = primaryColors(), + modifier = modifier, + enabled = enabled + ) { + DaxButtonText(text) + } +} + +@Composable +private fun primaryColors(): ButtonColors = ButtonDefaults.buttonColors( + containerColor = DuckDuckGoTheme.colors.accentBlue, + contentColor = DuckDuckGoTheme.colors.text.primaryInverted, + disabledContainerColor = DuckDuckGoTheme.colors.containerDisabled, + disabledContentColor = DuckDuckGoTheme.colors.textDisabled +) + +@PreviewLightDark +@Composable +private fun DaxButtonPrimaryPreview() { + DuckDuckGoTheme { + PreviewBox { + DaxButtonPrimary( + text = "Primary", + onClick = { } + ) + } + } +} + +@PreviewLightDark +@Composable +private fun DaxButtonPrimaryLargePreview() { + DuckDuckGoTheme { + PreviewBox { + DaxButtonPrimaryLarge( + text = "Primary Large", + onClick = { } + ) + } + } +} + +@PreviewLightDark +@Composable +private fun DaxButtonPrimaryDisabledPreview() { + DuckDuckGoTheme { + PreviewBox { + DaxButtonPrimary( + text = "Primary Disabled", + onClick = { }, + enabled = false + ) + } + } +} diff --git a/common/common-ui/src/main/java/com/duckduckgo/common/ui/compose/component/core/button/DaxButtonSecondary.kt b/common/common-ui/src/main/java/com/duckduckgo/common/ui/compose/component/core/button/DaxButtonSecondary.kt new file mode 100644 index 000000000000..961c79d1584e --- /dev/null +++ b/common/common-ui/src/main/java/com/duckduckgo/common/ui/compose/component/core/button/DaxButtonSecondary.kt @@ -0,0 +1,93 @@ +/* + * 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.component.core.button + +import androidx.compose.material3.ButtonColors +import androidx.compose.material3.ButtonDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.PreviewLightDark +import com.duckduckgo.common.ui.compose.component.core.text.DaxTextPrimary +import com.duckduckgo.common.ui.compose.theme.DuckDuckGoTheme + +@Composable +fun DaxButtonSecondary( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + DaxButton( + onClick = onClick, + colors = secondaryColors(), + modifier = modifier, + enabled = enabled + ) { + DaxButtonText(text) + } +} + +@Composable +fun DaxButtonSecondaryLarge( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + DaxButtonLarge( + onClick = onClick, + colors = secondaryColors(), + modifier = modifier, + enabled = enabled + ) { + DaxButtonText(text) + } +} + +@Composable +private fun secondaryColors(): ButtonColors = ButtonDefaults.buttonColors( + containerColor = DuckDuckGoTheme.colors.container, + contentColor = DuckDuckGoTheme.colors.text.primary, + disabledContainerColor = DuckDuckGoTheme.colors.containerDisabled, + disabledContentColor = DuckDuckGoTheme.colors.textDisabled +) + +@PreviewLightDark +@Composable +private fun DaxButtonSecondaryPreview() { + DuckDuckGoTheme { + PreviewBox { + DaxButtonSecondary( + text = "Secondary", + onClick = { } + ) + } + } +} + +@PreviewLightDark +@Composable +private fun DaxButtonSecondaryLargePreview() { + DuckDuckGoTheme { + PreviewBox { + DaxButtonSecondaryLarge( + text = "Secondary Large", + onClick = { } + ) + } + } +} diff --git a/common/common-ui/src/main/java/com/duckduckgo/common/ui/compose/component/core/text/DaxText.kt b/common/common-ui/src/main/java/com/duckduckgo/common/ui/compose/component/core/text/DaxText.kt new file mode 100644 index 000000000000..41bd034cfd94 --- /dev/null +++ b/common/common-ui/src/main/java/com/duckduckgo/common/ui/compose/component/core/text/DaxText.kt @@ -0,0 +1,138 @@ +/* + * 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.component.core.text + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.PreviewLightDark +import com.duckduckgo.common.ui.compose.theme.DuckDuckGoTextStyle +import com.duckduckgo.common.ui.compose.theme.DuckDuckGoTheme +import com.duckduckgo.common.ui.compose.theme.asTextStyle + +@Composable +fun DaxTextPrimary( + text: String, + modifier: Modifier = Modifier, + style: DuckDuckGoTextStyle = DuckDuckGoTheme.typography.body1, + textAlign: TextAlign? = null, + overflow: TextOverflow = TextOverflow.Ellipsis, + maxLines: Int = Int.MAX_VALUE, +) { + DaxText( + text = text, + color = DuckDuckGoTheme.textColors.primary, + modifier = modifier, + style = style, + textAlign = textAlign, + overflow = overflow, + maxLines = maxLines, + ) +} + +@Composable +fun DaxTextSecondary( + text: String, + modifier: Modifier = Modifier, + style: DuckDuckGoTextStyle = DuckDuckGoTheme.typography.body1, + textAlign: TextAlign? = null, + overflow: TextOverflow = TextOverflow.Ellipsis, + maxLines: Int = Int.MAX_VALUE, +) { + DaxText( + text = text, + color = DuckDuckGoTheme.textColors.secondary, + modifier = modifier, + style = style, + textAlign = textAlign, + overflow = overflow, + maxLines = maxLines, + ) +} + +@Composable +private fun DaxText( + text: String, + color: Color, + modifier: Modifier = Modifier, + style: DuckDuckGoTextStyle = DuckDuckGoTheme.typography.body1, + textAlign: TextAlign? = null, + overflow: TextOverflow = TextOverflow.Ellipsis, + maxLines: Int = Int.MAX_VALUE, +) { + Text( + text = text, + modifier = modifier, + color = color, + style = style.asTextStyle, + textAlign = textAlign, + overflow = overflow, + maxLines = maxLines, + ) +} + +@PreviewLightDark +@Composable +private fun DaxTextPrimaryPreview() { + DuckDuckGoTheme { + DaxTextPrimary("Primary Body1 Text") + } +} + +@PreviewLightDark +@Composable +private fun DaxTextSecondaryPreview() { + DuckDuckGoTheme { + DaxTextSecondary("Secondary Body1 Text") + } +} + +@PreviewLightDark +@Composable +private fun DaxTextTitlePreview() { + DuckDuckGoTheme { + DaxTextPrimary("Title Text", style = DuckDuckGoTheme.typography.title) + } +} + +@PreviewLightDark +@Composable +private fun DaxTextH1Preview() { + DuckDuckGoTheme { + DaxTextPrimary("H1 Text", style = DuckDuckGoTheme.typography.h1) + } +} + +@PreviewLightDark +@Composable +private fun DaxTextCaptionPreview() { + DuckDuckGoTheme { + DaxTextPrimary("Caption Text", style = DuckDuckGoTheme.typography.caption) + } +} + +@PreviewLightDark +@Composable +private fun DaxTextCaptionSecondaryPreview() { + DuckDuckGoTheme { + DaxTextSecondary("Caption Secondary", style = DuckDuckGoTheme.typography.caption) + } +} + diff --git a/common/common-ui/src/main/java/com/duckduckgo/common/ui/compose/theme/Color.kt b/common/common-ui/src/main/java/com/duckduckgo/common/ui/compose/theme/Color.kt new file mode 100644 index 000000000000..87c942d1f199 --- /dev/null +++ b/common/common-ui/src/main/java/com/duckduckgo/common/ui/compose/theme/Color.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2024 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.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color + +@Immutable +data class DuckDuckGoColors( + val background: Color, + val backgroundInverted: Color, + val surface: Color, + val container: Color, + val window: Color, + val text: DuckDuckGoTextColors, + val primaryIcon: Color, + val iconDisabled: Color, + val destructive: Color, + val lines: Color, + val accentBlue: Color, + val accentYellow: Color, + val containerDisabled: Color, + val textDisabled: Color, + val ripple: Color, + val logoTitleText: Color, + val omnibarTextColorHighlight: Color, +) + +@Immutable +data class DuckDuckGoTextColors( + val primary: Color, + val primaryInverted: Color, + val secondary: Color, + val secondaryInverted: Color, + val tertiaryText: Color, +) + +val LocalDuckDuckGoColors = staticCompositionLocalOf { + error("No DuckDuckGoColors provided") +} diff --git a/common/common-ui/src/main/java/com/duckduckgo/common/ui/compose/theme/Shape.kt b/common/common-ui/src/main/java/com/duckduckgo/common/ui/compose/theme/Shape.kt new file mode 100644 index 000000000000..a903d71d64ca --- /dev/null +++ b/common/common-ui/src/main/java/com/duckduckgo/common/ui/compose/theme/Shape.kt @@ -0,0 +1,32 @@ +/* + * 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.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Shape + +@Immutable +data class DuckDuckGoShapes( + val small: Shape, + val medium: Shape, + val large: Shape, +) + +val LocalDuckDuckGoShapes = staticCompositionLocalOf { + error("No DuckDuckGoShapes provided") +} diff --git a/common/common-ui/src/main/java/com/duckduckgo/common/ui/compose/theme/Theme.kt b/common/common-ui/src/main/java/com/duckduckgo/common/ui/compose/theme/Theme.kt new file mode 100644 index 000000000000..07b7fd5dad93 --- /dev/null +++ b/common/common-ui/src/main/java/com/duckduckgo/common/ui/compose/theme/Theme.kt @@ -0,0 +1,127 @@ +/* + * 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.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.dimensionResource +import com.duckduckgo.mobile.android.R + +object DuckDuckGoTheme { + + val colors: DuckDuckGoColors + @Composable + get() = LocalDuckDuckGoColors.current + + val textColors: DuckDuckGoTextColors + @Composable + get() = LocalDuckDuckGoColors.current.text + + val shapes + @Composable + get() = LocalDuckDuckGoShapes.current + + val typography + @Composable + get() = LocalDuckDuckGoTypography.current +} + +@Composable +fun ProvideDuckDuckGoTheme( + colors: DuckDuckGoColors, + shapes: DuckDuckGoShapes, + typography: DuckDuckGoTypography = DuckDuckGoTypography(colors.text.primary), + content: @Composable () -> Unit, +) { + CompositionLocalProvider( + LocalDuckDuckGoColors provides colors, + LocalDuckDuckGoShapes provides shapes, + LocalDuckDuckGoTypography provides typography, + content = content, + ) +} + +@Composable +fun DuckDuckGoTheme( + isDarkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit, +) { + val lightColorPalette = DuckDuckGoColors( + background = colorResource(R.color.gray0), + backgroundInverted = colorResource(R.color.gray100), + surface = colorResource(R.color.white), + container = colorResource(R.color.black6), + window = colorResource(R.color.white), + primaryIcon = colorResource(R.color.black84), + iconDisabled = colorResource(R.color.black40), + destructive = colorResource(R.color.alertRedOnLightDefault), + containerDisabled = colorResource(R.color.black6), + textDisabled = colorResource(R.color.black36), + lines = colorResource(R.color.black9), + accentBlue = colorResource(R.color.blue50), + accentYellow = colorResource(R.color.yellow50), + ripple = colorResource(R.color.black6), + logoTitleText = colorResource(R.color.gray85), + omnibarTextColorHighlight = colorResource(R.color.blue50_20), + text = DuckDuckGoTextColors( + primary = colorResource(R.color.black84), + primaryInverted = colorResource(R.color.white84), + secondary = colorResource(R.color.black60), + secondaryInverted = colorResource(R.color.white60), + tertiaryText = colorResource(R.color.black48), + ) + ) + + val darkColorPalette = DuckDuckGoColors( + background = colorResource(R.color.gray100), + backgroundInverted = colorResource(R.color.gray0), + surface = colorResource(R.color.gray90), + container = colorResource(R.color.white12), + window = colorResource(R.color.gray85), + primaryIcon = colorResource(R.color.white84), + iconDisabled = colorResource(R.color.white40), + destructive = colorResource(R.color.alertRedOnDarkDefault), + containerDisabled = colorResource(R.color.white18), + textDisabled = colorResource(R.color.white36), + lines = colorResource(R.color.white9), + accentBlue = colorResource(R.color.blue30), + accentYellow = colorResource(R.color.yellow50), + ripple = colorResource(R.color.white12), + logoTitleText = colorResource(R.color.white), + omnibarTextColorHighlight = colorResource(R.color.blue30_20), + text = DuckDuckGoTextColors( + primary = colorResource(R.color.white84), + primaryInverted = colorResource(R.color.black84), + secondary = colorResource(R.color.white60), + secondaryInverted = colorResource(R.color.black60), + tertiaryText = colorResource(R.color.white48), + ) + ) + + val shapes = DuckDuckGoShapes( + small = RoundedCornerShape(dimensionResource(R.dimen.smallShapeCornerRadius)), + medium = RoundedCornerShape(dimensionResource(R.dimen.mediumShapeCornerRadius)), + large = RoundedCornerShape(dimensionResource(R.dimen.largeShapeCornerRadius)), + ) + + val colors = if (isDarkTheme) darkColorPalette else lightColorPalette + + ProvideDuckDuckGoTheme(colors = colors, shapes = shapes, content = content) +} diff --git a/common/common-ui/src/main/java/com/duckduckgo/common/ui/compose/theme/Typography.kt b/common/common-ui/src/main/java/com/duckduckgo/common/ui/compose/theme/Typography.kt new file mode 100644 index 000000000000..2f30db7226f6 --- /dev/null +++ b/common/common-ui/src/main/java/com/duckduckgo/common/ui/compose/theme/Typography.kt @@ -0,0 +1,129 @@ +/* + * 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.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +@Immutable +data class DuckDuckGoTextStyle internal constructor( + internal val textStyle: TextStyle +) + +// Internal extension to extract TextStyle - only accessible within the design system +internal val DuckDuckGoTextStyle.asTextStyle: TextStyle + get() = textStyle + +@Immutable +data class DuckDuckGoTypography( + val defaultTextColor: Color, +) { + val title: DuckDuckGoTextStyle = DuckDuckGoTextStyle( + TextStyle( + fontSize = 32.sp, + lineHeight = 36.sp, + fontWeight = FontWeight.Bold, + color = defaultTextColor, + ) + ) + + val h1: DuckDuckGoTextStyle = DuckDuckGoTextStyle( + TextStyle( + fontSize = 24.sp, + lineHeight = 30.sp, + fontWeight = FontWeight.Bold, + color = defaultTextColor, + ) + ) + + val h2: DuckDuckGoTextStyle = DuckDuckGoTextStyle( + TextStyle( + fontSize = 20.sp, + lineHeight = 24.sp, + fontWeight = FontWeight.Medium, + color = defaultTextColor, + ) + ) + + val h3: DuckDuckGoTextStyle = DuckDuckGoTextStyle( + TextStyle( + fontSize = 16.sp, + lineHeight = 21.sp, + fontWeight = FontWeight.Medium, + color = defaultTextColor, + ) + ) + + val h4: DuckDuckGoTextStyle = DuckDuckGoTextStyle( + TextStyle( + fontSize = 14.sp, + lineHeight = 20.sp, + fontWeight = FontWeight.Medium, + color = defaultTextColor, + ) + ) + + val h5: DuckDuckGoTextStyle = DuckDuckGoTextStyle( + TextStyle( + fontSize = 13.sp, + lineHeight = 16.sp, + fontWeight = FontWeight.Medium, + color = defaultTextColor, + ) + ) + + val body1: DuckDuckGoTextStyle = DuckDuckGoTextStyle( + TextStyle( + fontSize = 16.sp, + lineHeight = 20.sp, + color = defaultTextColor, + ) + ) + + val body2: DuckDuckGoTextStyle = DuckDuckGoTextStyle( + TextStyle( + fontSize = 14.sp, + lineHeight = 18.sp, + color = defaultTextColor, + ) + ) + + val button: DuckDuckGoTextStyle = DuckDuckGoTextStyle( + TextStyle( + fontSize = 15.sp, + lineHeight = 20.sp, + fontWeight = FontWeight.Bold, + color = defaultTextColor, + ) + ) + + val caption: DuckDuckGoTextStyle = DuckDuckGoTextStyle( + TextStyle( + fontSize = 12.sp, + lineHeight = 16.sp, + color = defaultTextColor, + ) + ) +} + +val LocalDuckDuckGoTypography = staticCompositionLocalOf { + error("No DuckDuckGoTypography provided") +} diff --git a/common/common-ui/src/main/java/com/duckduckgo/common/ui/themepreview/ui/typography/TypographyFragment.kt b/common/common-ui/src/main/java/com/duckduckgo/common/ui/themepreview/ui/typography/TypographyFragment.kt index d6419e62cb5f..047cd952e888 100644 --- a/common/common-ui/src/main/java/com/duckduckgo/common/ui/themepreview/ui/typography/TypographyFragment.kt +++ b/common/common-ui/src/main/java/com/duckduckgo/common/ui/themepreview/ui/typography/TypographyFragment.kt @@ -21,7 +21,12 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.fragment.app.Fragment +import com.duckduckgo.common.ui.compose.component.core.text.DaxTextPrimary +import com.duckduckgo.common.ui.compose.theme.DuckDuckGoTheme import com.duckduckgo.common.ui.view.text.DaxTextView import com.duckduckgo.common.ui.view.text.DaxTextView.Typography import com.duckduckgo.mobile.android.R @@ -44,5 +49,137 @@ class TypographyFragment : Fragment() { ) { val daxTextView = view.findViewById(R.id.typographyTitle) daxTextView.setTypography(Typography.Body1) + + setupComposeViews(view) + } + + private fun setupComposeViews(view: View) { + // Title + setupComposeView(view, R.id.compose_title_label) { + DaxTextPrimary(text = "Text Appearance Title", style = DuckDuckGoTheme.typography.title) + } + setupComposeView(view, R.id.compose_title_lorem) { + DaxTextPrimary(text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit.", style = DuckDuckGoTheme.typography.title) + } + + // H1 + setupComposeView(view, R.id.compose_h1_label) { + DaxTextPrimary(text = "Text Appearance H1", style = DuckDuckGoTheme.typography.h1) + } + setupComposeView(view, R.id.compose_h1_lorem) { + DaxTextPrimary(text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", style = DuckDuckGoTheme.typography.h1) + } + + // H2 + setupComposeView(view, R.id.compose_h2_label) { + DaxTextPrimary(text = "Text Appearance H2", style = DuckDuckGoTheme.typography.h2) + } + setupComposeView(view, R.id.compose_h2_lorem) { + DaxTextPrimary(text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", style = DuckDuckGoTheme.typography.h2) + } + + // H3 + setupComposeView(view, R.id.compose_h3_label) { + DaxTextPrimary(text = "Text Appearance H3", style = DuckDuckGoTheme.typography.h3) + } + setupComposeView(view, R.id.compose_h3_lorem) { + DaxTextPrimary(text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", style = DuckDuckGoTheme.typography.h3) + } + + // H4 + setupComposeView(view, R.id.compose_h4_label) { + DaxTextPrimary(text = "Text Appearance H4", style = DuckDuckGoTheme.typography.h4) + } + setupComposeView(view, R.id.compose_h4_lorem) { + DaxTextPrimary(text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", style = DuckDuckGoTheme.typography.h4) + } + + // H5 + setupComposeView(view, R.id.compose_h5_label) { + DaxTextPrimary(text = "Text Appearance H5", style = DuckDuckGoTheme.typography.h5) + } + setupComposeView(view, R.id.compose_h5_lorem) { + DaxTextPrimary(text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", style = DuckDuckGoTheme.typography.h5) + } + + // Body1 + setupComposeView(view, R.id.compose_body1_label) { + DaxTextPrimary(text = "Text Appearance Body1", style = DuckDuckGoTheme.typography.body1) + } + setupComposeView(view, R.id.compose_body1_lorem) { + DaxTextPrimary(text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", style = DuckDuckGoTheme.typography.body1) + } + + // Body1 Bold (using body1 style for now as bold variant doesn't exist) + setupComposeView(view, R.id.compose_body1_bold_label) { + DaxTextPrimary(text = "Text Appearance Body1 Bold", style = DuckDuckGoTheme.typography.body1) + } + setupComposeView(view, R.id.compose_body1_bold_lorem) { + DaxTextPrimary(text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", style = DuckDuckGoTheme.typography.body1) + } + + // Body1 Mono (using body1 style for now as mono variant doesn't exist) + setupComposeView(view, R.id.compose_body1_mono_label) { + DaxTextPrimary(text = "Text Appearance Body1 Mono", style = DuckDuckGoTheme.typography.body1) + } + setupComposeView(view, R.id.compose_body1_mono_lorem) { + DaxTextPrimary(text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", style = DuckDuckGoTheme.typography.body1) + } + + // Body2 + setupComposeView(view, R.id.compose_body2_label) { + DaxTextPrimary(text = "Text Appearance Body2", style = DuckDuckGoTheme.typography.body2) + } + setupComposeView(view, R.id.compose_body2_lorem) { + DaxTextPrimary(text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", style = DuckDuckGoTheme.typography.body2) + } + + // Body2 Bold (using body2 style for now as bold variant doesn't exist) + setupComposeView(view, R.id.compose_body2_bold_label) { + DaxTextPrimary(text = "Text Appearance Body2 Bold", style = DuckDuckGoTheme.typography.body2) + } + setupComposeView(view, R.id.compose_body2_bold_lorem) { + DaxTextPrimary(text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", style = DuckDuckGoTheme.typography.body2) + } + + // Button + setupComposeView(view, R.id.compose_button_label) { + DaxTextPrimary(text = "Text Appearance Button", style = DuckDuckGoTheme.typography.button) + } + setupComposeView(view, R.id.compose_button_lorem) { + DaxTextPrimary(text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", style = DuckDuckGoTheme.typography.button) + } + + // Caption + setupComposeView(view, R.id.compose_caption_label) { + DaxTextPrimary(text = "Text Appearance Caption", style = DuckDuckGoTheme.typography.caption) + } + setupComposeView(view, R.id.compose_caption_lorem) { + DaxTextPrimary(text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", style = DuckDuckGoTheme.typography.caption) + } + + // Caption All Caps (using caption style for now as all caps variant doesn't exist) + setupComposeView(view, R.id.compose_caption_allcaps_label) { + DaxTextPrimary(text = "Text Appearance Caption All Caps", style = DuckDuckGoTheme.typography.caption) + } + setupComposeView(view, R.id.compose_caption_allcaps_lorem) { + DaxTextPrimary(text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", style = DuckDuckGoTheme.typography.caption) + } + + // Manual Typography + setupComposeView(view, R.id.compose_manual_typography) { + DaxTextPrimary(text = "Text Appearance Body 1 set manually", style = DuckDuckGoTheme.typography.body1) + } + } + + private fun setupComposeView(view: View, id: Int, content: @Composable () -> Unit) { + view.findViewById(id)?.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + DuckDuckGoTheme { + content() + } + } + } } } diff --git a/common/common-ui/src/main/res/layout/fragment_components_typography.xml b/common/common-ui/src/main/res/layout/fragment_components_typography.xml index d330e16a3dca..320509c73d73 100644 --- a/common/common-ui/src/main/res/layout/fragment_components_typography.xml +++ b/common/common-ui/src/main/res/layout/fragment_components_typography.xml @@ -45,6 +45,24 @@ android:text="Text Appearance Title" tools:ignore="HardcodedText"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gradle.properties b/gradle.properties index 86133788616f..37250a4effde 100644 --- a/gradle.properties +++ b/gradle.properties @@ -22,4 +22,5 @@ org.gradle.parallel=true org.gradle.configureondemand=true org.gradle.configuration-cache=true android.nonFinalResIds=false - +# Compose 1.9 requires Android Gradle Plugin (AGP) / Lint version 8.8.2 or higher. +android.experimental.lint.version=8.8.2 diff --git a/lint-rules/build.gradle b/lint-rules/build.gradle index e9d01477ba18..1e17c3b666de 100644 --- a/lint-rules/build.gradle +++ b/lint-rules/build.gradle @@ -18,7 +18,6 @@ apply plugin: 'java-library' apply plugin: 'kotlin' dependencies { - compileOnly Kotlin.stdlib.jdk7 compileOnly "com.android.tools.lint:lint-api:$lint_version" compileOnly "com.android.tools.lint:lint-checks:$lint_version" @@ -36,6 +35,16 @@ kotlin { jvmToolchain(17) } +// Force Kotlin 1.9.0 compatibility for lint-rules module +tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile) { + compilerOptions { + freeCompilerArgs.addAll([ + "-Xskip-metadata-version-check", + "-Xsuppress-version-warnings" + ]) + } +} + tasks.register('jvm_checks') { dependsOn 'test' } \ No newline at end of file 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 4250f01466b2..70b1815c267d 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 @@ -49,6 +49,8 @@ import com.duckduckgo.lint.ui.NoStyleAppliedToDesignSystemComponentDetector.Comp import com.duckduckgo.lint.ui.SkeletonViewBackgroundDetector.Companion.INVALID_SKELETON_VIEW_BACKGROUND import com.duckduckgo.lint.ui.WrongStyleDetector.Companion.WRONG_STYLE_NAME import com.duckduckgo.lint.ui.WrongStyleDetector.Companion.WRONG_STYLE_PARAMETER +import com.duckduckgo.lint.ui.NoComposeViewUsageDetector.Companion.NO_COMPOSE_VIEW_USAGE +import com.duckduckgo.lint.ui.NoSetContentDetector.Companion.NO_SET_CONTENT_USAGE @Suppress("UnstableApiUsage") class DuckDuckGoIssueRegistry : IssueRegistry() { @@ -83,7 +85,9 @@ class DuckDuckGoIssueRegistry : IssueRegistry() { INVALID_SKELETON_VIEW_BACKGROUND, WRONG_STYLE_PARAMETER, WRONG_STYLE_NAME, - INVALID_COLOR_ATTRIBUTE + INVALID_COLOR_ATTRIBUTE, + NO_COMPOSE_VIEW_USAGE, + NO_SET_CONTENT_USAGE ) diff --git a/lint-rules/src/main/java/com/duckduckgo/lint/ui/NoComposeViewUsageDetector.kt b/lint-rules/src/main/java/com/duckduckgo/lint/ui/NoComposeViewUsageDetector.kt new file mode 100644 index 000000000000..91f37cf28648 --- /dev/null +++ b/lint-rules/src/main/java/com/duckduckgo/lint/ui/NoComposeViewUsageDetector.kt @@ -0,0 +1,87 @@ +/* + * 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.detector.api.Category.Companion.CUSTOM_LINT_CHECKS +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.LayoutDetector +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 com.android.tools.lint.detector.api.XmlContext +import com.intellij.psi.PsiMethod +import org.jetbrains.uast.UCallExpression +import org.w3c.dom.Element +import java.util.EnumSet + +class NoComposeViewUsageDetector : LayoutDetector(), SourceCodeScanner { + + // XML Detection + override fun getApplicableElements() = listOf("androidx.compose.ui.platform.ComposeView") + + override fun visitElement(context: XmlContext, element: Element) { + reportComposeViewUsageInXml(context, element) + } + + // Kotlin Code Detection + override fun getApplicableConstructorTypes() = listOf("androidx.compose.ui.platform.ComposeView") + + override fun visitConstructor( + context: JavaContext, + node: UCallExpression, + constructor: PsiMethod + ) { + reportComposeViewUsageInCode(context, node) + } + + private fun reportComposeViewUsageInXml(context: XmlContext, element: Element) { + context.report( + issue = NO_COMPOSE_VIEW_USAGE, + location = context.getNameLocation(element), + message = NO_COMPOSE_VIEW_USAGE.getExplanation(TextFormat.RAW) + ) + } + + private fun reportComposeViewUsageInCode(context: JavaContext, node: UCallExpression) { + context.report( + issue = NO_COMPOSE_VIEW_USAGE, + location = context.getLocation(node), + message = NO_COMPOSE_VIEW_USAGE.getExplanation(TextFormat.RAW) + ) + } + + companion object { + val NO_COMPOSE_VIEW_USAGE = Issue + .create( + id = "NoComposeViewUsage", + briefDescription = "ComposeView should not be used in XML layouts or custom views", + explanation = "Compose is not yet approved to be used in production. ComposeView should not be used in XML layouts or custom views until Compose usage is officially approved for the codebase.", + moreInfo = "https://app.asana.com/0/1202857801505092/list", + category = CUSTOM_LINT_CHECKS, + priority = 10, + severity = Severity.ERROR, + androidSpecific = true, + implementation = Implementation( + NoComposeViewUsageDetector::class.java, + EnumSet.of(Scope.RESOURCE_FILE, Scope.JAVA_FILE, Scope.TEST_SOURCES) + ) + ) + } +} diff --git a/lint-rules/src/main/java/com/duckduckgo/lint/ui/NoSetContentDetector.kt b/lint-rules/src/main/java/com/duckduckgo/lint/ui/NoSetContentDetector.kt new file mode 100644 index 000000000000..b4ed923909ba --- /dev/null +++ b/lint-rules/src/main/java/com/duckduckgo/lint/ui/NoSetContentDetector.kt @@ -0,0 +1,80 @@ +/* + * 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.getContainingUClass +import java.util.EnumSet + +@Suppress("UnstableApiUsage") +class NoSetContentDetector : Detector(), SourceCodeScanner { + + override fun getApplicableUastTypes() = listOf(UCallExpression::class.java) + + override fun createUastHandler(context: JavaContext): UElementHandler = SetContentCallHandler(context) + + internal class SetContentCallHandler(private val context: JavaContext) : UElementHandler() { + override fun visitCallExpression(node: UCallExpression) { + if (node.methodName == "setContent") { + val containingClass = node.getContainingUClass() + if (containingClass != null && isActivityClass(containingClass)) { + reportSetContentUsage(node) + } + } + } + + private fun isActivityClass(cls: org.jetbrains.uast.UClass): Boolean { + return context.evaluator.extendsClass(cls, "android.app.Activity", false) + } + + private fun reportSetContentUsage(node: UCallExpression) { + context.report( + issue = NO_SET_CONTENT_USAGE, + location = context.getLocation(node), + message = NO_SET_CONTENT_USAGE.getExplanation(TextFormat.RAW) + ) + } + } + + companion object { + val NO_SET_CONTENT_USAGE = Issue + .create( + id = "NoSetContentUsage", + briefDescription = "setContent should not be used in Activities", + explanation = "Compose is not yet approved to be used in production. The setContent function should not be used in Activities until Compose usage is officially approved for the codebase.", + moreInfo = "https://app.asana.com/0/1202857801505092/list", + category = CUSTOM_LINT_CHECKS, + priority = 10, + severity = Severity.ERROR, + androidSpecific = true, + implementation = Implementation( + NoSetContentDetector::class.java, + EnumSet.of(Scope.JAVA_FILE, Scope.TEST_SOURCES) + ) + ) + } +} \ No newline at end of file diff --git a/lint-rules/src/test/java/com/duckduckgo/lint/ui/NoComposeViewUsageDetectorTest.kt b/lint-rules/src/test/java/com/duckduckgo/lint/ui/NoComposeViewUsageDetectorTest.kt new file mode 100644 index 000000000000..5d713026f7b6 --- /dev/null +++ b/lint-rules/src/test/java/com/duckduckgo/lint/ui/NoComposeViewUsageDetectorTest.kt @@ -0,0 +1,242 @@ +/* + * 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.duckduckgo.lint.ui.NoComposeViewUsageDetector.Companion.NO_COMPOSE_VIEW_USAGE +import org.junit.Test + +class NoComposeViewUsageDetectorTest { + + private val composeViewStub = TestFiles.kotlin( + """ + package androidx.compose.ui.platform + + import android.content.Context + import android.util.AttributeSet + import android.view.View + + class ComposeView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 + ) : View(context, attrs, defStyleAttr) + """.trimIndent() + ).indented() + + @Test + fun whenComposeViewUsedInXmlThenError() { + lint() + .files( + TestFiles.xml( + "res/layout/activity_main.xml", + """ + + + + + + """.trimIndent() + ).indented(), + composeViewStub + ) + .allowCompilationErrors() + .issues(NO_COMPOSE_VIEW_USAGE) + .run() + .expect( + """ + res/layout/activity_main.xml:7: Error: Compose is not yet approved to be used in production. ComposeView should not be used in XML layouts or custom views until Compose usage is officially approved for the codebase. [NoComposeViewUsage] + + + + + + """.trimIndent() + ).indented() + ) + .issues(NO_COMPOSE_VIEW_USAGE) + .run() + .expectClean() + } + + @Test + fun whenComposeViewConstructorUsedInKotlinThenError() { + lint() + .files( + TestFiles.kt( + """ + package com.example.test + + import android.content.Context + import androidx.compose.ui.platform.ComposeView + + class MyActivity { + fun createComposeView(context: Context) { + val composeView = ComposeView(context) + // Do something with composeView + } + } + """.trimIndent() + ).indented(), + composeViewStub + ) + .allowCompilationErrors() + .issues(NO_COMPOSE_VIEW_USAGE) + .run() + .expect( + """ + src/com/example/test/MyActivity.kt:8: Error: Compose is not yet approved to be used in production. ComposeView should not be used in XML layouts or custom views until Compose usage is officially approved for the codebase. [NoComposeViewUsage] + val composeView = ComposeView(context) + ~~~~~~~~~~~~~~~~~~~~ + 1 errors, 0 warnings + """.trimIndent() + ) + } + + @Test + fun whenComposeViewWithAttributesConstructorUsedInKotlinThenError() { + lint() + .files( + TestFiles.kt( + """ + package com.example.test + + import android.content.Context + import android.util.AttributeSet + import androidx.compose.ui.platform.ComposeView + + class CustomView(context: Context, attrs: AttributeSet?) : ComposeView(context, attrs) { + // Custom implementation + } + """.trimIndent() + ).indented(), + composeViewStub + ) + .allowCompilationErrors() + .issues(NO_COMPOSE_VIEW_USAGE) + .run() + .expect( + """ + src/com/example/test/CustomView.kt:7: Error: Compose is not yet approved to be used in production. ComposeView should not be used in XML layouts or custom views until Compose usage is officially approved for the codebase. [NoComposeViewUsage] + class CustomView(context: Context, attrs: AttributeSet?) : ComposeView(context, attrs) { + ~~~~~~~~~~~~~~~~~~~~~~~~~~~ + 1 errors, 0 warnings + """.trimIndent() + ) + } + + @Test + fun whenRegularViewUsedInKotlinThenNoError() { + lint() + .files( + TestFiles.kt( + """ + package com.example.test + + import android.content.Context + import android.widget.LinearLayout + import android.widget.TextView + + class MyActivity { + fun createViews(context: Context) { + val layout = LinearLayout(context) + val textView = TextView(context) + // Regular Android Views are fine + } + } + """.trimIndent() + ).indented() + ) + .issues(NO_COMPOSE_VIEW_USAGE) + .run() + .expectClean() + } + + @Test + fun whenMultipleComposeViewsUsedThenMultipleErrors() { + lint() + .files( + TestFiles.xml( + "res/layout/multiple_compose.xml", + """ + + + + + + + + """.trimIndent() + ).indented(), + composeViewStub + ) + .allowCompilationErrors() + .issues(NO_COMPOSE_VIEW_USAGE) + .run() + .expect( + """ + res/layout/multiple_compose.xml:7: Error: Compose is not yet approved to be used in production. ComposeView should not be used in XML layouts or custom views until Compose usage is officially approved for the codebase. [NoComposeViewUsage] + Unit) { + // Compose Activity extension function + } + """.trimIndent() + ).indented() + + @Test + fun whenSetContentUsedInActivityThenError() { + lint() + .files( + TestFiles.kt( + """ + package com.example.test + + import android.app.Activity + import androidx.activity.compose.setContent + + class MainActivity : Activity() { + override fun onCreate(savedInstanceState: android.os.Bundle?) { + super.onCreate(savedInstanceState) + setContent { + // Compose content + } + } + } + """.trimIndent() + ).indented(), + activityStub, + composeStub + ) + .allowCompilationErrors() + .issues(NO_SET_CONTENT_USAGE) + .run() + .expect( + """ + src/com/example/test/MainActivity.kt:9: Error: Compose is not yet approved to be used in production. The setContent function should not be used in Activities until Compose usage is officially approved for the codebase. [NoSetContentUsage] + setContent { + ^ + 1 errors, 0 warnings + """.trimIndent() + ) + } + + @Test + fun whenSetContentUsedInAppCompatActivityThenError() { + lint() + .files( + TestFiles.kt( + """ + package com.example.test + + import androidx.appcompat.app.AppCompatActivity + import androidx.activity.compose.setContent + + class BrowserActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: android.os.Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + // This should trigger the lint error + } + } + } + """.trimIndent() + ).indented(), + TestFiles.kotlin( + """ + package androidx.appcompat.app + + import android.app.Activity + + open class AppCompatActivity : Activity() + """.trimIndent() + ).indented(), + activityStub, + composeStub + ) + .allowCompilationErrors() + .issues(NO_SET_CONTENT_USAGE) + .run() + .expect( + """ + src/com/example/test/BrowserActivity.kt:10: Error: Compose is not yet approved to be used in production. The setContent function should not be used in Activities until Compose usage is officially approved for the codebase. [NoSetContentUsage] + setContent { + ^ + 1 errors, 0 warnings + """.trimIndent() + ) + } + + @Test + fun whenSetContentUsedInNonActivityClassThenNoError() { + lint() + .files( + TestFiles.kt( + """ + package com.example.test + + import androidx.compose.runtime.Composable + + class RegularClass { + fun setContent(content: @Composable () -> Unit) { + // This is not Activity.setContent, so it's allowed + } + + fun someMethod() { + setContent { + // This should not trigger lint error + } + } + } + """.trimIndent() + ).indented(), + activityStub, + composeStub + ) + .allowCompilationErrors() + .issues(NO_SET_CONTENT_USAGE) + .run() + .expectClean() + } + + @Test + fun whenActivityDoesNotUseSetContentThenNoError() { + lint() + .files( + TestFiles.kt( + """ + package com.example.test + + import android.app.Activity + + class MainActivity : Activity() { + override fun onCreate(savedInstanceState: android.os.Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + } + + private fun setContentView(layoutId: Int) { + // Traditional setContentView is fine + } + } + + object R { + object layout { + const val activity_main = 1 + } + } + """.trimIndent() + ).indented(), + activityStub + ) + .allowCompilationErrors() + .issues(NO_SET_CONTENT_USAGE) + .run() + .expectClean() + } + + @Test + fun whenMultipleSetContentCallsInActivityThenMultipleErrors() { + lint() + .files( + TestFiles.kt( + """ + package com.example.test + + import android.app.Activity + import androidx.activity.compose.setContent + + class MainActivity : Activity() { + override fun onCreate(savedInstanceState: android.os.Bundle?) { + super.onCreate(savedInstanceState) + + if (someCondition()) { + setContent { + // First violation + } + } else { + setContent { + // Second violation + } + } + } + + private fun someCondition() = true + } + """.trimIndent() + ).indented(), + activityStub, + composeStub + ) + .allowCompilationErrors() + .issues(NO_SET_CONTENT_USAGE) + .run() + .expect( + """ + src/com/example/test/MainActivity.kt:11: Error: Compose is not yet approved to be used in production. The setContent function should not be used in Activities until Compose usage is officially approved for the codebase. [NoSetContentUsage] + setContent { + ^ + src/com/example/test/MainActivity.kt:15: Error: Compose is not yet approved to be used in production. The setContent function should not be used in Activities until Compose usage is officially approved for the codebase. [NoSetContentUsage] + setContent { + ^ + 2 errors, 0 warnings + """.trimIndent() + ) + } + + @Test + fun whenSetContentUsedInCustomActivitySubclassThenError() { + lint() + .files( + TestFiles.kt( + """ + package com.example.test + + import android.app.Activity + import androidx.activity.compose.setContent + + open class BaseActivity : Activity() + + class CustomActivity : BaseActivity() { + fun setupUI() { + setContent { + // Should detect this in custom Activity subclass + } + } + } + """.trimIndent() + ).indented(), + activityStub, + composeStub + ) + .allowCompilationErrors() + .issues(NO_SET_CONTENT_USAGE) + .run() + .expect( + """ + src/com/example/test/BaseActivity.kt:10: Error: Compose is not yet approved to be used in production. The setContent function should not be used in Activities until Compose usage is officially approved for the codebase. [NoSetContentUsage] + setContent { + ^ + 1 errors, 0 warnings + """.trimIndent() + ) + } +} \ No newline at end of file diff --git a/versions.properties b/versions.properties index ffb4270b2348..f0f6d5276f68 100644 --- a/versions.properties +++ b/versions.properties @@ -23,6 +23,8 @@ plugin.com.diffplug.spotless=7.2.1 version.android.billingclient=7.1.1 +version.androidx.activity=1.10.1 + version.androidx.autofill=1.1.0 version.androidx.appcompat=1.7.0 @@ -31,6 +33,11 @@ version.androidx.arch.core=2.2.0 version.androidx.browser=1.8.0 +version.androidx.compose=2025.08.00 + +# Cannot update past 1.5.14 because we need Kotlin 2.x +version.androidx.compose.compiler=1.5.14 + version.androidx.constraintlayout=2.1.4 version.androidx.biometric=1.1.0 @@ -95,10 +102,15 @@ version.com.google.guava..guava=33.4.0-jre version.com.nhaarman.mockitokotlin2..mockito-kotlin=2.2.0 +version.com.slack.lint.compose..compose-lint-checks=1.4.2 + version.google.android.material=1.12.0 version.google.dagger=2.51.1 +#Cannot update to 3.1+ because we need at least Kotlin 2.1.0 +version.io.coil-kt.coil3..coil-bom=3.0.4 + version.io.github.pcmind..leveldb=1.2 version.io.github.vishwakarma..zjsonpatch=0.5.0 @@ -113,6 +125,9 @@ version.jakewharton.rxrelay2=2.0.0 version.kotlin=1.9.24 +# Cannot update to 0.4.0 because it requires Kotlin 2.1.20 +version.kotlinx.collections.immutable=0.3.8 + version.kotlinx.coroutines=1.8.1 version.leakcanary=2.14