@ -1,7 +1,6 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
package com.tailscale.ipn.ui.view
import androidx.compose.foundation.background
@ -30,9 +29,9 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SearchBar
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@ -60,330 +59,307 @@ import com.tailscale.ipn.ui.util.flag
import com.tailscale.ipn.ui.viewModel.MainViewModel
import kotlinx.coroutines.flow.StateFlow
// Navigation actions for the MainView
data class MainViewNavigation (
val onNavigateToSettings : ( ) -> Unit ,
val onNavigateToPeerDetails : ( Tailcfg . Node ) -> Unit ,
val onNavigateToExitNodes : ( ) -> Unit
val onNavigateToSettings : ( ) -> Unit ,
val onNavigateToPeerDetails : ( Tailcfg . Node ) -> Unit ,
val onNavigateToExitNodes : ( ) -> Unit
)
@Composable
fun MainView ( navigation : MainViewNavigation , viewModel : MainViewModel = viewModel ( ) ) {
Surface ( color = MaterialTheme . colorScheme . secondaryContainer ) {
Column (
modifier = Modifier . fillMaxWidth ( ) ,
verticalArrangement = Arrangement . Center
) {
val state = viewModel . ipnState . collectAsState ( initial = Ipn . State . NoState )
val user = viewModel . loggedInUser . collectAsState ( initial = null )
Row ( modifier = Modifier
. fillMaxWidth ( )
. padding ( horizontal = 8. dp ) ,
verticalAlignment = Alignment . CenterVertically ) {
val isOn = viewModel . vpnToggleState . collectAsState ( initial = false )
if ( state . value != Ipn . State . NeedsLogin && state . value != Ipn . State . NoState ) {
Switch ( onCheckedChange = { viewModel . toggleVpn ( ) } , checked = isOn . value )
Spacer ( Modifier . size ( 3. dp ) )
}
StateDisplay ( viewModel . stateRes , viewModel . userName )
Box ( modifier = Modifier
. weight ( 1f )
. clickable { navigation . onNavigateToSettings ( ) } , contentAlignment = Alignment . CenterEnd ) {
when ( user . value ) {
null -> SettingsButton ( user . value ) { navigation . onNavigateToSettings ( ) }
else -> Avatar ( profile = user . value , size = 36 )
}
}
Scaffold { _ ->
Column ( modifier = Modifier . fillMaxWidth ( ) , verticalArrangement = Arrangement . Center ) {
val state = viewModel . ipnState . collectAsState ( initial = Ipn . State . NoState )
val user = viewModel . loggedInUser . collectAsState ( initial = null )
Row (
modifier = Modifier . fillMaxWidth ( ) . padding ( horizontal = 8. dp ) ,
verticalAlignment = Alignment . CenterVertically ) {
val isOn = viewModel . vpnToggleState . collectAsState ( initial = false )
if ( state . value != Ipn . State . NeedsLogin && state . value != Ipn . State . NoState ) {
Switch ( onCheckedChange = { viewModel . toggleVpn ( ) } , checked = isOn . value )
Spacer ( Modifier . size ( 3. dp ) )
}
StateDisplay ( viewModel . stateRes , viewModel . userName )
when ( state . value ) {
Ipn . State . Running -> {
val selfPeerId = viewModel . selfPeerId . collectAsState ( initial = " " )
ExitNodeStatus ( navAction = navigation . onNavigateToExitNodes , viewModel = viewModel )
PeerList (
searchTerm = viewModel . searchTerm ,
state = viewModel . ipnState ,
peers = viewModel . peers ,
selfPeer = selfPeerId . value ,
onNavigateToPeerDetails = navigation . onNavigateToPeerDetails ,
onSearch = { viewModel . searchPeers ( it ) } )
Box (
modifier = Modifier . weight ( 1f ) . clickable { navigation . onNavigateToSettings ( ) } ,
contentAlignment = Alignment . CenterEnd ) {
when ( user . value ) {
null -> SettingsButton ( user . value ) { navigation . onNavigateToSettings ( ) }
else -> Avatar ( profile = user . value , size = 36 )
}
}
Ipn . State . Starting -> StartingView ( )
else ->
ConnectView (
user . value ,
{ viewModel . toggleVpn ( ) } ,
{ viewModel . login { } }
)
}
}
when ( state . value ) {
Ipn . State . Running -> {
val selfPeerId = viewModel . selfPeerId . collectAsState ( initial = " " )
ExitNodeStatus ( navAction = navigation . onNavigateToExitNodes , viewModel = viewModel )
PeerList (
searchTerm = viewModel . searchTerm ,
state = viewModel . ipnState ,
peers = viewModel . peers ,
selfPeer = selfPeerId . value ,
onNavigateToPeerDetails = navigation . onNavigateToPeerDetails ,
onSearch = { viewModel . searchPeers ( it ) } )
}
Ipn . State . Starting -> StartingView ( )
else -> ConnectView ( user . value , { viewModel . toggleVpn ( ) } , { viewModel . login { } } )
}
}
}
}
@Composable
fun ExitNodeStatus ( navAction : ( ) -> Unit , viewModel : MainViewModel ) {
val prefs = viewModel . prefs . collectAsState ( )
val netmap = viewModel . netmap . collectAsState ( )
val exitNodeId = prefs . value ?. ExitNodeID
val exitNode = exitNodeId ?. let { id ->
netmap . value ?. Peers ?. find { it . StableID == id } ?. let { peer ->
peer . Hostinfo . Location ?. let { location ->
val prefs = viewModel . prefs . collectAsState ( )
val netmap = viewModel . netmap . collectAsState ( )
val exitNodeId = prefs . value ?. ExitNodeID
val exitNode =
exitNodeId ?. let { id ->
netmap . value
?. Peers
?. find { it . StableID == id }
?. let { peer ->
peer . Hostinfo . Location ?. let { location ->
" ${location.Country?.flag()} ${location.Country} - ${location.City} "
} ?: peer . Name
}
}
Box ( modifier = Modifier
. clickable { navAction ( ) }
. padding ( horizontal = 8. dp )
. clip ( shape = RoundedCornerShape ( 10. dp , 10. dp , 10. dp , 10. dp ) )
. background ( MaterialTheme . colorScheme . secondaryContainer )
. fillMaxWidth ( ) ) {
} ?: peer . Name
}
}
Box (
modifier =
Modifier . clickable { navAction ( ) }
. padding ( horizontal = 8. dp )
. clip ( shape = RoundedCornerShape ( 10. dp , 10. dp , 10. dp , 10. dp ) )
. background ( MaterialTheme . colorScheme . secondaryContainer )
. fillMaxWidth ( ) ) {
Column ( modifier = Modifier . padding ( 6. dp ) ) {
Text (
text = stringResource ( id = R . string . exit _node ) ,
style = MaterialTheme . typography . titleMedium )
Row ( verticalAlignment = Alignment . CenterVertically ) {
Text (
text = stringResource ( id = R . string . exit _node ) ,
style = MaterialTheme . typography . titleMedium
text = exitNode ?: stringResource ( id = R . string . none ) ,
style = MaterialTheme . typography . bodyMedium )
Icon (
Icons . Outlined . ArrowDropDown ,
null ,
)
Row ( verticalAlignment = Alignment . CenterVertically ) {
Text ( text = exitNode
?: stringResource ( id = R . string . none ) , style = MaterialTheme . typography . bodyMedium )
Icon (
Icons . Outlined . ArrowDropDown ,
null ,
)
}
}
}
}
}
}
@Composable
fun StateDisplay ( state : StateFlow < Int > , tailnet : String ) {
val stateVal = state . collectAsState ( initial = R . string . placeholder )
val stateStr = stringResource ( id = stateVal . value )
Column ( modifier = Modifier . padding ( 7. dp ) ) {
when ( tailnet . isEmpty ( ) ) {
false -> {
Text ( text = tailnet , style = MaterialTheme . typography . titleMedium )
Text ( text = stateStr , style = MaterialTheme . typography . bodyMedium , color = MaterialTheme . colorScheme . secondary )
}
true -> {
Text ( text = stateStr , style = MaterialTheme . typography . bodyMedium , color = MaterialTheme . colorScheme . primary )
}
}
val stateVal = state . collectAsState ( initial = R . string . placeholder )
val stateStr = stringResource ( id = stateVal . value )
Column ( modifier = Modifier . padding ( 7. dp ) ) {
when ( tailnet . isEmpty ( ) ) {
false -> {
Text ( text = tailnet , style = MaterialTheme . typography . titleMedium )
Text (
text = stateStr ,
style = MaterialTheme . typography . bodyMedium ,
color = MaterialTheme . colorScheme . secondary )
}
true -> {
Text (
text = stateStr ,
style = MaterialTheme . typography . bodyMedium ,
color = MaterialTheme . colorScheme . primary )
}
}
}
}
@Composable
fun SettingsButton ( user : IpnLocal . LoginProfile ? , action : ( ) -> Unit ) {
// (jonathan) TODO: On iOS this is the users avatar or a letter avatar.
IconButton (
modifier = Modifier . size ( 24. dp ) ,
onClick = { action ( ) }
) {
Icon (
Icons . Outlined . Settings ,
null ,
)
}
// (jonathan) TODO: On iOS this is the users avatar or a letter avatar.
IconButton ( modifier = Modifier . size ( 24. dp ) , onClick = { action ( ) } ) {
Icon (
Icons . Outlined . Settings ,
null ,
)
}
}
@Composable
fun StartingView ( ) {
// (jonathan) TODO: On iOS this is the game-of-life Tailscale animation.
Column (
modifier = Modifier
. fillMaxSize ( )
. background ( MaterialTheme . colorScheme . background ) ,
verticalArrangement = Arrangement . Center ,
horizontalAlignment = Alignment . CenterHorizontally
) {
Text ( text = stringResource ( id = R . string . starting ) ,
style = MaterialTheme . typography . titleMedium ,
color = MaterialTheme . colorScheme . primary
)
}
// (jonathan) TODO: On iOS this is the game-of-life Tailscale animation.
Column (
modifier = Modifier . fillMaxSize ( ) . background ( MaterialTheme . colorScheme . background ) ,
verticalArrangement = Arrangement . Center ,
horizontalAlignment = Alignment . CenterHorizontally ) {
Text (
text = stringResource ( id = R . string . starting ) ,
style = MaterialTheme . typography . titleMedium ,
color = MaterialTheme . colorScheme . primary )
}
}
@Composable
fun ConnectView ( user : IpnLocal . LoginProfile ? , connectAction : ( ) -> Unit , loginAction : ( ) -> Unit ) {
Row ( horizontalArrangement = Arrangement . Center , modifier = Modifier . fillMaxWidth ( ) ) {
Column (
modifier = Modifier
. background ( MaterialTheme . colorScheme . secondaryContainer )
. padding ( 8. dp )
. fillMaxWidth ( 0.7f )
. fillMaxHeight ( ) ,
verticalArrangement = Arrangement . spacedBy (
8. dp ,
alignment = Alignment . CenterVertically
) ,
horizontalAlignment = Alignment . CenterHorizontally ,
) {
if ( user != null && ! user . isEmpty ( ) ) {
Icon (
painter = painterResource ( id = R . drawable . power ) ,
contentDescription = null ,
modifier = Modifier . size ( 48. dp ) ,
tint = MaterialTheme . colorScheme . secondary
)
Text (
text = stringResource ( id = R . string . not _connected ) ,
fontSize = MaterialTheme . typography . titleMedium . fontSize ,
fontWeight = FontWeight . SemiBold ,
color = MaterialTheme . colorScheme . primary ,
textAlign = TextAlign . Center ,
fontFamily = MaterialTheme . typography . titleMedium . fontFamily
)
val tailnetName = user . NetworkProfile ?. DomainName ?: " "
Text (
stringResource ( id = R . string . connect _to _tailnet , tailnetName ) ,
fontSize = MaterialTheme . typography . titleMedium . fontSize ,
fontWeight = FontWeight . Normal ,
color = MaterialTheme . colorScheme . secondary ,
textAlign = TextAlign . Center ,
)
Spacer ( modifier = Modifier . size ( 1. dp ) )
PrimaryActionButton ( onClick = connectAction ) {
Text (
text = stringResource ( id = R . string . connect ) ,
fontSize = MaterialTheme . typography . titleMedium . fontSize
)
}
} else {
TailscaleLogoView ( Modifier . size ( 50. dp ) )
Spacer ( modifier = Modifier . size ( 1. dp ) )
Text (
text = stringResource ( id = R . string . welcome _to _tailscale ) ,
style = MaterialTheme . typography . titleMedium ,
color = MaterialTheme . colorScheme . primary ,
textAlign = TextAlign . Center
)
Text (
stringResource ( R . string . login _to _join _your _tailnet ) ,
style = MaterialTheme . typography . titleSmall ,
color = MaterialTheme . colorScheme . secondary ,
textAlign = TextAlign . Center
)
Spacer ( modifier = Modifier . size ( 1. dp ) )
PrimaryActionButton ( onClick = loginAction ) {
Text (
text = stringResource ( id = R . string . log _in ) ,
fontSize = MaterialTheme . typography . titleMedium . fontSize
)
}
}
Row ( horizontalArrangement = Arrangement . Center , modifier = Modifier . fillMaxWidth ( ) ) {
Column (
modifier =
Modifier . background ( MaterialTheme . colorScheme . secondaryContainer )
. padding ( 8. dp )
. fillMaxWidth ( 0.7f )
. fillMaxHeight ( ) ,
verticalArrangement = Arrangement . spacedBy ( 8. dp , alignment = Alignment . CenterVertically ) ,
horizontalAlignment = Alignment . CenterHorizontally ,
) {
if ( user != null && ! user . isEmpty ( ) ) {
Icon (
painter = painterResource ( id = R . drawable . power ) ,
contentDescription = null ,
modifier = Modifier . size ( 48. dp ) ,
tint = MaterialTheme . colorScheme . secondary )
Text (
text = stringResource ( id = R . string . not _connected ) ,
fontSize = MaterialTheme . typography . titleMedium . fontSize ,
fontWeight = FontWeight . SemiBold ,
color = MaterialTheme . colorScheme . primary ,
textAlign = TextAlign . Center ,
fontFamily = MaterialTheme . typography . titleMedium . fontFamily )
val tailnetName = user . NetworkProfile ?. DomainName ?: " "
Text (
stringResource ( id = R . string . connect _to _tailnet , tailnetName ) ,
fontSize = MaterialTheme . typography . titleMedium . fontSize ,
fontWeight = FontWeight . Normal ,
color = MaterialTheme . colorScheme . secondary ,
textAlign = TextAlign . Center ,
)
Spacer ( modifier = Modifier . size ( 1. dp ) )
PrimaryActionButton ( onClick = connectAction ) {
Text (
text = stringResource ( id = R . string . connect ) ,
fontSize = MaterialTheme . typography . titleMedium . fontSize )
}
} else {
TailscaleLogoView ( Modifier . size ( 50. dp ) )
Spacer ( modifier = Modifier . size ( 1. dp ) )
Text (
text = stringResource ( id = R . string . welcome _to _tailscale ) ,
style = MaterialTheme . typography . titleMedium ,
color = MaterialTheme . colorScheme . primary ,
textAlign = TextAlign . Center )
Text (
stringResource ( R . string . login _to _join _your _tailnet ) ,
style = MaterialTheme . typography . titleSmall ,
color = MaterialTheme . colorScheme . secondary ,
textAlign = TextAlign . Center )
Spacer ( modifier = Modifier . size ( 1. dp ) )
PrimaryActionButton ( onClick = loginAction ) {
Text (
text = stringResource ( id = R . string . log _in ) ,
fontSize = MaterialTheme . typography . titleMedium . fontSize )
}
}
}
}
}
@Composable
fun ClearButton ( onClick : ( ) -> Unit ) {
IconButton ( onClick = onClick , modifier = Modifier . size ( 24. dp ) ) {
Icon ( Icons . Outlined . Clear , null )
}
IconButton ( onClick = onClick , modifier = Modifier . size ( 24. dp ) ) {
Icon ( Icons . Outlined . Clear , null )
}
}
@Composable
fun CloseButton ( ) {
val focusManager = LocalFocusManager . current
val focusManager = LocalFocusManager . current
IconButton ( onClick = { focusManager . clearFocus ( ) } , modifier = Modifier . size ( 24. dp ) ) {
Icon ( Icons . Outlined . Close , null )
}
IconButton ( onClick = { focusManager . clearFocus ( ) } , modifier = Modifier . size ( 24. dp ) ) {
Icon ( Icons . Outlined . Close , null )
}
}
@OptIn ( ExperimentalMaterial3Api :: class )
@Composable
fun PeerList (
searchTerm : StateFlow < String > ,
peers : StateFlow < List < PeerSet > > ,
state : StateFlow < Ipn . State > ,
selfPeer : StableNodeID ,
onNavigateToPeerDetails : ( Tailcfg . Node ) -> Unit ,
onSearch : ( String ) -> Unit
searchTerm : StateFlow < String > ,
peers : StateFlow < List < PeerSet > > ,
state : StateFlow < Ipn . State > ,
selfPeer : StableNodeID ,
onNavigateToPeerDetails : ( Tailcfg . Node ) -> Unit ,
onSearch : ( String ) -> Unit
) {
val peerList = peers . collectAsState ( initial = emptyList < PeerSet > ( ) )
val searchTermStr by searchTerm . collectAsState ( initial = " " )
val stateVal = state . collectAsState ( initial = Ipn . State . NoState )
SearchBar (
query = searchTermStr ,
onQueryChange = onSearch ,
onSearch = onSearch ,
active = true ,
onActiveChange = { } ,
shape = RoundedCornerShape ( 10. dp ) ,
leadingIcon = { Icon ( Icons . Outlined . Search , null ) } ,
trailingIcon = { if ( searchTermStr . isNotEmpty ( ) ) ClearButton ( { onSearch ( " " ) } ) else CloseButton ( ) } ,
tonalElevation = 2. dp ,
shadowElevation = 2. dp ,
colors = SearchBarDefaults . colors ( ) ,
modifier = Modifier . fillMaxWidth ( )
) {
val peerList = peers . collectAsState ( initial = emptyList < PeerSet > ( ) )
val searchTermStr by searchTerm . collectAsState ( initial = " " )
val stateVal = state . collectAsState ( initial = Ipn . State . NoState )
SearchBar (
query = searchTermStr ,
onQueryChange = onSearch ,
onSearch = onSearch ,
active = true ,
onActiveChange = { } ,
shape = RoundedCornerShape ( 10. dp ) ,
leadingIcon = { Icon ( Icons . Outlined . Search , null ) } ,
trailingIcon = {
if ( searchTermStr . isNotEmpty ( ) ) ClearButton ( { onSearch ( " " ) } ) else CloseButton ( )
} ,
tonalElevation = 2. dp ,
shadowElevation = 2. dp ,
colors = SearchBarDefaults . colors ( ) ,
modifier = Modifier . fillMaxWidth ( ) ) {
LazyColumn (
modifier =
Modifier
. fillMaxSize ( )
. background ( MaterialTheme . colorScheme . secondaryContainer ) ,
modifier =
Modifier . fillMaxSize ( ) . background ( MaterialTheme . colorScheme . secondaryContainer ) ,
) {
peerList . value . forEach { peerSet ->
item {
ListItem ( headlineContent = {
Text ( text = peerSet . user ?. DisplayName
?: stringResource ( id = R . string . unknown _user ) , style = MaterialTheme . typography . titleLarge )
} )
}
peerSet . peers . forEach { peer ->
item {
ListItem (
modifier = Modifier . clickable {
onNavigateToPeerDetails ( peer )
} ,
headlineContent = {
Row ( verticalAlignment = Alignment . CenterVertically ) {
// By definition, SelfPeer is online since we will not show the peer list unless you're connected.
val isSelfAndRunning = ( peer . StableID == selfPeer && stateVal . value == Ipn . State . Running )
val color : Color = if ( ( peer . Online == true ) || isSelfAndRunning ) {
ts _color _light _green
} else {
Color . Gray
}
Box ( modifier = Modifier
. size ( 8. dp )
. background ( color = color , shape = RoundedCornerShape ( percent = 50 ) ) ) { }
Spacer ( modifier = Modifier . size ( 8. dp ) )
Text ( text = peer . ComputedName , style = MaterialTheme . typography . titleMedium )
}
} ,
supportingContent = {
Text (
text = peer . Addresses ?. first ( ) ?. split ( " / " ) ?. first ( )
?: " " ,
style = MaterialTheme . typography . bodyMedium
)
} ,
trailingContent = {
Icon ( Icons . AutoMirrored . Filled . KeyboardArrowRight , null )
}
)
}
}
peerList . value . forEach { peerSet ->
item {
ListItem (
headlineContent = {
Text (
text =
peerSet . user ?. DisplayName ?: stringResource ( id = R . string . unknown _user ) ,
style = MaterialTheme . typography . titleLarge )
} )
}
peerSet . peers . forEach { peer ->
item {
ListItem (
modifier = Modifier . clickable { onNavigateToPeerDetails ( peer ) } ,
headlineContent = {
Row ( verticalAlignment = Alignment . CenterVertically ) {
// By definition, SelfPeer is online since we will not show the peer list
// unless you're connected.
val isSelfAndRunning =
( peer . StableID == selfPeer && stateVal . value == Ipn . State . Running )
val color : Color =
if ( ( peer . Online == true ) || isSelfAndRunning ) {
ts _color _light _green
} else {
Color . Gray
}
Box (
modifier =
Modifier . size ( 8. dp )
. background (
color = color , shape = RoundedCornerShape ( percent = 50 ) ) ) { }
Spacer ( modifier = Modifier . size ( 8. dp ) )
Text ( text = peer . ComputedName , style = MaterialTheme . typography . titleMedium )
}
} ,
supportingContent = {
Text (
text = peer . Addresses ?. first ( ) ?. split ( " / " ) ?. first ( ) ?: " " ,
style = MaterialTheme . typography . bodyMedium )
} ,
trailingContent = { Icon ( Icons . AutoMirrored . Filled . KeyboardArrowRight , null ) } )
}
}
}
}
}
}
}