android: implement exit node picker
Updates tailscale/corp#18202 Signed-off-by: Percy Wegmann <percy@tailscale.com>pull/212/head
parent
06e850bbd5
commit
9a6aecb454
@ -0,0 +1,34 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.util
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Code adapted from https://github.com/piashcse/Compose-museum/blob/master/app/src/main/java/com/piashcse/compose_museum/screens/CountryList.kt#L75
|
||||||
|
*/
|
||||||
|
|
||||||
|
//Copyright 2023 piashcse (Mehedi Hassan Piash)
|
||||||
|
//
|
||||||
|
//Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
//you may not use this file except in compliance with the License.
|
||||||
|
//You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
//http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
//Unless required by applicable law or agreed to in writing, software
|
||||||
|
//distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
//WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
//See the License for the specific language governing permissions and
|
||||||
|
//limitations under the License.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flag turns an ISO3166 country code into a flag emoji.
|
||||||
|
*/
|
||||||
|
fun String.flag(): String {
|
||||||
|
val caps = this.uppercase()
|
||||||
|
val flagOffset = 0x1F1E6
|
||||||
|
val asciiOffset = 0x41
|
||||||
|
val firstChar = Character.codePointAt(caps, 0) - asciiOffset + flagOffset
|
||||||
|
val secondChar = Character.codePointAt(caps, 1) - asciiOffset + flagOffset
|
||||||
|
return String(Character.toChars(firstChar)) + String(Character.toChars(secondChar))
|
||||||
|
}
|
@ -0,0 +1,54 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.util
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
|
||||||
|
object LoadingIndicator {
|
||||||
|
private val loading = MutableStateFlow(false)
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
loading.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun Wrap(content: @Composable () -> Unit) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
val isLoading = loading.collectAsState()
|
||||||
|
if (isLoading.value) {
|
||||||
|
Box(
|
||||||
|
Modifier
|
||||||
|
.matchParentSize()
|
||||||
|
.background(Color.Gray.copy(alpha = 0.5f))
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,14 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.util
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides a way to expose a MutableStateFlow as an immutable StateFlow.
|
||||||
|
*/
|
||||||
|
fun <T> StateFlow<T>.set(v: T) {
|
||||||
|
(this as MutableStateFlow<T>).value = v
|
||||||
|
}
|
@ -0,0 +1,59 @@
|
|||||||
|
// Copyright (c) Tailscale Inc & AUTHORS
|
||||||
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
|
||||||
|
package com.tailscale.ipn.ui.view
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import com.tailscale.ipn.R
|
||||||
|
import com.tailscale.ipn.ui.util.LoadingIndicator
|
||||||
|
import com.tailscale.ipn.ui.util.flag
|
||||||
|
import com.tailscale.ipn.ui.viewModel.ExitNodePickerViewModel
|
||||||
|
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun MullvadExitNodePicker(viewModel: ExitNodePickerViewModel, countryCode: String) {
|
||||||
|
val mullvadExitNodes = viewModel.mullvadExitNodesByCountryCode.collectAsState()
|
||||||
|
val bestAvailableByCountry = viewModel.mullvadBestAvailableByCountry.collectAsState()
|
||||||
|
|
||||||
|
mullvadExitNodes.value[countryCode]?.toList()?.let { nodes ->
|
||||||
|
val any = nodes.first()
|
||||||
|
|
||||||
|
LoadingIndicator.Wrap {
|
||||||
|
Scaffold(topBar = {
|
||||||
|
TopAppBar(title = { Text("${countryCode.flag()} ${any.country}") })
|
||||||
|
}) { innerPadding ->
|
||||||
|
LazyColumn(modifier = Modifier.padding(innerPadding)) {
|
||||||
|
if (nodes.size > 1) {
|
||||||
|
val bestAvailableNode = bestAvailableByCountry.value[countryCode]!!
|
||||||
|
item {
|
||||||
|
ExitNodeItem(
|
||||||
|
viewModel, ExitNodePickerViewModel.ExitNode(
|
||||||
|
id = bestAvailableNode.id,
|
||||||
|
label = stringResource(R.string.best_available),
|
||||||
|
online = bestAvailableNode.online,
|
||||||
|
selected = false,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items(nodes) { node ->
|
||||||
|
ExitNodeItem(viewModel, node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue