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