android: adds support for user avatars and some general cleanup (#202)

* android: show user avatars and styling fixes

Updates tailscale/corp#18202
fixes ENG-2852

Load and show the user avatar in the right places.  There's a universal Avatar composable for this that should work everywhere we need it.  This  uses the coil-compose lib which seems to be standard practice and will handle caching for us.

Restyles a few headers to match the about screen and corrects some layout issues with the height of columns.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>

* android: add localizations and view model cleanup to match IPNManager

Updates tailscale/corp#18202

Simplifies the view models a bit for readability and localizes a few things that weren't previously localized

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>

* android: fix peer categorization

Updates tailscale/corp#18202

Fixes a null predicate issue for searching and removes the self nodes if there are no matches.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>

* android: rename avatar loader to avatar and add header

Updates tailscale/corp#18202

Rename the AvatarLoader class to Avatar and move it to views.  Add the proper headers.

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>

---------

Signed-off-by: Jonathan Nobels <jonathan@tailscale.com>
Co-authored-by: Andrea Gottardo <andrea@tailscale.com>
pull/205/head
Jonathan Nobels 9 months ago committed by GitHub
parent f275656c25
commit 16ec19757d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -92,6 +92,8 @@ dependencies {
implementation "androidx.navigation:navigation-dynamic-features-fragment:$nav_version" implementation "androidx.navigation:navigation-dynamic-features-fragment:$nav_version"
implementation "androidx.navigation:navigation-compose:$nav_version" implementation "androidx.navigation:navigation-compose:$nav_version"
// Supporting libraries.
implementation("io.coil-kt:coil-compose:1.3.1")
// Tailscale dependencies. // Tailscale dependencies.
implementation ':ipn@aar' implementation ':ipn@aar'

@ -74,6 +74,7 @@ class MainActivity : ComponentActivity() {
ExitNodePicker(ExitNodePickerViewModel(manager.model)) ExitNodePicker(ExitNodePickerViewModel(manager.model))
} }
composable( composable(
"peerDetails/{nodeId}", "peerDetails/{nodeId}",
arguments = listOf(navArgument("nodeId") { type = NavType.StringType }) arguments = listOf(navArgument("nodeId") { type = NavType.StringType })
) { ) {
@ -120,7 +121,6 @@ class MainActivity : ComponentActivity() {
startActivity(browserIntent) startActivity(browserIntent)
} }
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
val restrictionsManager = val restrictionsManager =

@ -35,8 +35,8 @@ class PeerCategorizer(val model: IpnModel) {
var selfPeers = (grouped[selfNode.User] ?: emptyList()).sortedBy { it.ComputedName } var selfPeers = (grouped[selfNode.User] ?: emptyList()).sortedBy { it.ComputedName }
grouped.remove(selfNode.User) grouped.remove(selfNode.User)
val currentNode = selfPeers.first { it.ID == selfNode.ID } val currentNode = selfPeers.firstOrNull { it.ID == selfNode.ID }
currentNode.let { currentNode?.let {
selfPeers = selfPeers.filter { it.ID != currentNode.ID } selfPeers = selfPeers.filter { it.ID != currentNode.ID }
selfPeers = listOf(currentNode) + selfPeers selfPeers = listOf(currentNode) + selfPeers
} }
@ -49,6 +49,10 @@ class PeerCategorizer(val model: IpnModel) {
} }
val me = netmap.currentUserProfile() val me = netmap.currentUserProfile()
return listOf(PeerSet(me, selfPeers)) + sorted return if (selfPeers.isEmpty()) {
sorted
} else {
listOf(PeerSet(me, selfPeers)) + sorted
}
} }
} }

@ -17,10 +17,13 @@ class TimeUtil {
val diff = (expTime - now) / 1000 val diff = (expTime - now) / 1000
if(diff < 0){ if (diff < 0) {
return "expired" return "expired"
} }
// (jonathan) TODO: This is incorrect in a couple of ways
// - It needs to be in a composable so we can use stringResource
// - The string resources need to be proper plurals
return when (diff) { return when (diff) {
in 0..60 -> "under a minute" in 0..60 -> "under a minute"
in 61..3600 -> "in ${diff / 60} minutes" in 61..3600 -> "in ${diff / 60} minutes"
@ -43,3 +46,4 @@ class TimeUtil {
return Date.from(i) return Date.from(i)
} }
} }

@ -0,0 +1,51 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Person
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.draw.clip
import androidx.compose.ui.unit.dp
import coil.annotation.ExperimentalCoilApi
import coil.compose.rememberImagePainter
import com.tailscale.ipn.ui.model.IpnLocal
@OptIn(ExperimentalCoilApi::class)
@Composable
fun Avatar(profile: IpnLocal.LoginProfile?, size: Int = 50) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(size.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.tertiaryContainer)
) {
profile?.UserProfile?.ProfilePicURL?.let { url ->
val painter = rememberImagePainter(data = url)
Image(
painter = painter,
contentDescription = null,
modifier = Modifier.size(size.dp)
)
} ?: run {
Icon(
imageVector = Icons.Default.Person,
contentDescription = null,
tint = MaterialTheme.colorScheme.onTertiaryContainer,
modifier = Modifier.size((size * .8f).dp)
)
}
}
}

@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
@ -46,10 +47,11 @@ fun BugReportView(viewModel: BugReportViewModel) {
val handler = LocalUriHandler.current val handler = LocalUriHandler.current
Surface(color = MaterialTheme.colorScheme.surface) { Surface(color = MaterialTheme.colorScheme.surface) {
Column(modifier = defaultPaddingModifier().fillMaxWidth()) { Column(modifier = defaultPaddingModifier().fillMaxWidth().fillMaxHeight()) {
Text(text = stringResource(id = R.string.bug_report_title), Text(text = stringResource(id = R.string.bug_report_title),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.titleMedium) style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
@ -70,6 +72,7 @@ fun BugReportView(viewModel: BugReportViewModel) {
Text(text = stringResource(id = R.string.bug_report_id_desc), Text(text = stringResource(id = R.string.bug_report_id_desc),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Left, textAlign = TextAlign.Left,
color = MaterialTheme.colorScheme.secondary,
style = MaterialTheme.typography.bodySmall) style = MaterialTheme.typography.bodySmall)
} }
} }
@ -98,7 +101,9 @@ fun ReportIdRow(bugReportIdFlow: StateFlow<String>) {
@Composable @Composable
fun contactText(): AnnotatedString { fun contactText(): AnnotatedString {
val annotatedString = buildAnnotatedString { val annotatedString = buildAnnotatedString {
withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.primary)) {
append(stringResource(id = R.string.bug_report_instructions_prefix)) append(stringResource(id = R.string.bug_report_instructions_prefix))
}
pushStringAnnotation(tag = "reportLink", annotation = Links.SUPPORT_URL) pushStringAnnotation(tag = "reportLink", annotation = Links.SUPPORT_URL)
withStyle(style = SpanStyle(color = Color.Blue)) { withStyle(style = SpanStyle(color = Color.Blue)) {
@ -106,8 +111,10 @@ fun contactText(): AnnotatedString {
} }
pop() pop()
withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.primary)) {
append(stringResource(id = R.string.bug_report_instructions_suffix)) append(stringResource(id = R.string.bug_report_instructions_suffix))
} }
}
return annotatedString return annotatedString
} }

@ -76,9 +76,11 @@ fun MainView(viewModel: MainViewModel, navigation: MainViewNavigation) {
val isOn = viewModel.vpnToggleState.collectAsState(initial = false) val isOn = viewModel.vpnToggleState.collectAsState(initial = false)
Switch(onCheckedChange = { viewModel.toggleVpn() }, checked = isOn.value) Switch(onCheckedChange = { viewModel.toggleVpn() }, checked = isOn.value)
StateDisplay(viewModel.stateStr, viewModel.userName) StateDisplay(viewModel.stateRes, viewModel.userName)
Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) { Box(modifier = Modifier
SettingsButton(user.value, navigation.onNavigateToSettings) .weight(1f)
.clickable { navigation.onNavigateToSettings() }, contentAlignment = Alignment.CenterEnd) {
Avatar(profile = user.value, size = 36)
} }
} }
@ -128,12 +130,13 @@ fun ExitNodeStatus(navAction: () -> Unit, exitNode: String = stringResource(id =
} }
@Composable @Composable
fun StateDisplay(state: StateFlow<String>, tailnet: String) { fun StateDisplay(state: StateFlow<Int>, tailnet: String) {
val stateStr = state.collectAsState(initial = "--") val stateVal = state.collectAsState(initial = R.string.placeholder)
val stateStr = stringResource(id = stateVal.value)
Column(modifier = Modifier.padding(6.dp)) { Column(modifier = Modifier.padding(6.dp)) {
Text(text = "${tailnet}", style = MaterialTheme.typography.titleMedium) Text(text = tailnet, style = MaterialTheme.typography.titleMedium)
Text(text = "${stateStr.value}", style = MaterialTheme.typography.bodyMedium) Text(text = stateStr, style = MaterialTheme.typography.bodyMedium)
} }
} }
@ -158,10 +161,15 @@ fun StartingView() {
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.secondaryContainer), .background(MaterialTheme.colorScheme.background),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { Text(text = stringResource(id = R.string.starting), style = MaterialTheme.typography.titleMedium) } ) {
Text(text = stringResource(id = R.string.starting),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
}
} }
@Composable @Composable
@ -170,15 +178,18 @@ fun ConnectView(user: IpnLocal.LoginProfile?, connectAction: () -> Unit, loginAc
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.secondaryContainer), .background(MaterialTheme.colorScheme.background),
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Text(text = stringResource(id = R.string.not_connected), style = MaterialTheme.typography.titleMedium) Text(text = stringResource(id = R.string.not_connected),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary)
if (user != null && !user.isEmpty()) { if (user != null && !user.isEmpty()) {
val tailnetName = user.NetworkProfile?.DomainName ?: "" val tailnetName = user.NetworkProfile?.DomainName ?: ""
Text(stringResource(id = R.string.connect_to_tailnet, tailnetName), Text(stringResource(id = R.string.connect_to_tailnet, tailnetName),
style = MaterialTheme.typography.bodyMedium style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
) )
Button(onClick = connectAction) { Text(text = stringResource(id = R.string.connect)) } Button(onClick = connectAction) { Text(text = stringResource(id = R.string.connect)) }
} else { } else {

@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@ -36,24 +37,33 @@ import com.tailscale.ipn.ui.viewModel.PeerDetailsViewModel
fun PeerDetails(viewModel: PeerDetailsViewModel) { fun PeerDetails(viewModel: PeerDetailsViewModel) {
Surface(color = MaterialTheme.colorScheme.surface) { Surface(color = MaterialTheme.colorScheme.surface) {
Column(modifier = Modifier.padding(horizontal = 8.dp)) { Column(modifier = Modifier.padding(horizontal = 8.dp).fillMaxHeight()) {
Column(modifier = Modifier Column(modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 8.dp), .padding(vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally) { horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = viewModel.nodeName, style = MaterialTheme.typography.titleMedium) Text(text = viewModel.nodeName,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Box(modifier = Modifier Box(modifier = Modifier
.size(8.dp) .size(8.dp)
.background(color = viewModel.connectedColor, shape = RoundedCornerShape(percent = 50))) {} .background(color = viewModel.connectedColor, shape = RoundedCornerShape(percent = 50))) {}
Spacer(modifier = Modifier.size(8.dp)) Spacer(modifier = Modifier.size(8.dp))
Text(text = stringResource(id = viewModel.connectedStrRes), style = MaterialTheme.typography.bodyMedium) Text(text = stringResource(id = viewModel.connectedStrRes),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
} }
} }
Spacer(modifier = Modifier.size(8.dp)) Spacer(modifier = Modifier.size(8.dp))
Text(text = stringResource(id = R.string.addresses_section), style = MaterialTheme.typography.titleMedium) Text(text = stringResource(id = R.string.addresses_section),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
Column(modifier = settingsRowModifier()) { Column(modifier = settingsRowModifier()) {
viewModel.addresses.forEach { viewModel.addresses.forEach {

@ -5,10 +5,13 @@
package com.tailscale.ipn.ui.view package com.tailscale.ipn.ui.view
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.text.ClickableText
@ -30,9 +33,9 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.tailscale.ipn.mdm.MDMSettings
import com.tailscale.ipn.R import com.tailscale.ipn.R
import com.tailscale.ipn.ui.Links import com.tailscale.ipn.ui.Links
import com.tailscale.ipn.ui.model.IpnLocal import com.tailscale.ipn.ui.model.IpnLocal
@ -55,7 +58,21 @@ fun Settings(viewModel: SettingsViewModel) {
Surface(color = MaterialTheme.colorScheme.surface) { Surface(color = MaterialTheme.colorScheme.surface) {
Column(modifier = defaultPaddingModifier()) { Column(modifier = defaultPaddingModifier().fillMaxHeight()) {
Text(text = stringResource(id = R.string.settings_title),
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(8.dp))
// The login/logout button here is probably in the wrong location, but we need something
// somewhere for the time being. FUS should probably be implemented for V0 given that
// it's relatively simple to do so with localAPI. On iOS, the UI for user switching is
// all in the FUS screen.
viewModel.user?.let { user -> viewModel.user?.let { user ->
UserView(profile = user, viewModel.isAdmin, adminText(), onClick = { UserView(profile = user, viewModel.isAdmin, adminText(), onClick = {
handler.openUri(Links.ADMIN_URL) handler.openUri(Links.ADMIN_URL)
@ -70,7 +87,6 @@ fun Settings(viewModel: SettingsViewModel) {
} }
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
viewModel.settings.forEach { settingBundle -> viewModel.settings.forEach { settingBundle ->
@ -102,12 +118,19 @@ fun Settings(viewModel: SettingsViewModel) {
@Composable @Composable
fun UserView(profile: IpnLocal.LoginProfile?, isAdmin: Boolean, adminText: AnnotatedString, onClick: () -> Unit) { fun UserView(profile: IpnLocal.LoginProfile?, isAdmin: Boolean, adminText: AnnotatedString, onClick: () -> Unit) {
Column(modifier = defaultPaddingModifier()) { Column {
Column(modifier = settingsRowModifier().padding(8.dp)) { Row(modifier = settingsRowModifier().padding(8.dp)) {
Box(modifier = defaultPaddingModifier()) {
Avatar(profile = profile, size = 36)
}
Column(verticalArrangement = Arrangement.Center) {
Text(text = profile?.UserProfile?.DisplayName Text(text = profile?.UserProfile?.DisplayName
?: "", style = MaterialTheme.typography.titleMedium) ?: "", style = MaterialTheme.typography.titleMedium)
Text(text = profile?.Name ?: "", style = MaterialTheme.typography.bodyMedium) Text(text = profile?.Name ?: "", style = MaterialTheme.typography.bodyMedium)
} }
}
if (isAdmin) { if (isAdmin) {
Column(modifier = Modifier.padding(horizontal = 12.dp)) { Column(modifier = Modifier.padding(horizontal = 12.dp)) {
@ -150,7 +173,9 @@ fun SettingsSwitchRow(setting: Setting) {
@Composable @Composable
fun adminText(): AnnotatedString { fun adminText(): AnnotatedString {
val annotatedString = buildAnnotatedString { val annotatedString = buildAnnotatedString {
withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.primary)) {
append(stringResource(id = R.string.settings_admin_prefix)) append(stringResource(id = R.string.settings_admin_prefix))
}
pushStringAnnotation(tag = "link", annotation = Links.ADMIN_URL) pushStringAnnotation(tag = "link", annotation = Links.ADMIN_URL)
withStyle(style = SpanStyle(color = Color.Blue)) { withStyle(style = SpanStyle(color = Color.Blue)) {

@ -7,21 +7,21 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.ui.localapi.LocalApiClient import com.tailscale.ipn.ui.localapi.LocalApiClient
import com.tailscale.ipn.ui.model.BugReportID import com.tailscale.ipn.ui.model.BugReportID
import com.tailscale.ipn.ui.service.set
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class BugReportViewModel(localAPI: LocalApiClient) : ViewModel() { class BugReportViewModel(localAPI: LocalApiClient) : ViewModel() {
private var _bugReportID: MutableStateFlow<BugReportID> = MutableStateFlow("") var bugReportID: StateFlow<BugReportID> = MutableStateFlow("")
var bugReportID: StateFlow<String> = _bugReportID
init { init {
viewModelScope.launch { viewModelScope.launch {
localAPI.getBugReportId { localAPI.getBugReportId {
when (it.successful) { when (it.successful) {
true -> _bugReportID.value = it.success ?: "(Error fetching ID)" true -> bugReportID.set(it.success ?: "(Error fetching ID)")
false -> _bugReportID.value = "(Error fetching ID)" false -> bugReportID.set("(Error fetching ID)")
} }
} }
} }

@ -6,64 +6,57 @@ package com.tailscale.ipn.ui.viewModel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.model.Ipn.State import com.tailscale.ipn.ui.model.Ipn.State
import com.tailscale.ipn.ui.service.IpnActions import com.tailscale.ipn.ui.service.IpnActions
import com.tailscale.ipn.ui.service.IpnModel import com.tailscale.ipn.ui.service.IpnModel
import com.tailscale.ipn.ui.service.set
import com.tailscale.ipn.ui.util.PeerCategorizer import com.tailscale.ipn.ui.util.PeerCategorizer
import com.tailscale.ipn.ui.util.PeerSet import com.tailscale.ipn.ui.util.PeerSet
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class MainViewModel(val model: IpnModel, val actions: IpnActions) : ViewModel() { class MainViewModel(val model: IpnModel, val actions: IpnActions) : ViewModel() {
private val _stateStr = MutableStateFlow<String>("")
private val _tailnetName = MutableStateFlow<String>("")
private val _vpnToggleState = MutableStateFlow<Boolean>(false)
private val _peers = MutableStateFlow<List<PeerSet>>(emptyList<PeerSet>())
// The user readable state of the system // The user readable state of the system
val stateStr = _stateStr.asStateFlow() val stateRes: StateFlow<Int> = MutableStateFlow(State.NoState.userStringRes())
// The current state of the IPN for determining view visibility
val ipnState = model.state
// The name of the tailnet
val tailnetName = _tailnetName.asStateFlow()
// The expected state of the VPN toggle // The expected state of the VPN toggle
val vpnToggleState = _vpnToggleState.asStateFlow() val vpnToggleState: StateFlow<Boolean> = MutableStateFlow(false)
// The list of peers // The list of peers
val peers = _peers.asStateFlow() val peers: StateFlow<List<PeerSet>> = MutableStateFlow(emptyList<PeerSet>())
// The current state of the IPN for determining view visibility
val ipnState = model.state
// The logged in user // The logged in user
val loggedInUser = model.loggedInUser val loggedInUser = model.loggedInUser
// The active search term for filtering peers // The active search term for filtering peers
val searchTerm = MutableStateFlow<String>("") val searchTerm: StateFlow<String> = MutableStateFlow("")
init { init {
viewModelScope.launch { viewModelScope.launch {
model.state.collect { state -> model.state.collect { state ->
_stateStr.value = state.userString() stateRes.set(state.userStringRes())
_vpnToggleState.value = (state == State.Running || state == State.Starting) vpnToggleState.set((state == State.Running || state == State.Starting))
} }
} }
viewModelScope.launch { viewModelScope.launch {
model.netmap.collect { netmap -> model.netmap.collect { netmap ->
_tailnetName.value = netmap?.Domain ?: "" peers.set(PeerCategorizer(model).groupedAndFilteredPeers(searchTerm.value))
_peers.value = PeerCategorizer(model).groupedAndFilteredPeers(searchTerm.value)
} }
} }
} }
fun searchPeers(searchTerm: String) { fun searchPeers(searchTerm: String) {
this.searchTerm.value = searchTerm this.searchTerm.set(searchTerm)
viewModelScope.launch { viewModelScope.launch {
_peers.value = PeerCategorizer(model).groupedAndFilteredPeers(searchTerm) peers.set(PeerCategorizer(model).groupedAndFilteredPeers(searchTerm))
} }
} }
@ -85,15 +78,15 @@ class MainViewModel(val model: IpnModel, val actions: IpnActions) : ViewModel()
} }
private fun State?.userString(): String { private fun State?.userStringRes(): Int {
return when (this) { return when (this) {
State.NoState -> "Waiting..." State.NoState -> R.string.waiting
State.InUseOtherUser -> "--" State.InUseOtherUser -> R.string.placeholder
State.NeedsLogin -> "Please Login" State.NeedsLogin -> R.string.please_login
State.NeedsMachineAuth -> "--" State.NeedsMachineAuth -> R.string.placeholder
State.Stopped -> "Stopped" State.Stopped -> R.string.stopped
State.Starting -> "Starting" State.Starting -> R.string.starting
State.Running -> "Connected" State.Running -> R.string.connected
else -> "--" else -> R.string.placeholder
} }
} }

@ -30,14 +30,14 @@
<!-- Strings for the settings screen --> <!-- Strings for the settings screen -->
<string name="settings_title">Settings</string> <string name="settings_title">Settings</string>
<string name="settings_admin_prefix">You can manage your account from the admin console.&#160;</string> <string name="settings_admin_prefix">You can manage your account from the admin console.&#160;</string>
<string name="settings_admin_link">View admin console...</string> <string name="settings_admin_link">View admin console</string>
<string name="about">About</string> <string name="about">About</string>
<string name="bug_report">Bug Report</string> <string name="bug_report">Bug Report</string>
<string name="use_ts_dns">Use Tailscale DNS</string> <string name="use_ts_dns">Use Tailscale DNS</string>
<!-- Strings for the main scren --> <!-- Strings for the main scren -->
<string name="exit_node">Exit Node</string> <string name="exit_node">Exit Node</string>
<string name="starting">Staring...</string> <string name="starting">Starting…</string>
<string name="connect_to_tailnet">"Connect to your %1$s tailnet"</string> <string name="connect_to_tailnet">"Connect to your %1$s tailnet"</string>
<!-- Strings for peer details --> <!-- Strings for peer details -->
@ -49,5 +49,19 @@
<string name="current_mdm_settings">Current MDM Settings</string> <string name="current_mdm_settings">Current MDM Settings</string>
<string name="mdm_settings">MDM Settings</string> <string name="mdm_settings">MDM Settings</string>
<!-- State strings -->
<string name="waiting">Waiting…</string>
<string name="placeholder">--</string>
<string name="please_login">Please Login</string>
<string name="stopped">Stopped</string>
<!-- Time conversion templates -->
<string name="expired">expired</string>
<string name="under_a_minute">under a minute</string>
<string name="in_x_minutes">in %1$s minutes</string>
<string name="in_x_hours">in %1$s hours</string>
<string name="in_x_days">in %1$s days</string>
<string name="in_x_months">in %1$s months</string>
<string name="in_x_years">in %1$s years</string>
</resources> </resources>

Loading…
Cancel
Save