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