diff options
| author | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2024-03-13 13:04:14 +0100 |
|---|---|---|
| committer | Jonatan Rhodin <jonatan.rhodin@mullvad.net> | 2024-03-14 14:53:44 +0100 |
| commit | 866d475e6688ca0fa35ec182b0715a258be467b8 (patch) | |
| tree | 470683af79c9e6893ee5059661c705fbe67e6a44 /android | |
| parent | a4308de73a14665330bdff45cd9add1339bbccf8 (diff) | |
| download | mullvadvpn-866d475e6688ca0fa35ec182b0715a258be467b8.tar.xz mullvadvpn-866d475e6688ca0fa35ec182b0715a258be467b8.zip | |
Add custom lists to select location screen
Diffstat (limited to 'android')
39 files changed, 1443 insertions, 377 deletions
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/BaseCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/BaseCell.kt index 15f6e4d111..19049613bd 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/BaseCell.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/BaseCell.kt @@ -35,7 +35,7 @@ private fun PreviewBaseCell() { AppTheme { SpacedColumn { BaseCell( - title = { + headlineContent = { BaseCellTitle( title = "Header title", style = MaterialTheme.typography.titleMedium @@ -43,7 +43,7 @@ private fun PreviewBaseCell() { } ) BaseCell( - title = { + headlineContent = { BaseCellTitle( title = "Normal title", style = MaterialTheme.typography.labelLarge @@ -58,7 +58,7 @@ private fun PreviewBaseCell() { internal fun BaseCell( modifier: Modifier = Modifier, iconView: @Composable RowScope.() -> Unit = {}, - title: @Composable RowScope.() -> Unit, + headlineContent: @Composable RowScope.() -> Unit, bodyView: @Composable ColumnScope.() -> Unit = {}, isRowEnabled: Boolean = true, onCellClicked: () -> Unit = {}, @@ -83,7 +83,7 @@ internal fun BaseCell( ) { iconView() - title() + headlineContent() Column(modifier = Modifier.wrapContentWidth().wrapContentHeight()) { bodyView() } } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CheckboxCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CheckboxCell.kt index 5190a3a959..fe270ba445 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CheckboxCell.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CheckboxCell.kt @@ -2,16 +2,12 @@ package net.mullvad.mullvadvpn.compose.cell import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.defaultMinSize -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material3.Checkbox -import androidx.compose.material3.CheckboxDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -20,9 +16,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp +import net.mullvad.mullvadvpn.compose.component.MullvadCheckbox import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens -import net.mullvad.mullvadvpn.lib.theme.color.selected @Preview @Composable @@ -37,7 +33,7 @@ internal fun CheckboxCell( checked: Boolean, onCheckedChange: (Boolean) -> Unit, background: Color = MaterialTheme.colorScheme.secondaryContainer, - startPadding: Dp = Dimens.cellStartPadding, + startPadding: Dp = Dimens.mediumPadding, endPadding: Dp = Dimens.cellEndPadding, minHeight: Dp = Dimens.cellHeight ) { @@ -51,23 +47,7 @@ internal fun CheckboxCell( .background(background) .padding(start = startPadding, end = endPadding) ) { - Box( - modifier = - Modifier.size(Dimens.checkBoxSize) - .background(Color.White, MaterialTheme.shapes.small) - ) { - Checkbox( - modifier = Modifier.fillMaxSize(), - checked = checked, - onCheckedChange = onCheckedChange, - colors = - CheckboxDefaults.colors( - checkedColor = Color.Transparent, - uncheckedColor = Color.Transparent, - checkmarkColor = MaterialTheme.colorScheme.selected - ), - ) - } + MullvadCheckbox(checked = checked, onCheckedChange = onCheckedChange) Spacer(modifier = Modifier.size(Dimens.mediumPadding)) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CustomListCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CustomListCell.kt new file mode 100644 index 0000000000..1029cfada0 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/CustomListCell.kt @@ -0,0 +1,31 @@ +package net.mullvad.mullvadvpn.compose.cell + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import net.mullvad.mullvadvpn.relaylist.RelayItem + +@Composable +fun CustomListCell( + customList: RelayItem.CustomList, + modifier: Modifier = Modifier, + onCellClicked: (RelayItem.CustomList) -> Unit = {}, + textStyle: TextStyle = MaterialTheme.typography.titleMedium, + textColor: Color = MaterialTheme.colorScheme.onPrimary, + background: Color = MaterialTheme.colorScheme.primary, +) { + BaseCell( + headlineContent = { + BaseCellTitle( + title = customList.name, + style = textStyle, + color = textColor, + ) + }, + modifier = modifier, + background = background, + onCellClicked = { onCellClicked(customList) } + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/DnsCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/DnsCell.kt index 2a0043842a..21d5558f9e 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/DnsCell.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/DnsCell.kt @@ -34,7 +34,7 @@ fun DnsCell( val startPadding = 54.dp BaseCell( - title = { DnsTitle(address = address, modifier = titleModifier) }, + headlineContent = { DnsTitle(address = address, modifier = titleModifier) }, bodyView = { if (isUnreachableLocalDnsWarningVisible) { Icon( diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/DropdownMenuCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/DropdownMenuCell.kt new file mode 100644 index 0000000000..3d52aca80c --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/DropdownMenuCell.kt @@ -0,0 +1,61 @@ +package net.mullvad.mullvadvpn.compose.cell + +import androidx.compose.foundation.background +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.tooling.preview.Preview +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens + +@Preview +@Composable +private fun PreviewThreeDotCell() { + AppTheme { + ThreeDotCell( + text = "Three dots", + ) + } +} + +@Composable +fun ThreeDotCell( + text: String, + modifier: Modifier = Modifier, + onClickDots: () -> Unit = {}, + textStyle: TextStyle = MaterialTheme.typography.titleMedium, + textColor: Color = MaterialTheme.colorScheme.onPrimary, + background: Color = MaterialTheme.colorScheme.primary +) { + BaseCell( + headlineContent = { + BaseCellTitle( + title = text, + style = textStyle, + color = textColor, + modifier = Modifier.weight(1f, true) + ) + }, + modifier = modifier, + background = background, + bodyView = { + IconButton(onClick = onClickDots) { + Icon( + painter = painterResource(id = R.drawable.icon_more_vert), + contentDescription = null, + tint = textColor + ) + } + }, + isRowEnabled = false, + endPadding = Dimens.smallPadding + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ExpandableComposeCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ExpandableComposeCell.kt index 9ea26d6e01..73a6a5283d 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ExpandableComposeCell.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/ExpandableComposeCell.kt @@ -54,7 +54,7 @@ fun ExpandableComposeCell( BaseCell( modifier = Modifier.focusProperties { canFocus = false }, - title = { + headlineContent = { BaseCellTitle( title = title, style = MaterialTheme.typography.titleMedium, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/HeaderCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/HeaderCell.kt index 8807430989..3260e9099a 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/HeaderCell.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/HeaderCell.kt @@ -15,7 +15,7 @@ fun HeaderCell( background: Color = MaterialTheme.colorScheme.primary, ) { BaseCell( - title = { + headlineContent = { BaseCellTitle( title = text, style = textStyle, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/IconCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/IconCell.kt new file mode 100644 index 0000000000..faf537fb7f --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/IconCell.kt @@ -0,0 +1,54 @@ +package net.mullvad.mullvadvpn.compose.cell + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.tooling.preview.Preview +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens + +@Preview +@Composable +private fun PreviewIconCell() { + AppTheme { IconCell(iconId = R.drawable.icon_add, title = "Add") } +} + +@Composable +fun IconCell( + iconId: Int?, + contentDescription: String? = null, + title: String, + titleStyle: TextStyle = MaterialTheme.typography.labelLarge, + titleColor: Color = MaterialTheme.colorScheme.onPrimary, + onClick: () -> Unit = {}, + background: Color = MaterialTheme.colorScheme.primary, + enabled: Boolean = true, +) { + BaseCell( + headlineContent = { + Row(verticalAlignment = Alignment.CenterVertically) { + iconId?.let { + Icon( + painter = painterResource(id = iconId), + contentDescription = contentDescription, + tint = titleColor + ) + Spacer(modifier = Modifier.width(Dimens.mediumPadding)) + } + BaseCellTitle(title = title, style = titleStyle, color = titleColor) + } + }, + onCellClicked = onClick, + background = background, + isRowEnabled = enabled + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/InformationComposeCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/InformationComposeCell.kt index ef898aac6d..f490294491 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/InformationComposeCell.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/InformationComposeCell.kt @@ -47,7 +47,7 @@ fun InformationComposeCell( BaseCell( modifier = Modifier.focusProperties { canFocus = false }, - title = { + headlineContent = { BaseCellTitle( title = title, style = MaterialTheme.typography.titleMedium, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/MtuComposeCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/MtuComposeCell.kt index 7cd45ddb2d..d949f2a708 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/MtuComposeCell.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/MtuComposeCell.kt @@ -28,7 +28,7 @@ fun MtuComposeCell( val titleModifier = Modifier BaseCell( - title = { MtuTitle(modifier = titleModifier.weight(1f, true)) }, + headlineContent = { MtuTitle(modifier = titleModifier.weight(1f, true)) }, bodyView = { MtuBodyView(mtuValue = mtuValue, modifier = titleModifier) }, onCellClicked = { onEditMtu.invoke() } ) diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/NavigationComposeCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/NavigationComposeCell.kt index 272e599d8d..27b74227ca 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/NavigationComposeCell.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/NavigationComposeCell.kt @@ -70,7 +70,7 @@ fun NavigationComposeCell( ) { BaseCell( onCellClicked = onClick, - title = { + headlineContent = { NavigationTitleView( title = title, modifier = modifier.weight(1f, true), diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/RelayLocationCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/RelayLocationCell.kt index 68899f7f77..032695be88 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/RelayLocationCell.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/RelayLocationCell.kt @@ -1,13 +1,17 @@ package net.mullvad.mullvadvpn.compose.cell import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope 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.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -25,163 +29,210 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.component.Chevron +import net.mullvad.mullvadvpn.compose.component.MullvadCheckbox +import net.mullvad.mullvadvpn.compose.util.generateRelayItemCountry import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens -import net.mullvad.mullvadvpn.lib.theme.color.Alpha40 import net.mullvad.mullvadvpn.lib.theme.color.AlphaInactive import net.mullvad.mullvadvpn.lib.theme.color.AlphaInvisible import net.mullvad.mullvadvpn.lib.theme.color.AlphaVisible import net.mullvad.mullvadvpn.lib.theme.color.selected -import net.mullvad.mullvadvpn.model.GeographicLocationConstraint import net.mullvad.mullvadvpn.relaylist.RelayItem +import net.mullvad.mullvadvpn.relaylist.children @Composable @Preview -private fun PreviewRelayLocationCell() { +private fun PreviewStatusRelayLocationCell() { AppTheme { Column(Modifier.background(color = MaterialTheme.colorScheme.background)) { val countryActive = - RelayItem.Country( + generateRelayItemCountry( name = "Relay country Active", - code = "RC1", - expanded = false, - cities = - listOf( - RelayItem.City( - name = "Relay city 1", - code = "RI1", - expanded = false, - location = GeographicLocationConstraint.City("RC1", "RI1"), - relays = - listOf( - RelayItem.Relay( - name = "Relay 1", - active = true, - locationName = "", - location = - GeographicLocationConstraint.Hostname( - "RC1", - "RI1", - "NER" - ) - ) - ) - ), - RelayItem.City( - name = "Relay city 2", - code = "RI2", - expanded = true, - location = GeographicLocationConstraint.City("RC1", "RI2"), - relays = - listOf( - RelayItem.Relay( - name = "Relay 2", - active = true, - locationName = "", - location = - GeographicLocationConstraint.Hostname( - "RC1", - "RI2", - "NER" - ) - ), - RelayItem.Relay( - name = "Relay 3", - active = true, - locationName = "", - location = - GeographicLocationConstraint.Hostname( - "RC1", - "RI1", - "NER" - ) - ) - ) - ) - ) + cityNames = listOf("Relay city 1", "Relay city 2"), + relaysPerCity = 2 ) val countryNotActive = - RelayItem.Country( + generateRelayItemCountry( name = "Not Enabled Relay country", - code = "RC3", + cityNames = listOf("Not Enabled city"), + relaysPerCity = 1, + active = false + ) + val countryExpanded = + generateRelayItemCountry( + name = "Relay country Expanded", + cityNames = listOf("Normal city"), + relaysPerCity = 2, + expanded = true + ) + val countryAndCityExpanded = + generateRelayItemCountry( + name = "Country and city Expanded", + cityNames = listOf("Expanded city A", "Expanded city B"), + relaysPerCity = 2, expanded = true, - cities = - listOf( - RelayItem.City( - name = "Not Enabled city", - code = "RI3", - expanded = true, - location = GeographicLocationConstraint.City("RC3", "RI3"), - relays = - listOf( - RelayItem.Relay( - name = "Not Enabled Relay", - active = false, - locationName = "", - location = - GeographicLocationConstraint.Hostname( - "RC3", - "RI3", - "NER" - ) - ) - ) - ) - ) + expandChildren = true ) // Active relay list not expanded - RelayLocationCell(countryActive) + StatusRelayLocationCell(countryActive) // Not Active Relay - RelayLocationCell(countryNotActive) - // Relay expanded country and city - RelayLocationCell(countryActive.copy(expanded = true)) + StatusRelayLocationCell(countryNotActive) + // Relay expanded country + StatusRelayLocationCell(countryExpanded) + // Relay expanded country and cities + StatusRelayLocationCell(countryAndCityExpanded) + } + } +} + +@Composable +@Preview +private fun PreviewCheckableRelayLocationCell() { + AppTheme { + Column(Modifier.background(color = MaterialTheme.colorScheme.background)) { + val countryActive = + generateRelayItemCountry( + name = "Relay country Active", + cityNames = listOf("Relay city 1", "Relay city 2"), + relaysPerCity = 2 + ) + val countryExpanded = + generateRelayItemCountry( + name = "Relay country Expanded", + cityNames = listOf("Normal city"), + relaysPerCity = 2, + expanded = true + ) + val countryAndCityExpanded = + generateRelayItemCountry( + name = "Country and city Expanded", + cityNames = listOf("Expanded city A", "Expanded city B"), + relaysPerCity = 2, + expanded = true, + expandChildren = true + ) + // Active relay list not expanded + CheckableRelayLocationCell(countryActive) + // Relay expanded country + CheckableRelayLocationCell(countryExpanded) + // Relay expanded country and cities + CheckableRelayLocationCell(countryAndCityExpanded) } } } @Composable -fun RelayLocationCell( +fun StatusRelayLocationCell( relay: RelayItem, modifier: Modifier = Modifier, activeColor: Color = MaterialTheme.colorScheme.selected, inactiveColor: Color = MaterialTheme.colorScheme.error, + disabledColor: Color = MaterialTheme.colorScheme.onSecondary, selectedItem: RelayItem? = null, - onSelectRelay: (item: RelayItem) -> Unit = {} + onSelectRelay: (item: RelayItem) -> Unit = {}, + onLongClick: (item: RelayItem) -> Unit = {} ) { - val startPadding = - when (relay) { - is RelayItem.Country, - is RelayItem.CustomList -> Dimens.countryRowPadding - is RelayItem.City -> Dimens.cityRowPadding - is RelayItem.Relay -> Dimens.relayRowPadding - } - val selected = selectedItem?.code == relay.code + RelayLocationCell( + relay = relay, + leadingContent = { relayItem -> + val selected = selectedItem?.code == relayItem.code + Box( + modifier = + Modifier.align(Alignment.CenterStart) + .size(Dimens.relayCircleSize) + .background( + color = + when { + selected -> Color.Unspecified + relayItem is RelayItem.CustomList && !relayItem.hasChildren -> + disabledColor + relayItem.active -> activeColor + else -> inactiveColor + }, + shape = CircleShape + ) + ) + Image( + painter = painterResource(id = R.drawable.icon_tick), + modifier = + Modifier.align(Alignment.CenterStart) + .alpha( + if (selected) { + AlphaVisible + } else { + AlphaInvisible + } + ), + contentDescription = null + ) + }, + modifier = modifier, + specialBackgroundColor = { relayItem -> + when { + selectedItem?.code == relayItem.code -> MaterialTheme.colorScheme.selected + relayItem is RelayItem.CustomList && !relayItem.active -> + MaterialTheme.colorScheme.surfaceTint + else -> null + } + }, + onClick = onSelectRelay, + onLongClick = onLongClick, + depth = 0 + ) +} + +@Composable +fun CheckableRelayLocationCell( + relay: RelayItem, + modifier: Modifier = Modifier, + onRelayCheckedChange: (item: RelayItem, isChecked: Boolean) -> Unit = { _, _ -> }, + selectedRelays: Set<RelayItem> = emptySet(), +) { + RelayLocationCell( + relay = relay, + leadingContent = { relayItem -> + val checked = selectedRelays.contains(relayItem) + MullvadCheckbox( + checked = checked, + onCheckedChange = { isChecked -> onRelayCheckedChange(relayItem, isChecked) } + ) + }, + leadingContentStartPadding = Dimens.cellStartPaddingInteractive, + modifier = modifier, + onClick = { onRelayCheckedChange(it, !selectedRelays.contains(it)) }, + onLongClick = null, + depth = 0 + ) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun RelayLocationCell( + relay: RelayItem, + leadingContent: @Composable BoxScope.(relay: RelayItem) -> Unit, + modifier: Modifier = Modifier, + leadingContentStartPadding: Dp = Dimens.cellStartPadding, + leadingContentStarPaddingModifier: Dp = Dimens.mediumPadding, + specialBackgroundColor: @Composable (relayItem: RelayItem) -> Color? = { null }, + onClick: (item: RelayItem) -> Unit, + onLongClick: ((item: RelayItem) -> Unit)?, + depth: Int +) { + val startPadding = leadingContentStartPadding + leadingContentStarPaddingModifier * depth val expanded = rememberSaveable(key = relay.expanded.toString()) { mutableStateOf(relay.expanded) } - val backgroundColor = - when { - selected -> MaterialTheme.colorScheme.inversePrimary - relay is RelayItem.Country -> MaterialTheme.colorScheme.primary - relay is RelayItem.City -> - MaterialTheme.colorScheme.primary - .copy(alpha = Alpha40) - .compositeOver(MaterialTheme.colorScheme.background) - relay is RelayItem.Relay -> MaterialTheme.colorScheme.secondaryContainer - else -> MaterialTheme.colorScheme.primary - } Column( modifier = - modifier.then( - Modifier.fillMaxWidth() - .padding(top = Dimens.listItemDivider) - .wrapContentHeight() - .fillMaxWidth() - ) + modifier + .fillMaxWidth() + .padding(top = Dimens.listItemDivider) + .wrapContentHeight() + .fillMaxWidth() ) { Row( modifier = @@ -189,110 +240,89 @@ fun RelayLocationCell( .wrapContentHeight() .height(IntrinsicSize.Min) .fillMaxWidth() - .background(backgroundColor) + .background(specialBackgroundColor.invoke(relay) ?: depth.toBackgroundColor()) ) { Row( modifier = Modifier.weight(1f) - .then( - if (relay.active) { - Modifier.clickable { onSelectRelay(relay) } - } else { - Modifier - } + .combinedClickable( + enabled = relay.active, + onClick = { onClick(relay) }, + onLongClick = { onLongClick?.invoke(relay) }, ) ) { Box( modifier = Modifier.align(Alignment.CenterVertically).padding(start = startPadding) ) { - Box( - modifier = - Modifier.align(Alignment.CenterStart) - .size(Dimens.relayCircleSize) - .background( - color = - when { - selected -> Color.Transparent - relay.active -> activeColor - else -> inactiveColor - }, - shape = CircleShape - ) - ) - Image( - painter = painterResource(id = R.drawable.icon_tick), - modifier = - Modifier.align(Alignment.CenterStart) - .alpha( - if (selected) { - AlphaVisible - } else { - AlphaInvisible - } - ), - contentDescription = null - ) + leadingContent(relay) } - Text( - text = relay.name, - color = MaterialTheme.colorScheme.onPrimary, - modifier = - Modifier.weight(1f) - .align(Alignment.CenterVertically) - .alpha( - if (relay.active) { - AlphaVisible - } else { - AlphaInactive - } - ) - .padding( - horizontal = Dimens.smallPadding, - vertical = Dimens.mediumPadding - ) + Name( + modifier = Modifier.weight(1f).align(Alignment.CenterVertically), + relay = relay ) } if (relay.hasChildren) { - VerticalDivider( - color = MaterialTheme.colorScheme.background, - modifier = Modifier.padding(vertical = Dimens.verticalDividerPadding) - ) - Chevron( - isExpanded = expanded.value, - modifier = - Modifier.fillMaxHeight() - .clickable { expanded.value = !expanded.value } - .padding(horizontal = Dimens.largePadding) - .align(Alignment.CenterVertically) - ) + ExpandButton(isExpanded = expanded.value) { expand -> expanded.value = expand } } } if (expanded.value) { - when (relay) { - is RelayItem.Country -> { - relay.cities.forEach { relayCity -> - RelayLocationCell( - relay = relayCity, - selectedItem = selectedItem, - onSelectRelay = onSelectRelay, - modifier = Modifier.animateContentSize() - ) - } - } - is RelayItem.City -> { - relay.relays.forEach { relay -> - RelayLocationCell( - relay = relay, - selectedItem = selectedItem, - onSelectRelay = onSelectRelay, - modifier = Modifier.animateContentSize() - ) - } - } - is RelayItem.Relay, - is RelayItem.CustomList -> {} + relay.children().forEach { + RelayLocationCell( + relay = it, + onClick = onClick, + modifier = Modifier.animateContentSize(), + leadingContent = leadingContent, + specialBackgroundColor = specialBackgroundColor, + onLongClick = onLongClick, + depth = depth + 1, + ) } } } } + +@Composable +private fun Name(modifier: Modifier = Modifier, relay: RelayItem) { + Text( + text = relay.name, + color = MaterialTheme.colorScheme.onPrimary, + modifier = + modifier + .alpha( + if (relay.active) { + AlphaVisible + } else { + AlphaInactive + } + ) + .padding(horizontal = Dimens.smallPadding, vertical = Dimens.mediumPadding) + ) +} + +@Composable +private fun RowScope.ExpandButton(isExpanded: Boolean, onClick: (expand: Boolean) -> Unit) { + VerticalDivider( + color = MaterialTheme.colorScheme.background, + modifier = Modifier.padding(vertical = Dimens.verticalDividerPadding) + ) + Chevron( + isExpanded = isExpanded, + modifier = + Modifier.fillMaxHeight() + .clickable { onClick(!isExpanded) } + .padding(horizontal = Dimens.largePadding) + .align(Alignment.CenterVertically) + ) +} + +@Suppress("MagicNumber") +@Composable +private fun Int.toBackgroundColor(): Color = + when (this) { + 0 -> MaterialTheme.colorScheme.surfaceContainerHighest + 1 -> MaterialTheme.colorScheme.surfaceContainerHigh + 2 -> MaterialTheme.colorScheme.surfaceContainerLow + 3 -> MaterialTheme.colorScheme.surfaceContainerLowest + else -> MaterialTheme.colorScheme.surfaceContainerLowest + } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SelectableCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SelectableCell.kt index 686ba8846d..d69194490e 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SelectableCell.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SelectableCell.kt @@ -56,7 +56,7 @@ fun SelectableCell( ) { BaseCell( onCellClicked = onCellClicked, - title = { BaseCellTitle(title = title, style = titleStyle) }, + headlineContent = { BaseCellTitle(title = title, style = titleStyle) }, background = if (isSelected) { selectedColor diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SplitTunnelingCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SplitTunnelingCell.kt index 25b6f71445..98c3767393 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SplitTunnelingCell.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SplitTunnelingCell.kt @@ -89,7 +89,7 @@ fun SplitTunnelingCell( Modifier.align(Alignment.CenterVertically).size(size = Dimens.listIconSize) ) }, - title = { + headlineContent = { Text( text = title, style = MaterialTheme.typography.listItemText, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SwitchComposeCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SwitchComposeCell.kt index 6aeea8897d..f74349113c 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SwitchComposeCell.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/SwitchComposeCell.kt @@ -121,7 +121,7 @@ private fun SwitchComposeCell( ) { BaseCell( modifier = modifier.focusProperties { canFocus = false }, - title = titleView, + headlineContent = titleView, isRowEnabled = isEnabled, bodyView = { SwitchCellView( diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/TwoRowCell.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/TwoRowCell.kt new file mode 100644 index 0000000000..0b1f36d21d --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/cell/TwoRowCell.kt @@ -0,0 +1,50 @@ +package net.mullvad.mullvadvpn.compose.cell + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +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.tooling.preview.Preview +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens + +@Preview +@Composable +private fun PreviewTwoRowCell() { + AppTheme { TwoRowCell(titleText = "Title", subtitleText = "Subtitle") } +} + +@Composable +fun TwoRowCell( + titleText: String, + subtitleText: String, + onCellClicked: () -> Unit = {}, + titleColor: Color = MaterialTheme.colorScheme.onPrimary, + subtitleColor: Color = MaterialTheme.colorScheme.onPrimary, + background: Color = MaterialTheme.colorScheme.primary +) { + BaseCell( + headlineContent = { + Column(modifier = Modifier.weight(1f)) { + Text( + modifier = Modifier.fillMaxWidth(), + text = titleText, + style = MaterialTheme.typography.labelLarge, + color = titleColor + ) + Text( + modifier = Modifier.fillMaxWidth(), + text = subtitleText, + style = MaterialTheme.typography.labelLarge, + color = subtitleColor + ) + } + }, + onCellClicked = onCellClicked, + background = background, + minHeight = Dimens.cellHeightTwoRows + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/LocationsEmptyText.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/LocationsEmptyText.kt new file mode 100644 index 0000000000..7c12443647 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/LocationsEmptyText.kt @@ -0,0 +1,60 @@ +package net.mullvad.mullvadvpn.compose.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +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.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.core.text.HtmlCompat +import net.mullvad.mullvadvpn.R +import net.mullvad.mullvadvpn.compose.extensions.toAnnotatedString +import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.relaylist.MIN_SEARCH_LENGTH + +@Composable +fun LocationsEmptyText(searchTerm: String) { + if (searchTerm.length >= MIN_SEARCH_LENGTH) { + val firstRow = + HtmlCompat.fromHtml( + textResource( + id = R.string.select_location_empty_text_first_row, + searchTerm, + ), + HtmlCompat.FROM_HTML_MODE_COMPACT, + ) + .toAnnotatedString(boldFontWeight = FontWeight.ExtraBold) + val secondRow = textResource(id = R.string.select_location_empty_text_second_row) + Column( + modifier = Modifier.padding(horizontal = Dimens.selectLocationTitlePadding), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = firstRow, + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSecondary, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = secondRow, + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSecondary, + ) + } + } else { + Text( + text = stringResource(R.string.no_locations_found), + modifier = Modifier.padding(Dimens.screenVerticalMargin), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSecondary + ) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadCheckbox.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadCheckbox.kt new file mode 100644 index 0000000000..741fcf960f --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadCheckbox.kt @@ -0,0 +1,21 @@ +package net.mullvad.mullvadvpn.compose.component + +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import net.mullvad.mullvadvpn.lib.theme.color.selected + +@Composable +fun MullvadCheckbox(checked: Boolean, onCheckedChange: (Boolean) -> Unit) { + Checkbox( + checked = checked, + onCheckedChange = onCheckedChange, + colors = + CheckboxDefaults.colors( + checkedColor = MaterialTheme.colorScheme.onPrimary, + uncheckedColor = MaterialTheme.colorScheme.onPrimary, + checkmarkColor = MaterialTheme.colorScheme.selected + ) + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadModalBottomSheet.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadModalBottomSheet.kt new file mode 100644 index 0000000000..1f8fb46cd7 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/MullvadModalBottomSheet.kt @@ -0,0 +1,69 @@ +package net.mullvad.mullvadvpn.compose.component + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.height +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import net.mullvad.mullvadvpn.compose.cell.HeaderCell +import net.mullvad.mullvadvpn.compose.cell.IconCell +import net.mullvad.mullvadvpn.lib.theme.AppTheme +import net.mullvad.mullvadvpn.lib.theme.Dimens + +@OptIn(ExperimentalMaterial3Api::class) +@Preview +@Composable +private fun PreviewMullvadModalBottomSheet() { + AppTheme { + MullvadModalBottomSheet( + sheetContent = { + HeaderCell( + text = "Title", + ) + HorizontalDivider() + IconCell( + iconId = null, + title = "Select", + ) + }, + closeBottomSheet = {} + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MullvadModalBottomSheet( + modifier: Modifier = Modifier, + sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + backgroundColor: Color = MaterialTheme.colorScheme.surfaceContainer, + onBackgroundColor: Color = MaterialTheme.colorScheme.onSurface, + closeBottomSheet: () -> Unit, + sheetContent: @Composable ColumnScope.() -> Unit +) { + // This is to avoid weird colors in the status bar and the navigation bar + val paddingValues = BottomSheetDefaults.windowInsets.asPaddingValues() + ModalBottomSheet( + onDismissRequest = closeBottomSheet, + sheetState = sheetState, + containerColor = backgroundColor, + modifier = modifier, + windowInsets = WindowInsets(0, 0, 0, 0), // No insets + dragHandle = { BottomSheetDefaults.DragHandle(color = onBackgroundColor) } + ) { + sheetContent() + Spacer(modifier = Modifier.height(Dimens.smallPadding)) + Spacer(modifier = Modifier.height(paddingValues.calculateBottomPadding())) + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt index 7ca9af3b17..b9a6306413 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/component/Scaffolding.kt @@ -108,7 +108,12 @@ fun ScaffoldWithTopBarAndDeviceName( @Composable fun MullvadSnackbar(snackbarData: SnackbarData) { - Snackbar(snackbarData = snackbarData, contentColor = MaterialTheme.colorScheme.secondary) + Snackbar( + snackbarData = snackbarData, + containerColor = MaterialTheme.colorScheme.surfaceContainer, + contentColor = MaterialTheme.colorScheme.onSurface, + actionColor = MaterialTheme.colorScheme.onSurface + ) } @Composable @@ -257,3 +262,24 @@ fun ScaffoldWithLargeTopBarAndButton( } ) } + +@Composable +fun ScaffoldWithSmallTopBar( + appBarTitle: String, + modifier: Modifier = Modifier, + navigationIcon: @Composable () -> Unit = {}, + actions: @Composable RowScope.() -> Unit = {}, + content: @Composable (modifier: Modifier) -> Unit +) { + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + MullvadSmallTopBar( + title = appBarTitle, + navigationIcon = navigationIcon, + actions = actions + ) + }, + content = { content(Modifier.fillMaxSize().padding(it)) } + ) +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/SnackbarHostExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/SnackbarHostExtensions.kt new file mode 100644 index 0000000000..8a418c17aa --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/extensions/SnackbarHostExtensions.kt @@ -0,0 +1,18 @@ +package net.mullvad.mullvadvpn.compose.extensions + +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult + +suspend fun SnackbarHostState.showSnackbar( + message: String, + actionLabel: String, + duration: SnackbarDuration = SnackbarDuration.Indefinite, + onAction: (() -> Unit), + onDismiss: (() -> Unit) = {} +) { + when (showSnackbar(message = message, actionLabel = actionLabel, duration = duration)) { + SnackbarResult.ActionPerformed -> onAction() + SnackbarResult.Dismissed -> onDismiss() + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt index 6e762bbf43..e6402fc8bd 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/DeviceListScreen.kt @@ -350,7 +350,7 @@ private fun DeviceListItem( ) { BaseCell( isRowEnabled = false, - title = { + headlineContent = { Column(modifier = Modifier.weight(1f)) { Text( modifier = Modifier.fillMaxWidth(), diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt index 1f17da8bc5..594c657cdb 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/SelectLocationScreen.kt @@ -1,6 +1,8 @@ package net.mullvad.mullvadvpn.compose.screen +import android.content.Context import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.layout.Column @@ -13,49 +15,81 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SheetState +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview -import androidx.core.text.HtmlCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.navigation.DestinationsNavigator +import com.ramcosta.composedestinations.result.NavResult +import com.ramcosta.composedestinations.result.ResultRecipient +import com.ramcosta.composedestinations.spec.DestinationSpec +import kotlinx.coroutines.launch import net.mullvad.mullvadvpn.R import net.mullvad.mullvadvpn.compose.cell.FilterCell -import net.mullvad.mullvadvpn.compose.cell.RelayLocationCell +import net.mullvad.mullvadvpn.compose.cell.HeaderCell +import net.mullvad.mullvadvpn.compose.cell.IconCell +import net.mullvad.mullvadvpn.compose.cell.StatusRelayLocationCell +import net.mullvad.mullvadvpn.compose.cell.SwitchComposeSubtitleCell +import net.mullvad.mullvadvpn.compose.cell.ThreeDotCell +import net.mullvad.mullvadvpn.compose.communication.CustomListAction +import net.mullvad.mullvadvpn.compose.communication.CustomListResult +import net.mullvad.mullvadvpn.compose.component.LocationsEmptyText import net.mullvad.mullvadvpn.compose.component.MullvadCircularProgressIndicatorLarge +import net.mullvad.mullvadvpn.compose.component.MullvadModalBottomSheet +import net.mullvad.mullvadvpn.compose.component.MullvadSnackbar import net.mullvad.mullvadvpn.compose.component.drawVerticalScrollbar -import net.mullvad.mullvadvpn.compose.component.textResource import net.mullvad.mullvadvpn.compose.constant.ContentType +import net.mullvad.mullvadvpn.compose.destinations.CreateCustomListDestination +import net.mullvad.mullvadvpn.compose.destinations.CustomListLocationsDestination +import net.mullvad.mullvadvpn.compose.destinations.CustomListsDestination +import net.mullvad.mullvadvpn.compose.destinations.DeleteCustomListDestination +import net.mullvad.mullvadvpn.compose.destinations.EditCustomListNameDestination import net.mullvad.mullvadvpn.compose.destinations.FilterScreenDestination -import net.mullvad.mullvadvpn.compose.extensions.toAnnotatedString -import net.mullvad.mullvadvpn.compose.state.RelayListState +import net.mullvad.mullvadvpn.compose.extensions.showSnackbar import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState import net.mullvad.mullvadvpn.compose.test.CIRCULAR_PROGRESS_INDICATOR +import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_CUSTOM_LIST_BOTTOM_SHEET_TEST_TAG +import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_CUSTOM_LIST_HEADER_TEST_TAG +import net.mullvad.mullvadvpn.compose.test.SELECT_LOCATION_LOCATION_BOTTOM_SHEET_TEST_TAG import net.mullvad.mullvadvpn.compose.textfield.SearchTextField import net.mullvad.mullvadvpn.compose.transitions.SelectLocationTransition import net.mullvad.mullvadvpn.compose.util.LaunchedEffectCollect +import net.mullvad.mullvadvpn.compose.util.RunOnKeyChange import net.mullvad.mullvadvpn.lib.theme.AppTheme import net.mullvad.mullvadvpn.lib.theme.Dimens +import net.mullvad.mullvadvpn.lib.theme.color.AlphaInactive import net.mullvad.mullvadvpn.lib.theme.color.AlphaScrollbar +import net.mullvad.mullvadvpn.lib.theme.color.AlphaVisible import net.mullvad.mullvadvpn.relaylist.RelayItem +import net.mullvad.mullvadvpn.relaylist.canAddLocation import net.mullvad.mullvadvpn.viewmodel.SelectLocationSideEffect import net.mullvad.mullvadvpn.viewmodel.SelectLocationViewModel import org.koin.androidx.compose.koinViewModel @@ -64,16 +98,14 @@ import org.koin.androidx.compose.koinViewModel @Composable private fun PreviewSelectLocationScreen() { val state = - SelectLocationUiState.Data( + SelectLocationUiState.Content( searchTerm = "", selectedOwnership = null, selectedProvidersCount = 0, - relayListState = - RelayListState.RelayList( - countries = - listOf(RelayItem.Country("Country 1", "Code 1", false, emptyList())), - selectedItem = null, - ) + countries = listOf(RelayItem.Country("Country 1", "Code 1", false, emptyList())), + selectedItem = null, + customLists = emptyList(), + filteredCustomLists = emptyList() ) AppTheme { SelectLocationScreen( @@ -84,23 +116,84 @@ private fun PreviewSelectLocationScreen() { @Destination(style = SelectLocationTransition::class) @Composable -fun SelectLocation(navigator: DestinationsNavigator) { +fun SelectLocation( + navigator: DestinationsNavigator, + createCustomListDialogResultRecipient: + ResultRecipient<CreateCustomListDestination, CustomListResult.Created>, + editCustomListNameDialogResultRecipient: + ResultRecipient<EditCustomListNameDestination, CustomListResult.Renamed>, + deleteCustomListDialogResultRecipient: + ResultRecipient<DeleteCustomListDestination, CustomListResult.Deleted>, + updateCustomListResultRecipient: + ResultRecipient<CustomListLocationsDestination, CustomListResult.LocationsChanged> +) { val vm = koinViewModel<SelectLocationViewModel>() - val state by vm.uiState.collectAsStateWithLifecycle() + val state = vm.uiState.collectAsStateWithLifecycle().value + + val snackbarHostState = remember { SnackbarHostState() } + val context = LocalContext.current + LaunchedEffectCollect(vm.uiSideEffect) { when (it) { SelectLocationSideEffect.CloseScreen -> navigator.navigateUp() + is SelectLocationSideEffect.LocationAddedToCustomList -> { + launch { + snackbarHostState.showResultSnackbar( + context = context, + result = it.result, + onUndo = vm::performAction + ) + } + } } } + createCustomListDialogResultRecipient.OnCustomListNavResult( + snackbarHostState, + vm::performAction + ) + + editCustomListNameDialogResultRecipient.OnCustomListNavResult( + snackbarHostState, + vm::performAction + ) + + deleteCustomListDialogResultRecipient.OnCustomListNavResult( + snackbarHostState, + vm::performAction + ) + + updateCustomListResultRecipient.OnCustomListNavResult(snackbarHostState, vm::performAction) + SelectLocationScreen( state = state, + snackbarHostState = snackbarHostState, onSelectRelay = vm::selectRelay, onSearchTermInput = vm::onSearchTermInput, onBackClick = navigator::navigateUp, onFilterClick = { navigator.navigate(FilterScreenDestination, true) }, + onCreateCustomList = { relayItem -> + navigator.navigate(CreateCustomListDestination(locationCode = relayItem?.code ?: "")) { + launchSingleTop = true + } + }, + onEditCustomLists = { navigator.navigate(CustomListsDestination()) }, removeOwnershipFilter = vm::removeOwnerFilter, - removeProviderFilter = vm::removeProviderFilter + removeProviderFilter = vm::removeProviderFilter, + onAddLocationToList = vm::addLocationToList, + onEditCustomListName = { + navigator.navigate( + EditCustomListNameDestination(customListId = it.id, initialName = it.name) + ) + }, + onEditLocationsCustomList = { + navigator.navigate( + CustomListLocationsDestination(customListId = it.id, newList = false) + ) + }, + onDeleteCustomList = { + navigator.navigate(DeleteCustomListDestination(customListId = it.id, name = it.name)) + } ) } @@ -108,16 +201,43 @@ fun SelectLocation(navigator: DestinationsNavigator) { @Composable fun SelectLocationScreen( state: SelectLocationUiState, + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, onSelectRelay: (item: RelayItem) -> Unit = {}, onSearchTermInput: (searchTerm: String) -> Unit = {}, onBackClick: () -> Unit = {}, onFilterClick: () -> Unit = {}, + onCreateCustomList: (location: RelayItem?) -> Unit = {}, + onEditCustomLists: () -> Unit = {}, removeOwnershipFilter: () -> Unit = {}, - removeProviderFilter: () -> Unit = {} + removeProviderFilter: () -> Unit = {}, + onAddLocationToList: (location: RelayItem, customList: RelayItem.CustomList) -> Unit = { _, _ -> + }, + onEditCustomListName: (RelayItem.CustomList) -> Unit = {}, + onEditLocationsCustomList: (RelayItem.CustomList) -> Unit = {}, + onDeleteCustomList: (RelayItem.CustomList) -> Unit = {} ) { val backgroundColor = MaterialTheme.colorScheme.background - Scaffold { + Scaffold( + snackbarHost = { + SnackbarHost( + snackbarHostState, + snackbar = { snackbarData -> MullvadSnackbar(snackbarData = snackbarData) } + ) + } + ) { + var bottomSheetState by remember { mutableStateOf<BottomSheetState?>(null) } + BottomSheets( + bottomSheetState = bottomSheetState, + onCreateCustomList = onCreateCustomList, + onEditCustomLists = onEditCustomLists, + onAddLocationToList = onAddLocationToList, + onEditCustomListName = onEditCustomListName, + onEditLocationsCustomList = onEditLocationsCustomList, + onDeleteCustomList = onDeleteCustomList, + onHideBottomSheet = { bottomSheetState = null } + ) + Column(modifier = Modifier.padding(it).background(backgroundColor).fillMaxSize()) { Row(modifier = Modifier.fillMaxWidth()) { IconButton(onClick = onBackClick) { @@ -133,7 +253,7 @@ fun SelectLocationScreen( modifier = Modifier.align(Alignment.CenterVertically).weight(weight = 1f), textAlign = TextAlign.Center, style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onPrimary + color = MaterialTheme.colorScheme.onPrimary, ) IconButton(onClick = onFilterClick) { Icon( @@ -146,13 +266,13 @@ fun SelectLocationScreen( when (state) { SelectLocationUiState.Loading -> {} - is SelectLocationUiState.Data -> { + is SelectLocationUiState.Content -> { if (state.hasFilter) { FilterCell( ownershipFilter = state.selectedOwnership, selectedProviderFilter = state.selectedProvidersCount, removeOwnershipFilter = removeOwnershipFilter, - removeProviderFilter = removeProviderFilter + removeProviderFilter = removeProviderFilter, ) } } @@ -164,24 +284,20 @@ fun SelectLocationScreen( .height(Dimens.searchFieldHeight) .padding(horizontal = Dimens.searchFieldHorizontalPadding), backgroundColor = MaterialTheme.colorScheme.tertiaryContainer, - textColor = MaterialTheme.colorScheme.onTertiaryContainer + textColor = MaterialTheme.colorScheme.onTertiaryContainer, ) { searchString -> onSearchTermInput.invoke(searchString) } Spacer(modifier = Modifier.height(height = Dimens.verticalSpace)) val lazyListState = rememberLazyListState() - if ( - state is SelectLocationUiState.Data && - state.relayListState is RelayListState.RelayList && - state.relayListState.selectedItem != null - ) { - LaunchedEffect(state.relayListState.selectedItem) { - val index = state.relayListState.indexOfSelectedRelayItem() + val selectedItemCode = + (state as? SelectLocationUiState.Content)?.selectedItem?.code ?: "" + RunOnKeyChange(key = selectedItemCode) { + val index = state.indexOfSelectedRelayItem() - if (index >= 0) { - lazyListState.scrollToItem(index) - lazyListState.animateScrollAndCentralizeItem(index) - } + if (index >= 0) { + lazyListState.scrollToItem(index) + lazyListState.animateScrollAndCentralizeItem(index) } } LazyColumn( @@ -189,7 +305,7 @@ fun SelectLocationScreen( Modifier.fillMaxSize() .drawVerticalScrollbar( lazyListState, - MaterialTheme.colorScheme.onBackground.copy(alpha = AlphaScrollbar) + MaterialTheme.colorScheme.onBackground.copy(alpha = AlphaScrollbar), ), state = lazyListState, horizontalAlignment = Alignment.CenterHorizontally, @@ -198,12 +314,42 @@ fun SelectLocationScreen( SelectLocationUiState.Loading -> { loading() } - is SelectLocationUiState.Data -> { - relayList( - relayListState = state.relayListState, - searchTerm = state.searchTerm, - onSelectRelay = onSelectRelay - ) + is SelectLocationUiState.Content -> { + if (state.showCustomLists) { + customLists( + customLists = state.filteredCustomLists, + selectedItem = state.selectedItem, + onSelectRelay = onSelectRelay, + onShowCustomListBottomSheet = { + bottomSheetState = + BottomSheetState.ShowCustomListsBottomSheet( + state.customLists.isNotEmpty() + ) + }, + onShowEditBottomSheet = { customList -> + bottomSheetState = + BottomSheetState.ShowEditCustomListBottomSheet(customList) + } + ) + item { Spacer(modifier = Modifier.height(Dimens.mediumPadding)) } + } + if (state.countries.isNotEmpty()) { + relayList( + countries = state.countries, + selectedItem = state.selectedItem, + onSelectRelay = onSelectRelay, + onShowLocationBottomSheet = { location -> + bottomSheetState = + BottomSheetState.ShowLocationBottomSheet( + customLists = state.customLists, + item = location + ) + } + ) + } + if (state.showEmpty) { + item { LocationsEmptyText(searchTerm = state.searchTerm) } + } } } } @@ -217,78 +363,337 @@ private fun LazyListScope.loading() { } } -private fun LazyListScope.relayList( - relayListState: RelayListState, - searchTerm: String, - onSelectRelay: (item: RelayItem) -> Unit +@OptIn(ExperimentalFoundationApi::class) +private fun LazyListScope.customLists( + customLists: List<RelayItem.CustomList>, + selectedItem: RelayItem?, + onSelectRelay: (item: RelayItem) -> Unit, + onShowCustomListBottomSheet: () -> Unit, + onShowEditBottomSheet: (RelayItem.CustomList) -> Unit ) { - when (relayListState) { - is RelayListState.RelayList -> { - items( - count = relayListState.countries.size, - key = { index -> relayListState.countries[index].hashCode() }, - contentType = { ContentType.ITEM } - ) { index -> - val country = relayListState.countries[index] - RelayLocationCell( - relay = country, - selectedItem = relayListState.selectedItem, - onSelectRelay = onSelectRelay, - modifier = Modifier.animateContentSize() - ) - } + item( + contentType = { ContentType.HEADER }, + ) { + ThreeDotCell( + text = stringResource(R.string.custom_lists), + onClickDots = onShowCustomListBottomSheet, + modifier = Modifier.testTag(SELECT_LOCATION_CUSTOM_LIST_HEADER_TEST_TAG) + ) + } + if (customLists.isNotEmpty()) { + items( + items = customLists, + key = { item -> item.code }, + contentType = { ContentType.ITEM }, + ) { customList -> + StatusRelayLocationCell( + relay = customList, + // Do not show selection for locations in custom lists + selectedItem = selectedItem as? RelayItem.CustomList, + onSelectRelay = onSelectRelay, + onLongClick = { + if (it is RelayItem.CustomList) { + onShowEditBottomSheet(it) + } + }, + modifier = Modifier.animateContentSize().animateItemPlacement(), + ) } - RelayListState.Empty -> { - if (searchTerm.isNotEmpty()) - item(contentType = ContentType.EMPTY_TEXT) { - val firstRow = - HtmlCompat.fromHtml( - textResource( - id = R.string.select_location_empty_text_first_row, - searchTerm - ), - HtmlCompat.FROM_HTML_MODE_COMPACT - ) - .toAnnotatedString(boldFontWeight = FontWeight.ExtraBold) - val secondRow = - textResource(id = R.string.select_location_empty_text_second_row) - Column( - modifier = Modifier.padding(horizontal = Dimens.selectLocationTitlePadding), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = firstRow, - style = MaterialTheme.typography.labelMedium, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSecondary, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - Text( - text = secondRow, - style = MaterialTheme.typography.labelMedium, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.onSecondary - ) + item { + SwitchComposeSubtitleCell(text = stringResource(R.string.to_add_locations_to_a_list)) + } + } else { + item(contentType = ContentType.EMPTY_TEXT) { + SwitchComposeSubtitleCell(text = stringResource(R.string.to_create_a_custom_list)) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +private fun LazyListScope.relayList( + countries: List<RelayItem.Country>, + selectedItem: RelayItem?, + onSelectRelay: (item: RelayItem) -> Unit, + onShowLocationBottomSheet: (item: RelayItem) -> Unit, +) { + item( + contentType = ContentType.HEADER, + ) { + HeaderCell( + text = stringResource(R.string.all_locations), + ) + } + items( + items = countries, + key = { item -> item.code }, + contentType = { ContentType.ITEM }, + ) { country -> + StatusRelayLocationCell( + relay = country, + selectedItem = selectedItem, + onSelectRelay = onSelectRelay, + onLongClick = onShowLocationBottomSheet, + modifier = Modifier.animateContentSize().animateItemPlacement(), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun BottomSheets( + bottomSheetState: BottomSheetState?, + onCreateCustomList: (RelayItem?) -> Unit, + onEditCustomLists: () -> Unit, + onAddLocationToList: (RelayItem, RelayItem.CustomList) -> Unit, + onEditCustomListName: (RelayItem.CustomList) -> Unit, + onEditLocationsCustomList: (RelayItem.CustomList) -> Unit, + onDeleteCustomList: (RelayItem.CustomList) -> Unit, + onHideBottomSheet: () -> Unit +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val scope = rememberCoroutineScope() + val onCloseBottomSheet: (animate: Boolean) -> Unit = { animate -> + if (animate) { + scope + .launch { sheetState.hide() } + .invokeOnCompletion { + if (!sheetState.isVisible) { + onHideBottomSheet() } } + } else { + onHideBottomSheet() + } + } + val onBackgroundColor: Color = MaterialTheme.colorScheme.onSurface + + when (bottomSheetState) { + is BottomSheetState.ShowCustomListsBottomSheet -> { + CustomListsBottomSheet( + sheetState = sheetState, + onBackgroundColor = onBackgroundColor, + bottomSheetState = bottomSheetState, + onCreateCustomList = { onCreateCustomList(null) }, + onEditCustomLists = onEditCustomLists, + closeBottomSheet = onCloseBottomSheet + ) + } + is BottomSheetState.ShowLocationBottomSheet -> { + LocationBottomSheet( + sheetState = sheetState, + onBackgroundColor = onBackgroundColor, + customLists = bottomSheetState.customLists, + item = bottomSheetState.item, + onCreateCustomList = onCreateCustomList, + onAddLocationToList = onAddLocationToList, + closeBottomSheet = onCloseBottomSheet + ) + } + is BottomSheetState.ShowEditCustomListBottomSheet -> { + EditCustomListBottomSheet( + sheetState = sheetState, + onBackgroundColor = onBackgroundColor, + customList = bottomSheetState.customList, + onEditName = onEditCustomListName, + onEditLocations = onEditLocationsCustomList, + onDeleteCustomList = onDeleteCustomList, + closeBottomSheet = onCloseBottomSheet + ) + } + null -> { + /* Do nothing */ } } } -private fun RelayListState.RelayList.indexOfSelectedRelayItem(): Int = - countries.indexOfFirst { relayCountry -> - relayCountry.location.location.country == - when (selectedItem) { - is RelayItem.Country -> selectedItem.code - is RelayItem.City -> selectedItem.location.countryCode - is RelayItem.Relay -> selectedItem.location.countryCode - is RelayItem.CustomList, - null -> null - } +private fun SelectLocationUiState.indexOfSelectedRelayItem(): Int = + if (this is SelectLocationUiState.Content) { + when (selectedItem) { + is RelayItem.Country, + is RelayItem.City, + is RelayItem.Relay -> + countries.indexOfFirst { it.code == selectedItem.countryCode() } + + customLists.size + + EXTRA_ITEMS_LOCATION + is RelayItem.CustomList -> + filteredCustomLists.indexOfFirst { it.id == selectedItem.id } + + EXTRA_ITEM_CUSTOM_LIST + else -> -1 + } + } else { + -1 + } + +private fun RelayItem.countryCode(): String = + when (this) { + is RelayItem.Country -> this.code + is RelayItem.City -> this.location.countryCode + is RelayItem.Relay -> this.location.countryCode + is RelayItem.CustomList -> + throw IllegalArgumentException("Custom list does not have a country code") } -suspend fun LazyListState.animateScrollAndCentralizeItem(index: Int) { +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CustomListsBottomSheet( + onBackgroundColor: Color, + sheetState: SheetState, + bottomSheetState: BottomSheetState.ShowCustomListsBottomSheet, + onCreateCustomList: () -> Unit, + onEditCustomLists: () -> Unit, + closeBottomSheet: (animate: Boolean) -> Unit +) { + MullvadModalBottomSheet( + sheetState = sheetState, + closeBottomSheet = { closeBottomSheet(false) }, + modifier = Modifier.testTag(SELECT_LOCATION_CUSTOM_LIST_BOTTOM_SHEET_TEST_TAG) + ) { -> + HeaderCell( + text = stringResource(id = R.string.edit_custom_lists), + background = Color.Unspecified + ) + HorizontalDivider(color = onBackgroundColor) + IconCell( + iconId = R.drawable.icon_add, + title = stringResource(id = R.string.new_list), + onClick = { + onCreateCustomList() + closeBottomSheet(true) + }, + background = Color.Unspecified, + titleColor = onBackgroundColor + ) + IconCell( + iconId = R.drawable.icon_edit, + title = stringResource(id = R.string.edit_lists), + onClick = { + onEditCustomLists() + closeBottomSheet(true) + }, + background = Color.Unspecified, + titleColor = + onBackgroundColor.copy( + alpha = + if (bottomSheetState.editListEnabled) { + AlphaVisible + } else { + AlphaInactive + } + ), + enabled = bottomSheetState.editListEnabled + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun LocationBottomSheet( + onBackgroundColor: Color, + sheetState: SheetState, + customLists: List<RelayItem.CustomList>, + item: RelayItem, + onCreateCustomList: (relayItem: RelayItem) -> Unit, + onAddLocationToList: (location: RelayItem, customList: RelayItem.CustomList) -> Unit, + closeBottomSheet: (animate: Boolean) -> Unit +) { + MullvadModalBottomSheet( + sheetState = sheetState, + closeBottomSheet = { closeBottomSheet(false) }, + modifier = Modifier.testTag(SELECT_LOCATION_LOCATION_BOTTOM_SHEET_TEST_TAG) + ) { -> + HeaderCell( + text = stringResource(id = R.string.add_location_to_list, item.name), + background = Color.Unspecified + ) + HorizontalDivider(color = onBackgroundColor) + customLists.forEach { + val enabled = it.canAddLocation(item) + IconCell( + background = Color.Unspecified, + titleColor = + if (enabled) { + onBackgroundColor + } else { + MaterialTheme.colorScheme.onSecondary + }, + iconId = null, + title = + if (enabled) { + it.name + } else { + stringResource(id = R.string.location_added, it.name) + }, + onClick = { + onAddLocationToList(item, it) + closeBottomSheet(true) + }, + enabled = enabled + ) + } + IconCell( + iconId = R.drawable.icon_add, + title = stringResource(id = R.string.new_list), + onClick = { + onCreateCustomList(item) + closeBottomSheet(true) + }, + background = Color.Unspecified, + titleColor = onBackgroundColor + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun EditCustomListBottomSheet( + onBackgroundColor: Color, + sheetState: SheetState, + customList: RelayItem.CustomList, + onEditName: (item: RelayItem.CustomList) -> Unit, + onEditLocations: (item: RelayItem.CustomList) -> Unit, + onDeleteCustomList: (item: RelayItem.CustomList) -> Unit, + closeBottomSheet: (animate: Boolean) -> Unit +) { + MullvadModalBottomSheet( + sheetState = sheetState, + closeBottomSheet = { closeBottomSheet(false) } + ) { + HeaderCell(text = customList.name, background = Color.Unspecified) + IconCell( + iconId = R.drawable.icon_edit, + title = stringResource(id = R.string.edit_name), + onClick = { + onEditName(customList) + closeBottomSheet(true) + }, + background = Color.Unspecified, + titleColor = onBackgroundColor + ) + IconCell( + iconId = R.drawable.icon_add, + title = stringResource(id = R.string.edit_locations), + onClick = { + onEditLocations(customList) + closeBottomSheet(true) + }, + background = Color.Unspecified, + titleColor = onBackgroundColor + ) + HorizontalDivider(color = onBackgroundColor) + IconCell( + iconId = R.drawable.icon_delete, + title = stringResource(id = R.string.delete), + onClick = { + onDeleteCustomList(customList) + closeBottomSheet(true) + }, + background = Color.Unspecified, + titleColor = onBackgroundColor + ) + } +} + +private suspend fun LazyListState.animateScrollAndCentralizeItem(index: Int) { val itemInfo = this.layoutInfo.visibleItemsInfo.firstOrNull { it.index == index } if (itemInfo != null) { val center = layoutInfo.viewportEndOffset / 2 @@ -298,3 +703,71 @@ suspend fun LazyListState.animateScrollAndCentralizeItem(index: Int) { animateScrollToItem(index) } } + +private suspend fun SnackbarHostState.showResultSnackbar( + context: Context, + result: CustomListResult, + onUndo: (CustomListAction) -> Unit +) { + currentSnackbarData?.dismiss() + showSnackbar( + message = result.message(context), + actionLabel = context.getString(R.string.undo), + duration = SnackbarDuration.Long, + onAction = { onUndo(result.undo) } + ) +} + +private fun CustomListResult.message(context: Context): String = + when (this) { + is CustomListResult.Created -> + context.getString(R.string.location_was_added_to_list, locationName, name) + is CustomListResult.Deleted -> context.getString(R.string.delete_custom_list_message, name) + is CustomListResult.Renamed -> context.getString(R.string.name_was_changed_to, name) + is CustomListResult.LocationsChanged -> + context.getString(R.string.locations_were_changed_for, name) + } + +@Composable +private fun <D : DestinationSpec<*>, R : CustomListResult> ResultRecipient<D, R> + .OnCustomListNavResult( + snackbarHostState: SnackbarHostState, + performAction: (action: CustomListAction) -> Unit +) { + val scope = rememberCoroutineScope() + val context = LocalContext.current + this.onNavResult { result -> + when (result) { + NavResult.Canceled -> { + /* Do nothing */ + } + is NavResult.Value -> { + // Handle result + scope.launch { + snackbarHostState.showResultSnackbar( + context = context, + result = result.value, + onUndo = performAction + ) + } + } + } + } +} + +private const val EXTRA_ITEMS_LOCATION = + 4 // Custom lists header, custom lists description, spacer, all locations header +private const val EXTRA_ITEM_CUSTOM_LIST = 1 // Custom lists header + +sealed interface BottomSheetState { + + data class ShowCustomListsBottomSheet(val editListEnabled: Boolean) : BottomSheetState + + data class ShowLocationBottomSheet( + val customLists: List<RelayItem.CustomList>, + val item: RelayItem + ) : BottomSheetState + + data class ShowEditCustomListBottomSheet(val customList: RelayItem.CustomList) : + BottomSheetState +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt index 403b1bb57e..bd8809b00f 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/screen/VpnSettingsScreen.kt @@ -468,7 +468,7 @@ fun VpnSettingsScreen( itemWithDivider { BaseCell( onCellClicked = { navigateToDns(null, null) }, - title = { + headlineContent = { Text( text = stringResource(id = R.string.add_a_server), color = Color.White, diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationUiState.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationUiState.kt index fd775fa1bb..747e21d91c 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationUiState.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/state/SelectLocationUiState.kt @@ -1,25 +1,27 @@ package net.mullvad.mullvadvpn.compose.state import net.mullvad.mullvadvpn.model.Ownership +import net.mullvad.mullvadvpn.relaylist.MIN_SEARCH_LENGTH import net.mullvad.mullvadvpn.relaylist.RelayItem sealed interface SelectLocationUiState { data object Loading : SelectLocationUiState - data class Data( + data class Content( val searchTerm: String, val selectedOwnership: Ownership?, val selectedProvidersCount: Int?, - val relayListState: RelayListState + val filteredCustomLists: List<RelayItem.CustomList>, + val customLists: List<RelayItem.CustomList>, + val countries: List<RelayItem.Country>, + val selectedItem: RelayItem? ) : SelectLocationUiState { val hasFilter: Boolean = (selectedProvidersCount != null || selectedOwnership != null) + val inSearch = searchTerm.length >= MIN_SEARCH_LENGTH + val showCustomLists = inSearch.not() || filteredCustomLists.isNotEmpty() + // Show empty state if we don't have any relays or if we are searching and no custom list or + // relay is found + val showEmpty = countries.isEmpty() && (inSearch.not() || filteredCustomLists.isEmpty()) } } - -sealed interface RelayListState { - data object Empty : RelayListState - - data class RelayList(val countries: List<RelayItem.Country>, val selectedItem: RelayItem?) : - RelayListState -} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Effect.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Effect.kt index 42d5f6caa0..c8a0847e89 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Effect.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/Effect.kt @@ -2,11 +2,14 @@ package net.mullvad.mullvadvpn.compose.util import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.lifecycle.Lifecycle import androidx.lifecycle.flowWithLifecycle import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch @Composable inline fun <T> LaunchedEffectCollect( @@ -34,3 +37,12 @@ inline fun <T> CollectSideEffectWithLifecycle( } } } + +@Composable +fun RunOnKeyChange(key: Any, block: suspend CoroutineScope.() -> Unit) { + val scope = rememberCoroutineScope() + rememberSaveable(key) { + scope.launch { block() } + key + } +} diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/PreviewData.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/PreviewData.kt new file mode 100644 index 0000000000..61b563564c --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/compose/util/PreviewData.kt @@ -0,0 +1,78 @@ +package net.mullvad.mullvadvpn.compose.util + +import net.mullvad.mullvadvpn.model.GeographicLocationConstraint +import net.mullvad.mullvadvpn.relaylist.RelayItem + +fun generateRelayItemCountry( + name: String, + cityNames: List<String>, + relaysPerCity: Int, + active: Boolean = true, + expanded: Boolean = false, + expandChildren: Boolean = false, +) = + RelayItem.Country( + name = name, + code = name.generateCountryCode(), + cities = + cityNames.map { cityName -> + generateRelayItemCity( + cityName, + name.generateCountryCode(), + relaysPerCity, + active, + expandChildren + ) + }, + expanded = expanded, + ) + +fun generateRelayItemCity( + name: String, + countryCode: String, + numberOfRelays: Int, + active: Boolean = true, + expanded: Boolean = false, +) = + RelayItem.City( + name = name, + code = name.generateCityCode(), + relays = + List(numberOfRelays) { index -> + generateRelayItemRelay( + countryCode, + name.generateCityCode(), + generateHostname(countryCode, name.generateCityCode(), index), + active + ) + }, + expanded = expanded, + location = GeographicLocationConstraint.City(countryCode, name.generateCityCode()), + ) + +fun generateRelayItemRelay( + countryCode: String, + cityCode: String, + hostName: String, + active: Boolean = true, +) = + RelayItem.Relay( + name = hostName, + location = + GeographicLocationConstraint.Hostname( + countryCode = countryCode, + cityCode = cityCode, + hostname = hostName, + ), + locationName = "$cityCode $hostName", + active = active + ) + +private fun String.generateCountryCode() = (take(1) + takeLast(1)).lowercase() + +private fun String.generateCityCode() = take(CITY_CODE_LENGTH).lowercase() + +private fun generateHostname(countryCode: String, cityCode: String, index: Int) = + "$countryCode-$cityCode-wg-${index+1}" + +private const val CITY_CODE_LENGTH = 3 diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/CustomListExtensions.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/CustomListExtensions.kt index 9ad0c220e9..6fb87a6af5 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/CustomListExtensions.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/CustomListExtensions.kt @@ -2,18 +2,32 @@ package net.mullvad.mullvadvpn.relaylist import net.mullvad.mullvadvpn.model.CustomList -fun CustomList.toRelayItemCustomList( +private fun CustomList.toRelayItemCustomList( relayCountries: List<RelayItem.Country> ): RelayItem.CustomList = RelayItem.CustomList( - code = this.id, id = this.id, name = this.name, expanded = false, locations = - this.locations.mapNotNull { relayCountries.findItemForGeographicLocationConstraint(it) } + this.locations.mapNotNull { + relayCountries.findItemForGeographicLocationConstraint(it) + }, ) fun List<CustomList>.toRelayItemLists( relayCountries: List<RelayItem.Country> ): List<RelayItem.CustomList> = this.map { it.toRelayItemCustomList(relayCountries) } + +fun List<RelayItem.CustomList>.filterOnSearchTerm(searchTerm: String) = + if (searchTerm.length >= MIN_SEARCH_LENGTH) { + this.filter { it.name.contains(searchTerm, ignoreCase = true) } + } else { + this + } + +fun RelayItem.CustomList.canAddLocation(location: RelayItem) = + this.locations.none { it.code == location.code } && + this.locations.flatMap { it.descendants() }.none { it.code == location.code } + +fun List<RelayItem.CustomList>.getById(id: String) = this.find { it.id == id } diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListConstant.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListConstant.kt new file mode 100644 index 0000000000..4fcc5c7902 --- /dev/null +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/relaylist/RelayListConstant.kt @@ -0,0 +1,3 @@ +package net.mullvad.mullvadvpn.relaylist + +const val MIN_SEARCH_LENGTH = 2 diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModel.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModel.kt index 4ca27a105a..15df90be9e 100644 --- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModel.kt +++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/viewmodel/SelectLocationViewModel.kt @@ -10,7 +10,8 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import net.mullvad.mullvadvpn.compose.state.RelayListState +import net.mullvad.mullvadvpn.compose.communication.CustomListAction +import net.mullvad.mullvadvpn.compose.communication.CustomListResult import net.mullvad.mullvadvpn.compose.state.SelectLocationUiState import net.mullvad.mullvadvpn.compose.state.toNullableOwnership import net.mullvad.mullvadvpn.compose.state.toSelectedProviders @@ -24,11 +25,13 @@ import net.mullvad.mullvadvpn.ui.serviceconnection.ServiceConnectionManager import net.mullvad.mullvadvpn.ui.serviceconnection.connectionProxy import net.mullvad.mullvadvpn.usecase.RelayListFilterUseCase import net.mullvad.mullvadvpn.usecase.RelayListUseCase +import net.mullvad.mullvadvpn.usecase.customlists.CustomListActionUseCase class SelectLocationViewModel( private val serviceConnectionManager: ServiceConnectionManager, private val relayListUseCase: RelayListUseCase, - private val relayListFilterUseCase: RelayListFilterUseCase + private val relayListFilterUseCase: RelayListFilterUseCase, + private val customListActionUseCase: CustomListActionUseCase ) : ViewModel() { private val _searchTerm = MutableStateFlow(EMPTY_SEARCH_TERM) @@ -38,9 +41,9 @@ class SelectLocationViewModel( _searchTerm, relayListFilterUseCase.selectedOwnership(), relayListFilterUseCase.availableProviders(), - relayListFilterUseCase.selectedProviders() + relayListFilterUseCase.selectedProviders(), ) { - (customList, relayCountries, selectedItem), + (customLists, relayCountries, selectedItem), searchTerm, selectedOwnership, allProviders, @@ -60,25 +63,22 @@ class SelectLocationViewModel( val filteredRelayCountries = relayCountries.filterOnSearchTerm(searchTerm, selectedItem) - SelectLocationUiState.Data( + val filteredCustomLists = customLists.filterOnSearchTerm(searchTerm) + + SelectLocationUiState.Content( searchTerm = searchTerm, selectedOwnership = selectedOwnershipItem, selectedProvidersCount = selectedProvidersCount, - relayListState = - if (filteredRelayCountries.isNotEmpty()) { - RelayListState.RelayList( - countries = filteredRelayCountries, - selectedItem = selectedItem - ) - } else { - RelayListState.Empty - }, + filteredCustomLists = filteredCustomLists, + customLists = customLists, + countries = filteredRelayCountries, + selectedItem = selectedItem, ) } .stateIn( viewModelScope, SharingStarted.WhileSubscribed(), - SelectLocationUiState.Loading + SelectLocationUiState.Loading, ) private val _uiSideEffect = Channel<SelectLocationSideEffect>() @@ -113,7 +113,7 @@ class SelectLocationViewModel( viewModelScope.launch { relayListFilterUseCase.updateOwnershipAndProviderFilter( Constraint.Any(), - relayListFilterUseCase.selectedProviders().first() + relayListFilterUseCase.selectedProviders().first(), ) } } @@ -122,11 +122,28 @@ class SelectLocationViewModel( viewModelScope.launch { relayListFilterUseCase.updateOwnershipAndProviderFilter( relayListFilterUseCase.selectedOwnership().first(), - Constraint.Any() + Constraint.Any(), ) } } + fun addLocationToList(item: RelayItem, customList: RelayItem.CustomList) { + viewModelScope.launch { + val newLocations = (customList.locations + item).map { it.code } + val result = + customListActionUseCase.performAction( + CustomListAction.UpdateLocations(customList.id, newLocations) + ) + _uiSideEffect.send( + SelectLocationSideEffect.LocationAddedToCustomList(result.getOrThrow()) + ) + } + } + + fun performAction(action: CustomListAction) { + viewModelScope.launch { customListActionUseCase.performAction(action) } + } + companion object { private const val EMPTY_SEARCH_TERM = "" } @@ -134,4 +151,7 @@ class SelectLocationViewModel( sealed interface SelectLocationSideEffect { data object CloseScreen : SelectLocationSideEffect + + data class LocationAddedToCustomList(val result: CustomListResult.LocationsChanged) : + SelectLocationSideEffect } diff --git a/android/lib/resource/src/main/res/drawable/icon_add.xml b/android/lib/resource/src/main/res/drawable/icon_add.xml new file mode 100644 index 0000000000..1b016dcfb2 --- /dev/null +++ b/android/lib/resource/src/main/res/drawable/icon_add.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="960" + android:viewportHeight="960"> + <path + android:fillColor="@android:color/white" + android:pathData="M440,520L200,520L200,440L440,440L440,200L520,200L520,440L760,440L760,520L520,520L520,760L440,760L440,520Z" /> +</vector> diff --git a/android/lib/resource/src/main/res/drawable/icon_delete.xml b/android/lib/resource/src/main/res/drawable/icon_delete.xml new file mode 100644 index 0000000000..0e8b2004cb --- /dev/null +++ b/android/lib/resource/src/main/res/drawable/icon_delete.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="960" + android:viewportHeight="960"> + <path + android:fillColor="@android:color/white" + android:pathData="M280,840Q247,840 223.5,816.5Q200,793 200,760L200,240L160,240L160,160L360,160L360,120L600,120L600,160L800,160L800,240L760,240L760,760Q760,793 736.5,816.5Q713,840 680,840L280,840ZM680,240L280,240L280,760Q280,760 280,760Q280,760 280,760L680,760Q680,760 680,760Q680,760 680,760L680,240ZM360,680L440,680L440,320L360,320L360,680ZM520,680L600,680L600,320L520,320L520,680ZM280,240L280,240L280,760Q280,760 280,760Q280,760 280,760L280,760Q280,760 280,760Q280,760 280,760L280,240Z" /> +</vector> diff --git a/android/lib/resource/src/main/res/drawable/icon_edit.xml b/android/lib/resource/src/main/res/drawable/icon_edit.xml new file mode 100644 index 0000000000..3df2eb93a6 --- /dev/null +++ b/android/lib/resource/src/main/res/drawable/icon_edit.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="960" + android:viewportHeight="960"> + <path + android:fillColor="@android:color/white" + android:pathData="M200,760L257,760L648,369L591,312L200,703L200,760ZM120,840L120,670L648,143Q660,132 674.5,126Q689,120 705,120Q721,120 736,126Q751,132 762,144L817,200Q829,211 834.5,226Q840,241 840,256Q840,272 834.5,286.5Q829,301 817,313L290,840L120,840ZM760,256L760,256L704,200L704,200L760,256ZM619,341L591,312L591,312L648,369L648,369L619,341Z" /> +</vector> diff --git a/android/lib/resource/src/main/res/drawable/icon_more_vert.xml b/android/lib/resource/src/main/res/drawable/icon_more_vert.xml new file mode 100644 index 0000000000..59400ec977 --- /dev/null +++ b/android/lib/resource/src/main/res/drawable/icon_more_vert.xml @@ -0,0 +1,9 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="960" + android:viewportHeight="960"> + <path + android:fillColor="@android:color/white" + android:pathData="M480,800Q447,800 423.5,776.5Q400,753 400,720Q400,687 423.5,663.5Q447,640 480,640Q513,640 536.5,663.5Q560,687 560,720Q560,753 536.5,776.5Q513,800 480,800ZM480,560Q447,560 423.5,536.5Q400,513 400,480Q400,447 423.5,423.5Q447,400 480,400Q513,400 536.5,423.5Q560,447 560,480Q560,513 536.5,536.5Q513,560 480,560ZM480,320Q447,320 423.5,296.5Q400,273 400,240Q400,207 423.5,183.5Q447,160 480,160Q513,160 536.5,183.5Q560,207 560,240Q560,273 536.5,296.5Q513,320 480,320Z"/> +</vector> diff --git a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/Theme.kt b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/Theme.kt index 85d45c4d2b..69096ceccb 100644 --- a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/Theme.kt +++ b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/Theme.kt @@ -33,6 +33,12 @@ import net.mullvad.mullvadvpn.lib.theme.color.md_theme_dark_onSurfaceVariant import net.mullvad.mullvadvpn.lib.theme.color.md_theme_dark_onTertiaryContainer import net.mullvad.mullvadvpn.lib.theme.color.md_theme_dark_primary import net.mullvad.mullvadvpn.lib.theme.color.md_theme_dark_secondaryContainer +import net.mullvad.mullvadvpn.lib.theme.color.md_theme_dark_surfaceContainer +import net.mullvad.mullvadvpn.lib.theme.color.md_theme_dark_surfaceContainerHigh +import net.mullvad.mullvadvpn.lib.theme.color.md_theme_dark_surfaceContainerHighest +import net.mullvad.mullvadvpn.lib.theme.color.md_theme_dark_surfaceContainerLow +import net.mullvad.mullvadvpn.lib.theme.color.md_theme_dark_surfaceContainerLowest +import net.mullvad.mullvadvpn.lib.theme.color.md_theme_dark_surfaceTint import net.mullvad.mullvadvpn.lib.theme.color.md_theme_dark_surfaceVariant import net.mullvad.mullvadvpn.lib.theme.color.md_theme_dark_tertiaryContainer import net.mullvad.mullvadvpn.lib.theme.dimensions.Dimensions @@ -89,6 +95,12 @@ private val darkColorScheme = // surfaceTint = md_theme_dark_surfaceTint, outlineVariant = Color.Transparent, // Used by divider, // scrim = md_theme_dark_scrim, + surfaceContainerHighest = md_theme_dark_surfaceContainerHighest, + surfaceContainerHigh = md_theme_dark_surfaceContainerHigh, + surfaceContainerLow = md_theme_dark_surfaceContainerLow, + surfaceContainerLowest = md_theme_dark_surfaceContainerLowest, + surfaceContainer = md_theme_dark_surfaceContainer, + surfaceTint = md_theme_dark_surfaceTint ) val Shapes = diff --git a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/color/Color.kt b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/color/Color.kt index 82f924ebe0..01959b7934 100644 --- a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/color/Color.kt +++ b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/color/Color.kt @@ -21,6 +21,7 @@ const val AlphaDescription = 0.6f const val AlphaDisconnectButton = 0.6f const val AlphaChevron = 0.6f const val AlphaScrollbar = 0.6f +const val Alpha60 = 0.6f const val AlphaTopBar = 0.8f const val AlphaInvisible = 0f diff --git a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/color/ColorTokens.kt b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/color/ColorTokens.kt index 413a37f93e..1915cc911a 100644 --- a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/color/ColorTokens.kt +++ b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/color/ColorTokens.kt @@ -61,6 +61,11 @@ internal val md_theme_dark_outline = Color(0xFF8D9199) // Generated internal val md_theme_dark_inverseOnSurface = Color(0xFFFFFFFF) // MullvadWhite internal val md_theme_dark_inverseSurface = Color(0xFFFFFFFF) // MullvadWhite internal val md_theme_dark_inversePrimary = Color(0xFF0561A3) // Generated -internal val md_theme_dark_surfaceTint = Color(0xFF9FCAFF) // Generated +internal val md_theme_dark_surfaceTint = Color(0xFF233953) // Custom list disabled internal val md_theme_dark_outlineVariant = Color(0xFF43474E) // Generated internal val md_theme_dark_scrim = Color(0xFF000000) // Generated +internal val md_theme_dark_surfaceContainerHighest = Color(0xFF234161) // Relay list depth 0 +internal val md_theme_dark_surfaceContainerHigh = Color(0xFF1F3A57) // Relay list depth 1 +internal val md_theme_dark_surfaceContainerLow = Color(0xFF1C344E) // Relay list depth 2 +internal val md_theme_dark_surfaceContainerLowest = Color(0xFF1B314A) // Relay list depth 3 +internal val md_theme_dark_surfaceContainer = Color(0xFF192638) // Alert Blue diff --git a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt index ef6b04146e..2763033a30 100644 --- a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt +++ b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/dimensions/Dimensions.kt @@ -16,8 +16,10 @@ data class Dimensions( val cellEndPadding: Dp = 16.dp, val cellFooterTopPadding: Dp = 6.dp, val cellHeight: Dp = 56.dp, + val cellHeightTwoRows: Dp = 72.dp, val cellLabelVerticalPadding: Dp = 14.dp, val cellStartPadding: Dp = 22.dp, + val cellStartPaddingInteractive: Dp = 14.dp, val cellTopPadding: Dp = 6.dp, val cellVerticalSpacing: Dp = 14.dp, val checkBoxSize: Dp = 24.dp, @@ -36,7 +38,8 @@ data class Dimensions( val customPortBoxMinWidth: Dp = 80.dp, val deleteIconSize: Dp = 24.dp, val dialogIconHeight: Dp = 44.dp, - val dialogIconSize: Dp = 48.dp, + val dropdownMenuVerticalPadding: Dp = 8.dp, // Used to remove padding from dropdown menu + val dropdownMenuBorder: Dp = 1.dp, val expandableCellChevronSize: Dp = 30.dp, val filterTittlePadding: Dp = 4.dp, val iconFailSuccessTopMargin: Dp = 30.dp, @@ -61,6 +64,7 @@ data class Dimensions( val progressIndicatorSize: Dp = 48.dp, val relayCircleSize: Dp = 16.dp, val relayRowPadding: Dp = 50.dp, + val relayRowPaddingExtra: Dp = 66.dp, val screenVerticalMargin: Dp = 22.dp, val searchFieldHeight: Dp = 42.dp, val searchFieldHorizontalPadding: Dp = 22.dp, diff --git a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/shape/Shape.kt b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/shape/Shape.kt index 501cb72946..aa2f40782c 100644 --- a/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/shape/Shape.kt +++ b/android/lib/theme/src/main/kotlin/net/mullvad/mullvadvpn/lib/theme/shape/Shape.kt @@ -11,3 +11,9 @@ val Shapes.chipShape: Shape get() { return RoundedCornerShape(8.dp) } + +val Shapes.fabShape: Shape + @Composable + get() { + return RoundedCornerShape(16.dp) + } |
