@ -21,6 +21,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.outlined.ArrowDropDown
import androidx.compose.material.icons.outlined.Clear
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.ExperimentalMaterial3Api
@ -40,6 +42,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@ -53,7 +56,6 @@ import com.tailscale.ipn.ui.model.StableNodeID
import com.tailscale.ipn.ui.model.Tailcfg
import com.tailscale.ipn.ui.theme.ts_color_light_green
import com.tailscale.ipn.ui.util.PeerSet
import com.tailscale.ipn.ui.util.PrimaryActionButton
import com.tailscale.ipn.ui.util.flag
import com.tailscale.ipn.ui.viewModel.MainViewModel
import kotlinx.coroutines.flow.StateFlow
@ -68,50 +70,58 @@ data class MainViewNavigation(
@Composable
fun MainView ( navigation : MainViewNavigation , m odel: MainViewModel = viewModel ( ) ) {
fun MainView ( navigation : MainViewNavigation , viewM odel: MainViewModel = viewModel ( ) ) {
Surface ( color = MaterialTheme . colorScheme . secondaryContainer ) {
Column (
modifier = Modifier . fillMaxWidth ( ) , verticalArrangement = Arrangement . Center
modifier = Modifier . fillMaxWidth ( ) ,
verticalArrangement = Arrangement . Center
) {
val state = m odel. ipnState . collectAsState ( initial = Ipn . State . NoState )
val user = m odel. loggedInUser . collectAsState ( initial = null )
val state = viewM odel. ipnState . collectAsState ( initial = Ipn . State . NoState )
val user = viewM odel. loggedInUser . collectAsState ( initial = null )
Row (
modifier = Modifier
Row ( modifier = Modifier
. fillMaxWidth ( )
. padding ( horizontal = 8. dp ) ,
verticalAlignment = Alignment . CenterVertically
) {
val isOn = model . vpnToggleState . collectAsState ( initial = false )
Switch ( onCheckedChange = { model . toggleVpn ( ) } , checked = isOn . value )
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 ( model . stateRes , model . userName )
Box (
modifier = Modifier
}
StateDisplay ( viewModel . stateRes , viewModel . userName )
Box ( modifier = Modifier
. weight ( 1f )
. clickable { navigation . onNavigateToSettings ( ) } ,
contentAlignment = Alignment . CenterEnd
) {
Avatar ( profile = user . value , size = 36 )
. clickable { navigation . onNavigateToSettings ( ) } , contentAlignment = Alignment . CenterEnd ) {
when ( user . value ) {
null -> SettingsButton ( user . value ) { navigation . onNavigateToSettings ( ) }
else -> Avatar ( profile = user . value , size = 36 )
}
}
}
when ( state . value ) {
Ipn . State . Running -> {
ExitNodeStatus ( navigation . onNavigateToExitNodes , model )
PeerList ( searchTerm = model . searchTerm ,
state = model . ipnState ,
peers = model . peers ,
selfPeer = model . selfPeerId ,
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 = { model . searchPeers ( it ) } )
onSearch = { viewM odel. searchPeers ( it ) } )
}
Ipn . State . Starting -> StartingView ( )
else -> ConnectView ( user . value , { model . toggleVpn ( ) } , { model . login ( ) }
else ->
ConnectView (
user . value ,
{ viewModel . toggleVpn ( ) } ,
{ viewModel . login { } }
)
}
}
@ -142,10 +152,9 @@ fun ExitNodeStatus(navAction: () -> Unit, viewModel: MainViewModel) {
style = MaterialTheme . typography . titleMedium
)
Row ( verticalAlignment = Alignment . CenterVertically ) {
Text (
text = exitNode ?: stringResource ( id = R . string . none ) ,
style = MaterialTheme . typography . bodyMedium
)
Text ( text = exitNode
?: stringResource ( id = R . string . none ) , style = MaterialTheme . typography . bodyMedium )
Icon (
Icons . Outlined . ArrowDropDown ,
null ,
@ -161,19 +170,27 @@ fun StateDisplay(state: StateFlow<Int>, tailnet: String) {
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
)
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 ( ) } ) {
IconButton (
modifier = Modifier . size ( 24. dp ) ,
onClick = { action ( ) }
) {
Icon (
Icons . Outlined . Settings ,
null ,
@ -191,8 +208,7 @@ fun StartingView() {
verticalArrangement = Arrangement . Center ,
horizontalAlignment = Alignment . CenterHorizontally
) {
Text (
text = stringResource ( id = R . string . starting ) ,
Text ( text = stringResource ( id = R . string . starting ) ,
style = MaterialTheme . typography . titleMedium ,
color = MaterialTheme . colorScheme . primary
)
@ -209,7 +225,8 @@ fun ConnectView(user: IpnLocal.LoginProfile?, connectAction: () -> Unit, loginAc
. fillMaxWidth ( 0.7f )
. fillMaxHeight ( ) ,
verticalArrangement = Arrangement . spacedBy (
8. dp , alignment = Alignment . CenterVertically
8. dp ,
alignment = Alignment . CenterVertically
) ,
horizontalAlignment = Alignment . CenterHorizontally ,
) {
@ -270,6 +287,22 @@ fun ConnectView(user: IpnLocal.LoginProfile?, connectAction: () -> Unit, loginAc
}
}
@Composable
fun ClearButton ( onClick : ( ) -> Unit ) {
IconButton ( onClick = onClick , modifier = Modifier . size ( 24. dp ) ) {
Icon ( Icons . Outlined . Clear , null )
}
}
@Composable
fun CloseButton ( ) {
val focusManager = LocalFocusManager . current
IconButton ( onClick = { focusManager . clearFocus ( ) } , modifier = Modifier . size ( 24. dp ) ) {
Icon ( Icons . Outlined . Close , null )
}
}
@OptIn ( ExperimentalMaterial3Api :: class )
@Composable
fun PeerList (
@ -281,7 +314,6 @@ fun PeerList(
onSearch : ( String ) -> Unit
) {
val peerList = peers . collectAsState ( initial = emptyList < PeerSet > ( ) )
var searching = false
val searchTermStr by searchTerm . collectAsState ( initial = " " )
val stateVal = state . collectAsState ( initial = Ipn . State . NoState )
@ -290,9 +322,10 @@ fun PeerList(
onQueryChange = onSearch ,
onSearch = onSearch ,
active = true ,
onActiveChange = { searching = it } ,
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 ( ) ,
@ -300,55 +333,54 @@ fun PeerList(
) {
LazyColumn (
modifier = Modifier
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
)
Text ( text = peerSet . user ?. DisplayName
?: stringResource ( id = R . string . unknown _user ) , style = MaterialTheme . typography . titleLarge )
} )
}
peerSet . peers . forEach { peer ->
item {
ListItem ( modifier = Modifier . clickable {
ListItem (
modifier = Modifier . clickable {
onNavigateToPeerDetails ( peer )
} , headlineContent = {
} ,
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 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
Box ( modifier = Modifier
. size ( 8. dp )
. background (
color = color , shape = RoundedCornerShape ( percent = 50 )
)
) { }
. background ( color = color , shape = RoundedCornerShape ( percent = 50 ) ) ) { }
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 = peer . Addresses ?. first ( ) ?. split ( " / " ) ?. first ( ) ?: " " ,
text = peer . Addresses ?. first ( ) ?. split ( " / " ) ?. first ( )
?: " " ,
style = MaterialTheme . typography . bodyMedium
)
} , trailingContent = {
} ,
trailingContent = {
Icon ( Icons . AutoMirrored . Filled . KeyboardArrowRight , null )
} )
}
)
}
}
}