diff options
| author | David Göransson <david.goransson@mullvad.net> | 2025-07-04 16:12:32 +0200 |
|---|---|---|
| committer | David Göransson <david.goransson@mullvad.net> | 2025-07-04 16:12:32 +0200 |
| commit | 5300f1663559ebd7a87c699db8e858d13e6fa556 (patch) | |
| tree | 0081e14129def76d6a57b32232e42411c2fbe10d /android/lib/ui/designsystem | |
| parent | 3e5795cbab6866fcd3eafee58d1790fc9e7f1829 (diff) | |
| parent | 0d5660226494abaf04dc619997bf4d6a27c637d8 (diff) | |
| download | mullvadvpn-5300f1663559ebd7a87c699db8e858d13e6fa556.tar.xz mullvadvpn-5300f1663559ebd7a87c699db8e858d13e6fa556.zip | |
Merge branch 'implement-new-select-location-design-droid-1954'
Diffstat (limited to 'android/lib/ui/designsystem')
5 files changed, 492 insertions, 0 deletions
diff --git a/android/lib/ui/designsystem/build.gradle.kts b/android/lib/ui/designsystem/build.gradle.kts new file mode 100644 index 0000000000..efc9d0108b --- /dev/null +++ b/android/lib/ui/designsystem/build.gradle.kts @@ -0,0 +1,43 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.compose) +} + +android { + namespace = "net.mullvad.mullvadvpn.lib.ui.designsystem" + compileSdk = libs.versions.compile.sdk.get().toInt() + buildToolsVersion = libs.versions.build.tools.get() + + defaultConfig { minSdk = libs.versions.min.sdk.get().toInt() } + + buildFeatures { compose = true } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = libs.versions.jvm.target.get() + allWarningsAsErrors = true + } + + lint { + lintConfig = file("${rootProject.projectDir}/config/lint.xml") + abortOnError = true + warningsAsErrors = true + } +} + +dependencies { + implementation(projects.lib.theme) + implementation(projects.lib.model) + implementation(projects.lib.ui.tag) + + implementation(libs.compose.ui) + implementation(libs.compose.ui.tooling) + implementation(libs.compose.ui.tooling.preview) + implementation(libs.compose.material3) + implementation(libs.compose.icons.extended) +} diff --git a/android/lib/ui/designsystem/src/main/AndroidManifest.xml b/android/lib/ui/designsystem/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..b2d3ea1235 --- /dev/null +++ b/android/lib/ui/designsystem/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest xmlns:android="http://schemas.android.com/apk/res/android" /> diff --git a/android/lib/ui/designsystem/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/designsystem/Checkbox.kt b/android/lib/ui/designsystem/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/designsystem/Checkbox.kt new file mode 100644 index 0000000000..15e9556e47 --- /dev/null +++ b/android/lib/ui/designsystem/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/designsystem/Checkbox.kt @@ -0,0 +1,56 @@ +package net.mullvad.mullvadvpn.lib.ui.designsystem + +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Checkbox as Material3Checkbox +import androidx.compose.material3.CheckboxColors +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.lib.theme.color.selected + +@Composable +fun Checkbox( + checked: Boolean, + onCheckedChange: ((Boolean) -> Unit)?, + modifier: Modifier = Modifier, + enabled: Boolean = true, + colors: CheckboxColors = + CheckboxDefaults.colors( + checkedColor = MaterialTheme.colorScheme.onPrimary, + uncheckedColor = MaterialTheme.colorScheme.onPrimary, + checkmarkColor = MaterialTheme.colorScheme.selected, + ), + interactionSource: MutableInteractionSource? = null, +) { + Material3Checkbox( + checked = checked, + onCheckedChange = onCheckedChange, + modifier = modifier, + enabled = enabled, + colors = colors, + interactionSource = interactionSource, + ) +} + +@Preview +@Composable +private fun PreviewCheckbox() { + AppTheme { + Column( + Modifier.background(color = MaterialTheme.colorScheme.background), + verticalArrangement = Arrangement.spacedBy(Dimens.smallSpacer), + ) { + Checkbox(checked = false, null) + Checkbox(checked = true, null) + Checkbox(checked = false, null, enabled = false) + Checkbox(checked = true, null, enabled = false) + } + } +} diff --git a/android/lib/ui/designsystem/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/designsystem/RelayListHeader.kt b/android/lib/ui/designsystem/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/designsystem/RelayListHeader.kt new file mode 100644 index 0000000000..3dc20ff0e7 --- /dev/null +++ b/android/lib/ui/designsystem/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/designsystem/RelayListHeader.kt @@ -0,0 +1,78 @@ +package net.mullvad.mullvadvpn.lib.ui.designsystem + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens + +private val LIST_HEADER_MIN_HEIGHT = 48.dp + +@Composable +fun RelayListHeader( + content: @Composable () -> Unit, + modifier: Modifier = Modifier, + actions: @Composable (RowScope.() -> Unit)? = null, +) { + ProvideContentColorTextStyle( + MaterialTheme.colorScheme.onBackground, + MaterialTheme.typography.bodyLarge, + ) { + Row( + modifier = + Modifier.padding(horizontal = Dimens.tinyPadding) + .defaultMinSize(minHeight = LIST_HEADER_MIN_HEIGHT) + .height(IntrinsicSize.Min) + .then(modifier), + verticalAlignment = Alignment.CenterVertically, + ) { + content() + HorizontalDivider( + Modifier.weight(1f, true).padding(start = Dimens.smallPadding), + color = + MaterialTheme.colorScheme.onBackground.copy( + alpha = RelayListHeaderTokens.RelayListHeaderDividerAlpha + ), + ) + actions?.invoke(this) + } + } +} + +object RelayListHeaderTokens { + const val RelayListHeaderDividerAlpha = 0.2f +} + +@Preview(backgroundColor = 0xFF192E45, showBackground = true) +@Composable +fun PreviewRelayListHeader() { + AppTheme { + Column { + RelayListHeader(content = { Text("Header") }) + RelayListHeader( + content = { Text("Header") }, + actions = { + IconButton(onClick = {}) { + Icon(imageVector = Icons.Default.Edit, contentDescription = null) + } + }, + ) + } + } +} diff --git a/android/lib/ui/designsystem/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/designsystem/RelayListItem.kt b/android/lib/ui/designsystem/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/designsystem/RelayListItem.kt new file mode 100644 index 0000000000..c2e9664a18 --- /dev/null +++ b/android/lib/ui/designsystem/src/main/kotlin/net/mullvad/mullvadvpn/lib/ui/designsystem/RelayListItem.kt @@ -0,0 +1,313 @@ +package net.mullvad.mullvadvpn.lib.ui.designsystem + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material3.Icon +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewFontScale +import androidx.compose.ui.unit.dp +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.color.AlphaInactive + +@Composable +fun RelayListItem( + modifier: Modifier = Modifier, + selected: Boolean = false, + enabled: Boolean = true, + onClick: (() -> Unit) = {}, + onLongClick: (() -> Unit)? = {}, + leadingContent: @Composable (() -> Unit)? = null, + content: @Composable () -> Unit, + trailingContent: @Composable (() -> Unit)? = null, + colors: RelayListItemColors = RelayListItemDefaults.colors(), + shape: Shape = RectangleShape, +) { + Surface( + modifier = + modifier + .defaultMinSize(minHeight = RelayListTokens.listItemMinHeight) + .height(IntrinsicSize.Min), + shape = shape, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(RelayListTokens.listItemSpacer), + verticalAlignment = Alignment.CenterVertically, + ) { + if (leadingContent != null) { + Box( + Modifier.background(colors.containerColor) + .width(RelayListTokens.listItemButtonWidth) + .fillMaxHeight(), + contentAlignment = Alignment.Center, + ) { + ProvideContentColorTextStyle( + colors.leadingIconColor, + MaterialTheme.typography.titleMedium, + ) { + leadingContent() + } + } + } + + Row( + Modifier.weight(1f, fill = true) + .background(colors.containerColor) + .fillMaxHeight() + .combinedClickable( + enabled = true, + onClick = onClick, + onLongClick = onLongClick, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + ProvideContentColorTextStyle( + colors.headlineColor(enabled, selected), + MaterialTheme.typography.titleMedium, + ) { + content() + } + } + + if (trailingContent != null) { + Box( + Modifier.background(color = colors.containerColor) + .width(RelayListTokens.listItemButtonWidth) + .fillMaxHeight() + ) { + ProvideContentColorTextStyle( + colors.trailingIconColor, + MaterialTheme.typography.titleMedium, + ) { + trailingContent() + } + } + } + } + } +} + +// Based of ListItem +@Immutable +class RelayListItemColors( + val containerColor: Color, + val headlineColor: Color, + val leadingIconColor: Color, + val trailingIconColor: Color, + val selectedHeadlineColor: Color, + val disabledHeadlineColor: Color, +) { + internal fun containerColor(): Color = containerColor + + @Stable + internal fun headlineColor(enabled: Boolean, selected: Boolean): Color = + when { + !enabled -> disabledHeadlineColor + selected -> selectedHeadlineColor + else -> headlineColor + } +} + +@Composable +internal fun ProvideContentColorTextStyle( + contentColor: Color, + textStyle: TextStyle, + content: @Composable () -> Unit, +) { + val mergedStyle = LocalTextStyle.current.merge(textStyle) + CompositionLocalProvider( + LocalContentColor provides contentColor, + LocalTextStyle provides mergedStyle, + content = content, + ) +} + +object RelayListItemDefaults { + @Composable + fun colors( + containerColor: Color = MaterialTheme.colorScheme.surface, + headlineColor: Color = MaterialTheme.colorScheme.onSurface, + leadingIconColor: Color = MaterialTheme.colorScheme.onSurface, + trailingIconColor: Color = MaterialTheme.colorScheme.onSurface, + selectedHeadlineColor: Color = MaterialTheme.colorScheme.tertiary, + disabledHeadlineColor: Color = + headlineColor.copy(alpha = RelayListTokens.RelayListItemDisabledLabelTextOpacity), + ): RelayListItemColors = + RelayListItemColors( + containerColor = containerColor, + headlineColor = headlineColor, + leadingIconColor = leadingIconColor, + trailingIconColor = trailingIconColor, + selectedHeadlineColor = selectedHeadlineColor, + disabledHeadlineColor = disabledHeadlineColor, + ) +} + +object RelayListTokens { + const val RelayListItemDisabledLabelTextOpacity = AlphaInactive + + val listItemMinHeight = 56.dp + val listItemSpacer = 2.dp + val listItemButtonWidth = 56.dp +} + +@Preview +@PreviewFontScale +@Composable +private fun PreviewSimpleRelayListItem() { + AppTheme { + RelayListItem( + modifier = Modifier.fillMaxWidth(), + content = { Text("Hello world", modifier = Modifier.padding(16.dp).fillMaxSize()) }, + ) + } +} + +@Preview +@PreviewFontScale +@Composable +private fun PreviewLeadingRelayListItem() { + AppTheme { + RelayListItem( + modifier = Modifier.fillMaxWidth(), + content = { + Text( + "Hello world fsadhkuhfiuskahf iuhsadhuf sa", + modifier = + Modifier.padding(16.dp) + .fillMaxSize() + .wrapContentHeight(align = Alignment.CenterVertically), + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + leadingContent = { + Box( + modifier = Modifier.fillMaxSize().clickable(onClick = { /* Handle click */ }), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier.padding(16.dp), + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = null, + ) + } + }, + ) + } +} + +@Preview +@PreviewFontScale +@Composable +private fun PreviewTrailingRelayListItem() { + AppTheme { + RelayListItem( + modifier = Modifier.fillMaxWidth(), + selected = true, + content = { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(imageVector = Icons.Default.Check, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text( + "Hello world fsadhkuhfiuskahf iuhsadhuf sa", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + }, + trailingContent = { + Box( + modifier = Modifier.fillMaxSize().clickable(onClick = {}), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier.padding(16.dp), + imageVector = Icons.Default.Add, + contentDescription = null, + ) + } + }, + ) + } +} + +@Preview +@PreviewFontScale +@Composable +private fun PreviewLeadingAndTrailingRelayListItem() { + AppTheme { + RelayListItem( + modifier = Modifier.fillMaxWidth(), + content = { + Text( + "Hello world iuhsadhuf sa", + modifier = Modifier.clickable {}.padding(16.dp).fillMaxSize(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + leadingContent = { + Box( + modifier = Modifier.fillMaxSize().clickable(onClick = {}), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier.padding(16.dp), + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = null, + ) + } + }, + trailingContent = { + Box( + modifier = Modifier.fillMaxSize().clickable(onClick = {}), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier.padding(16.dp), + imageVector = Icons.Default.Add, + contentDescription = null, + ) + } + }, + ) + } +} |
