android: style exit node picker per Material UI and add disable button
Updates #ENG-2911 Signed-off-by: Percy Wegmann <percy@tailscale.com>ox/exit_node_clickable
parent
3fea68ef2e
commit
fb5635b8a5
@ -0,0 +1,27 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.util
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Similar to Modifier.clickable, but if enabled == false, this adds a 75% alpha to make disabled
|
||||||
|
* items appear grayed out.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun Modifier.clickableOrGrayedOut(
|
||||||
|
enabled: Boolean = true,
|
||||||
|
onClickLabel: String? = null,
|
||||||
|
role: Role? = null,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) =
|
||||||
|
if (enabled) {
|
||||||
|
clickable(onClickLabel = onClickLabel, role = role, onClick = onClick)
|
||||||
|
} else {
|
||||||
|
alpha(0.75f)
|
||||||
|
}
|
@ -0,0 +1,43 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.util
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyItemScope
|
||||||
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
object Lists {
|
||||||
|
@Composable
|
||||||
|
fun SectionDivider() {
|
||||||
|
Box(Modifier.size(0.dp, 24.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ItemDivider() {
|
||||||
|
HorizontalDivider(color = MaterialTheme.colorScheme.secondaryContainer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Similar to items() but includes a horizontal divider between items. */
|
||||||
|
inline fun <T> LazyListScope.itemsWithDividers(
|
||||||
|
items: List<T>,
|
||||||
|
noinline key: ((item: T) -> Any)? = null,
|
||||||
|
crossinline contentType: (item: T) -> Any? = { _ -> null },
|
||||||
|
crossinline itemContent: @Composable LazyItemScope.(item: T) -> Unit
|
||||||
|
) =
|
||||||
|
items(
|
||||||
|
count = items.size,
|
||||||
|
key = if (key != null) { index: Int -> key(items[index]) } else null,
|
||||||
|
contentType = { index -> contentType(items[index]) }) {
|
||||||
|
if (it > 0 && it < items.size) {
|
||||||
|
Lists.ItemDivider()
|
||||||
|
}
|
||||||
|
itemContent(items[it])
|
||||||
|
}
|
@ -0,0 +1,92 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.view
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.Check
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.ListItem
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import com.tailscale.ipn.R
|
||||||
|
import com.tailscale.ipn.ui.util.LoadingIndicator
|
||||||
|
import com.tailscale.ipn.ui.util.flag
|
||||||
|
import com.tailscale.ipn.ui.util.itemsWithDividers
|
||||||
|
import com.tailscale.ipn.ui.viewModel.ExitNodePickerNav
|
||||||
|
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel
|
||||||
|
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModelFactory
|
||||||
|
import com.tailscale.ipn.ui.viewModel.selected
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun MullvadExitNodePickerList(
|
||||||
|
nav: ExitNodePickerNav,
|
||||||
|
model: ExitNodePickerViewModel = viewModel(factory = ExitNodePickerViewModelFactory(nav))
|
||||||
|
) {
|
||||||
|
LoadingIndicator.Wrap {
|
||||||
|
Scaffold(topBar = { Header(R.string.choose_mullvad_exit_node, onBack = nav.onNavigateBack) }) {
|
||||||
|
innerPadding ->
|
||||||
|
val mullvadExitNodes = model.mullvadExitNodesByCountryCode.collectAsState()
|
||||||
|
|
||||||
|
LazyColumn(modifier = Modifier.padding(innerPadding)) {
|
||||||
|
val sortedCountries =
|
||||||
|
mullvadExitNodes.value.entries.toList().sortedBy {
|
||||||
|
it.value.first().country.lowercase()
|
||||||
|
}
|
||||||
|
itemsWithDividers(sortedCountries) { (countryCode, nodes) ->
|
||||||
|
val first = nodes.first()
|
||||||
|
|
||||||
|
// TODO(oxtoacart): the modifier on the ListItem occasionally causes a crash
|
||||||
|
// with java.lang.ClassCastException: androidx.compose.ui.ComposedModifier cannot be cast
|
||||||
|
// to androidx.compose.runtime.RecomposeScopeImpl
|
||||||
|
// Wrapping it in a Box eliminates this. It appears to be some kind of
|
||||||
|
// interaction between the LazyList and the modifier.
|
||||||
|
Box {
|
||||||
|
ListItem(
|
||||||
|
modifier =
|
||||||
|
Modifier.clickable {
|
||||||
|
if (nodes.size > 1) {
|
||||||
|
nav.onNavigateToMullvadCountry(countryCode)
|
||||||
|
} else {
|
||||||
|
model.setExitNode(first)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
leadingContent = {
|
||||||
|
Text(
|
||||||
|
countryCode.flag(),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
headlineContent = { Text(first.country) },
|
||||||
|
supportingContent = {
|
||||||
|
Text(
|
||||||
|
if (nodes.size == 1) first.city
|
||||||
|
else "${nodes.size} ${stringResource(R.string.cities_available)}")
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
if (nodes.size > 1 && nodes.selected || first.selected) {
|
||||||
|
if (nodes.selected) {
|
||||||
|
Icon(
|
||||||
|
Icons.Outlined.Check,
|
||||||
|
contentDescription = stringResource(R.string.selected))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue