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