@ -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,7 +59,6 @@ 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 ,
@ -68,20 +66,15 @@ data class MainViewNavigation(
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 ( ) ,
verticalArrangement = Arrangement . Center
) {
val state = viewModel . ipnState . collectAsState ( initial = Ipn . State . NoState )
val state = viewModel . ipnState . collectAsState ( initial = Ipn . State . NoState )
val user = viewModel . loggedInUser . collectAsState ( initial = null )
val user = viewModel . loggedInUser . collectAsState ( initial = null )
Row ( modifier = Modifier
Row (
. fillMaxWidth ( )
modifier = Modifier . fillMaxWidth ( ) . padding ( horizontal = 8. dp ) ,
. padding ( horizontal = 8. dp ) ,
verticalAlignment = Alignment . CenterVertically ) {
verticalAlignment = Alignment . CenterVertically ) {
val isOn = viewModel . vpnToggleState . collectAsState ( initial = false )
val isOn = viewModel . vpnToggleState . collectAsState ( initial = false )
if ( state . value != Ipn . State . NeedsLogin && state . value != Ipn . State . NoState ) {
if ( state . value != Ipn . State . NeedsLogin && state . value != Ipn . State . NoState ) {
@ -91,9 +84,9 @@ fun MainView(navigation: MainViewNavigation, viewModel: MainViewModel = viewMode
StateDisplay ( viewModel . stateRes , viewModel . userName )
StateDisplay ( viewModel . stateRes , viewModel . userName )
Box ( modifier = Modifier
Box (
. weight ( 1f )
modifier = Modifier . weight ( 1f ) . clickable { navigation . onNavigateToSettings ( ) } ,
. clickable { navigation . onNavigateToSettings ( ) } , contentAlignment = Alignment . CenterEnd ) {
contentAlignment = Alignment . CenterEnd ) {
when ( user . value ) {
when ( user . value ) {
null -> SettingsButton ( user . value ) { navigation . onNavigateToSettings ( ) }
null -> SettingsButton ( user . value ) { navigation . onNavigateToSettings ( ) }
else -> Avatar ( profile = user . value , size = 36 )
else -> Avatar ( profile = user . value , size = 36 )
@ -101,7 +94,6 @@ fun MainView(navigation: MainViewNavigation, viewModel: MainViewModel = viewMode
}
}
}
}
when ( state . value ) {
when ( state . value ) {
Ipn . State . Running -> {
Ipn . State . Running -> {
@ -115,14 +107,8 @@ fun MainView(navigation: MainViewNavigation, viewModel: MainViewModel = viewMode
onNavigateToPeerDetails = navigation . onNavigateToPeerDetails ,
onNavigateToPeerDetails = navigation . onNavigateToPeerDetails ,
onSearch = { viewModel . searchPeers ( it ) } )
onSearch = { viewModel . searchPeers ( it ) } )
}
}
Ipn . State . Starting -> StartingView ( )
Ipn . State . Starting -> StartingView ( )
else ->
else -> ConnectView ( user . value , { viewModel . toggleVpn ( ) } , { viewModel . login { } } )
ConnectView (
user . value ,
{ viewModel . toggleVpn ( ) } ,
{ viewModel . login { } }
)
}
}
}
}
}
}
@ -133,15 +119,20 @@ 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 ->
netmap . value
?. Peers
?. find { it . StableID == id }
?. let { peer ->
peer . Hostinfo . Location ?. let { location ->
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 =
Modifier . clickable { navAction ( ) }
. padding ( horizontal = 8. dp )
. padding ( horizontal = 8. dp )
. clip ( shape = RoundedCornerShape ( 10. dp , 10. dp , 10. dp , 10. dp ) )
. clip ( shape = RoundedCornerShape ( 10. dp , 10. dp , 10. dp , 10. dp ) )
. background ( MaterialTheme . colorScheme . secondaryContainer )
. background ( MaterialTheme . colorScheme . secondaryContainer )
@ -149,12 +140,11 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
Column ( modifier = Modifier . padding ( 6. dp ) ) {
Column ( modifier = Modifier . padding ( 6. dp ) ) {
Text (
Text (
text = stringResource ( id = R . string . exit _node ) ,
text = stringResource ( id = R . string . exit _node ) ,
style = MaterialTheme . typography . titleMedium
style = MaterialTheme . typography . titleMedium )
)
Row ( verticalAlignment = Alignment . CenterVertically ) {
Row ( verticalAlignment = Alignment . CenterVertically ) {
Text (
Text( text = exitNode
text = exitNode ?: stringResource ( id = R . string . none ) ,
?: stringResource ( id = R . string . none ) , style = MaterialTheme . typography . bodyMedium )
style = MaterialTheme . typography . bodyMedium )
Icon (
Icon (
Icons . Outlined . ArrowDropDown ,
Icons . Outlined . ArrowDropDown ,
null ,
null ,
@ -173,11 +163,16 @@ fun StateDisplay(state: StateFlow<Int>, tailnet: String) {
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 ,
color = MaterialTheme . colorScheme . secondary )
}
}
true -> {
true -> {
Text ( text = stateStr , style = MaterialTheme . typography . bodyMedium , color = MaterialTheme . colorScheme . primary )
Text (
text = stateStr ,
style = MaterialTheme . typography . bodyMedium ,
color = MaterialTheme . colorScheme . primary )
}
}
}
}
}
}
@ -187,10 +182,7 @@ fun StateDisplay(state: StateFlow<Int>, tailnet: String) {
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 ) ,
onClick = { action ( ) }
) {
Icon (
Icon (
Icons . Outlined . Settings ,
Icons . Outlined . Settings ,
null ,
null ,
@ -202,16 +194,13 @@ fun SettingsButton(user: IpnLocal.LoginProfile?, action: () -> Unit) {
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 ( )
. background ( MaterialTheme . colorScheme . background ) ,
verticalArrangement = Arrangement . Center ,
verticalArrangement = Arrangement . Center ,
horizontalAlignment = Alignment . CenterHorizontally
horizontalAlignment = Alignment . CenterHorizontally ) {
) {
Text (
Text ( text = stringResource ( id = R . string . starting ) ,
text = stringResource ( id = R . string . starting ) ,
style = MaterialTheme . typography . titleMedium ,
style = MaterialTheme . typography . titleMedium ,
color = MaterialTheme . colorScheme . primary
color = MaterialTheme . colorScheme . primary )
)
}
}
}
}
@ -219,15 +208,12 @@ fun StartingView() {
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 ,
alignment = Alignment . CenterVertically
) ,
horizontalAlignment = Alignment . CenterHorizontally ,
horizontalAlignment = Alignment . CenterHorizontally ,
) {
) {
if ( user != null && ! user . isEmpty ( ) ) {
if ( user != null && ! user . isEmpty ( ) ) {
@ -235,16 +221,14 @@ fun ConnectView(user: IpnLocal.LoginProfile?, connectAction: () -> Unit, loginAc
painter = painterResource ( id = R . drawable . power ) ,
painter = painterResource ( id = R . drawable . power ) ,
contentDescription = null ,
contentDescription = null ,
modifier = Modifier . size ( 48. dp ) ,
modifier = Modifier . size ( 48. dp ) ,
tint = MaterialTheme . colorScheme . secondary
tint = MaterialTheme . colorScheme . secondary )
)
Text (
Text (
text = stringResource ( id = R . string . not _connected ) ,
text = stringResource ( id = R . string . not _connected ) ,
fontSize = MaterialTheme . typography . titleMedium . fontSize ,
fontSize = MaterialTheme . typography . titleMedium . fontSize ,
fontWeight = FontWeight . SemiBold ,
fontWeight = FontWeight . SemiBold ,
color = MaterialTheme . colorScheme . primary ,
color = MaterialTheme . colorScheme . primary ,
textAlign = TextAlign . Center ,
textAlign = TextAlign . Center ,
fontFamily = MaterialTheme . typography . titleMedium . fontFamily
fontFamily = MaterialTheme . typography . titleMedium . fontFamily )
)
val tailnetName = user . NetworkProfile ?. DomainName ?: " "
val tailnetName = user . NetworkProfile ?. DomainName ?: " "
Text (
Text (
stringResource ( id = R . string . connect _to _tailnet , tailnetName ) ,
stringResource ( id = R . string . connect _to _tailnet , tailnetName ) ,
@ -257,8 +241,7 @@ fun ConnectView(user: IpnLocal.LoginProfile?, connectAction: () -> Unit, loginAc
PrimaryActionButton ( onClick = connectAction ) {
PrimaryActionButton ( onClick = connectAction ) {
Text (
Text (
text = stringResource ( id = R . string . connect ) ,
text = stringResource ( id = R . string . connect ) ,
fontSize = MaterialTheme . typography . titleMedium . fontSize
fontSize = MaterialTheme . typography . titleMedium . fontSize )
)
}
}
} else {
} else {
TailscaleLogoView ( Modifier . size ( 50. dp ) )
TailscaleLogoView ( Modifier . size ( 50. dp ) )
@ -267,20 +250,17 @@ fun ConnectView(user: IpnLocal.LoginProfile?, connectAction: () -> Unit, loginAc
text = stringResource ( id = R . string . welcome _to _tailscale ) ,
text = stringResource ( id = R . string . welcome _to _tailscale ) ,
style = MaterialTheme . typography . titleMedium ,
style = MaterialTheme . typography . titleMedium ,
color = MaterialTheme . colorScheme . primary ,
color = MaterialTheme . colorScheme . primary ,
textAlign = TextAlign . Center
textAlign = TextAlign . Center )
)
Text (
Text (
stringResource ( R . string . login _to _join _your _tailnet ) ,
stringResource ( R . string . login _to _join _your _tailnet ) ,
style = MaterialTheme . typography . titleSmall ,
style = MaterialTheme . typography . titleSmall ,
color = MaterialTheme . colorScheme . secondary ,
color = MaterialTheme . colorScheme . secondary ,
textAlign = TextAlign . Center
textAlign = TextAlign . Center )
)
Spacer ( modifier = Modifier . size ( 1. dp ) )
Spacer ( modifier = Modifier . size ( 1. dp ) )
PrimaryActionButton ( onClick = loginAction ) {
PrimaryActionButton ( onClick = loginAction ) {
Text (
Text (
text = stringResource ( id = R . string . log _in ) ,
text = stringResource ( id = R . string . log _in ) ,
fontSize = MaterialTheme . typography . titleMedium . fontSize
fontSize = MaterialTheme . typography . titleMedium . fontSize )
)
}
}
}
}
}
}
@ -325,62 +305,58 @@ fun PeerList(
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 = {
if ( searchTermStr . isNotEmpty ( ) ) ClearButton ( { onSearch ( " " ) } ) else CloseButton ( )
} ,
tonalElevation = 2. dp ,
tonalElevation = 2. dp ,
shadowElevation = 2. dp ,
shadowElevation = 2. dp ,
colors = SearchBarDefaults . colors ( ) ,
colors = SearchBarDefaults . colors ( ) ,
modifier = Modifier . fillMaxWidth ( )
modifier = Modifier . fillMaxWidth ( ) ) {
) {
LazyColumn (
LazyColumn (
modifier =
modifier =
Modifier
Modifier . fillMaxSize ( ) . background ( MaterialTheme . colorScheme . secondaryContainer ) ,
. 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 ->
peerSet . peers . forEach { peer ->
item {
item {
ListItem (
ListItem (
modifier = Modifier . clickable {
modifier = Modifier . clickable { onNavigateToPeerDetails ( peer ) } ,
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 unless you're connected.
// By definition, SelfPeer is online since we will not show the peer list
val isSelfAndRunning = ( peer . StableID == selfPeer && stateVal . value == Ipn . State . Running )
// unless you're connected.
val color : Color = if ( ( peer . Online == true ) || isSelfAndRunning ) {
val isSelfAndRunning =
( peer . StableID == selfPeer && stateVal . value == Ipn . State . Running )
val color : Color =
if ( ( peer . Online == true ) || isSelfAndRunning ) {
ts _color _light _green
ts _color _light _green
} else {
} else {
Color . Gray
Color . Gray
}
}
Box ( modifier = Modifier
Box (
. size ( 8. dp )
modifier =
. background ( color = color , shape = RoundedCornerShape ( percent = 50 ) ) ) { }
Modifier . size ( 8. dp )
. background (
color = color , shape = RoundedCornerShape ( percent = 50 ) ) ) { }
Spacer ( modifier = Modifier . size ( 8. dp ) )
Spacer ( modifier = Modifier . size ( 8. dp ) )
Text ( text = peer . ComputedName , style = MaterialTheme . typography . titleMedium )
Text ( text = peer . ComputedName , style = MaterialTheme . typography . titleMedium )
}
}
} ,
} ,
supportingContent = {
supportingContent = {
Text (
Text (
text = peer . Addresses ?. first ( ) ?. split ( " / " ) ?. first ( )
text = peer . Addresses ?. first ( ) ?. split ( " / " ) ?. first ( ) ?: " " ,
?: " " ,
style = MaterialTheme . typography . bodyMedium )
style = MaterialTheme . typography . bodyMedium
)
} ,
} ,
trailingContent = {
trailingContent = { Icon ( Icons . AutoMirrored . Filled . KeyboardArrowRight , null ) } )
Icon ( Icons . AutoMirrored . Filled . KeyboardArrowRight , null )
}
)
}
}
}
}
}
}