@ -22,13 +22,14 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.outlined.ArrowDropDown
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.Lock
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.Button
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenu
@ -40,25 +41,23 @@ import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SearchBar
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.*
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.SpanStyle
@ -86,7 +85,6 @@ import com.tailscale.ipn.ui.theme.exitNodeToggleButton
import com.tailscale.ipn.ui.theme.listItem
import com.tailscale.ipn.ui.theme.listItem
import com.tailscale.ipn.ui.theme.minTextSize
import com.tailscale.ipn.ui.theme.minTextSize
import com.tailscale.ipn.ui.theme.primaryListItem
import com.tailscale.ipn.ui.theme.primaryListItem
import com.tailscale.ipn.ui.theme.searchBarColors
import com.tailscale.ipn.ui.theme.secondaryButton
import com.tailscale.ipn.ui.theme.secondaryButton
import com.tailscale.ipn.ui.theme.short
import com.tailscale.ipn.ui.theme.short
import com.tailscale.ipn.ui.theme.surfaceContainerListItem
import com.tailscale.ipn.ui.theme.surfaceContainerListItem
@ -526,154 +524,119 @@ fun PeerList(
remember { derivedStateOf { searchTermStr . isNotEmpty ( ) && peerList . isEmpty ( ) } } . value
remember { derivedStateOf { searchTermStr . isNotEmpty ( ) && peerList . isEmpty ( ) } } . value
val netmap = viewModel . netmap . collectAsState ( )
val netmap = viewModel . netmap . collectAsState ( )
val focusManager = LocalFocusManager . current
val focusManager = LocalFocusManager . current
var isFocussed by remember { mutableStateOf ( false ) }
var isFocussed by remember { mutableStateOf ( false ) }
var isListFocussed by remember { mutableStateOf ( false ) }
var isListFocussed by remember { mutableStateOf ( false ) }
val expandedPeer = viewModel . expandedMenuPeer . collectAsState ( )
val expandedPeer = viewModel . expandedMenuPeer . collectAsState ( )
val localClipboardManager = LocalClipboardManager . current
val localClipboardManager = LocalClipboardManager . current
val enableSearch = !is AndroidTV ( )
val enableSearch = !is AndroidTV ( )
if ( enableSearch ) {
Column ( modifier = Modifier . fillMaxSize ( ) ) {
Box ( modifier = Modifier . fillMaxWidth ( ) . background ( color = MaterialTheme . colorScheme . surface ) ) {
if ( enableSearch ) {
OutlinedTextField (
SearchWithDynamicSuggestions ( viewModel , onSearch )
modifier =
Modifier . fillMaxWidth ( )
Spacer ( modifier = Modifier . height ( if ( showNoResults ) 0. dp else 8. dp ) )
. padding ( start = 16. dp , end = 16. dp , top = 16. dp , bottom = 0. dp )
. onFocusChanged { isFocussed = it . isFocused } ,
singleLine = true ,
shape = MaterialTheme . shapes . extraLarge ,
colors = MaterialTheme . colorScheme . searchBarColors ,
leadingIcon = {
Icon ( imageVector = Icons . Outlined . Search , contentDescription = " search " )
} ,
trailingIcon = {
if ( isFocussed ) {
IconButton (
onClick = {
focusManager . clearFocus ( )
onSearch ( " " )
} ) {
Icon (
imageVector =
if ( searchTermStr . isEmpty ( ) ) Icons . Outlined . Close
else Icons . Outlined . Clear ,
contentDescription = " clear search " ,
tint = MaterialTheme . colorScheme . onSurfaceVariant )
}
}
} ,
placeholder = {
Text (
text = stringResource ( id = R . string . search ) ,
style = MaterialTheme . typography . bodyLarge ,
maxLines = 1 )
} ,
value = searchTermStr ,
onValueChange = { onSearch ( it ) } )
}
}
}
LazyColumn (
// Peers display
modifier =
LazyColumn (
Modifier . fillMaxSize ( )
modifier =
. onFocusChanged { isListFocussed = it . isFocused }
Modifier . fillMaxWidth ( )
. background ( color = MaterialTheme . colorScheme . surface ) ) {
. weight ( 1f ) // LazyColumn gets the remaining vertical space
if ( showNoResults ) {
. onFocusChanged { isListFocussed = it . isFocused }
item {
. background ( color = MaterialTheme . colorScheme . surface ) ) {
Spacer (
Modifier . height ( 16. dp )
// Handle case when no results are found
. fillMaxSize ( )
if ( showNoResults ) {
. focusable ( false )
item {
. background ( color = MaterialTheme . colorScheme . surface ) )
Spacer (
Modifier . height ( 16. dp )
Lists . LargeTitle (
. fillMaxSize ( )
stringResource ( id = R . string . no _results ) ,
. focusable ( false )
bottomPadding = 8. dp ,
. background ( color = MaterialTheme . colorScheme . surface ) )
style = MaterialTheme . typography . bodyMedium ,
Lists . LargeTitle (
fontWeight = FontWeight . Light )
stringResource ( id = R . string . no _results ) ,
bottomPadding = 8. dp ,
style = MaterialTheme . typography . bodyMedium ,
fontWeight = FontWeight . Light )
}
}
}
}
var first = true
// Iterate over peer sets to display them
peerList . forEach { peerSet ->
var first = true
if ( ! first ) {
peerList . forEach { peerSet ->
item ( key = " user_divider_ ${peerSet.user?.ID ?: 0L} " ) { Lists . ItemDivider ( ) }
if ( ! first ) {
}
item ( key = " user_divider_ ${peerSet.user?.ID ?: 0L} " ) { Lists . ItemDivider ( ) }
first = false
}
first = false
// Sticky headers are a bit broken on Android TV - they hide their content
if ( isAndroidTV ( ) ) {
if ( isAndroidTV ( ) ) {
item { NodesSectionHeader ( peerSet = peerSet ) }
item { NodesSectionHeader ( peerSet = peerSet ) }
} else {
} else {
stickyHeader { NodesSectionHeader ( peerSet = peerSet ) }
stickyHeader { NodesSectionHeader ( peerSet = peerSet ) }
}
}
itemsWithDividers ( peerSet . peers , key = { it . StableID } ) { peer ->
itemsWithDividers ( peerSet . peers , key = { it . StableID } ) { peer ->
ListItem (
ListItem (
modifier =
modifier =
Modifier . combinedClickable (
Modifier . combinedClickable (
onClick = { onNavigateToPeerDetails ( peer ) } ,
onClick = { onNavigateToPeerDetails ( peer ) } ,
onLongClick = { viewModel . expandedMenuPeer . set ( peer ) } ) ,
onLongClick = { viewModel . expandedMenuPeer . set ( peer ) } ) ,
colors = MaterialTheme . colorScheme . listItem ,
colors = MaterialTheme . colorScheme . listItem ,
headlineContent = {
headlineContent = {
Row ( verticalAlignment = Alignment . CenterVertically ) {
Row ( verticalAlignment = Alignment . CenterVertically ) {
Box (
Box (
modifier =
modifier =
Modifier . padding ( top = 2. dp )
Modifier . padding ( top = 2. dp )
. size ( 10. dp )
. size ( 10. dp )
. background (
. background (
color = peer . connectedColor ( netmap . value ) ,
color = peer . connectedColor ( netmap . value ) ,
shape = RoundedCornerShape ( percent = 50 ) ) ) { }
shape = RoundedCornerShape ( percent = 50 ) ) ) { }
Spacer ( modifier = Modifier . size ( 8. dp ) )
Spacer ( modifier = Modifier . size ( 8. dp ) )
Text ( text = peer . displayName , style = MaterialTheme . typography . titleMedium )
Text ( text = peer . displayName , style = MaterialTheme . typography . titleMedium )
DropdownMenu (
DropdownMenu (
expanded = expandedPeer . value ?. StableID == peer . StableID ,
expanded = expandedPeer . value ?. StableID == peer . StableID ,
onDismissRequest = { viewModel . hidePeerDropdownMenu ( ) } ) {
onDismissRequest = { viewModel . hidePeerDropdownMenu ( ) } ) {
DropdownMenuItem (
DropdownMenuItem (
leadingIcon = {
leadingIcon = {
Icon (
Icon (
painter = painterResource ( R . drawable . clipboard ) ,
painter = painterResource ( R . drawable . clipboard ) ,
contentDescription = null )
contentDescription = null )
} ,
} ,
text = { Text ( text = stringResource ( R . string . copy _ip _address ) ) } ,
text = { Text ( text = stringResource ( R . string . copy _ip _address ) ) } ,
onClick = {
onClick = {
viewModel . copyIpAddress ( peer , localClipboardManager )
viewModel . copyIpAddress ( peer , localClipboardManager )
viewModel . hidePeerDropdownMenu ( )
viewModel . hidePeerDropdownMenu ( )
} )
} )
netmap . value ?. let { netMap ->
netmap . value ?. let { netMap ->
if ( ! peer . isSelfNode ( netMap ) ) {
if ( ! peer . isSelfNode ( netMap ) ) {
DropdownMenuItem (
// Don't show the ping item for the self-node
leadingIcon = {
DropdownMenuItem (
Icon (
leadingIcon = {
painter = painterResource ( R . drawable . timer ) ,
Icon (
contentDescription = null )
painter = painterResource ( R . drawable . timer ) ,
} ,
contentDescription = null )
text = { Text ( text = stringResource ( R . string . ping ) ) } ,
} ,
onClick = {
text = { Text ( text = stringResource ( R . string . ping ) ) } ,
viewModel . hidePeerDropdownMenu ( )
onClick = {
viewModel . startPing ( peer )
viewModel . hidePeerDropdownMenu ( )
} )
viewModel . startPing ( peer )
}
} )
}
}
}
}
}
}
}
} ,
} ,
supportingContent = {
supportingContent = {
Text (
Text (
text = peer . Addresses ?. first ( ) ?. split ( " / " ) ?. first ( ) ?: " " ,
text = peer . Addresses ?. first ( ) ?. split ( " / " ) ?. first ( ) ?: " " ,
style =
style =
MaterialTheme . typography . bodyMedium . copy (
MaterialTheme . typography . bodyMedium. copy (
lineHeight = MaterialTheme . typography . titleMedium. lineHeight ) )
lineHeight = MaterialTheme . typography . titleMedium . lineHeight ) )
} )
} )
}
}
}
}
}
}
}
}
}
@Composable
@Composable
@ -729,6 +692,103 @@ fun PromptPermissionsIfNecessary() {
}
}
}
}
@OptIn ( ExperimentalMaterial3Api :: class )
@Composable
fun SearchWithDynamicSuggestions ( viewModel : MainViewModel , onSearch : ( String ) -> Unit ) {
val searchTerm by viewModel . searchTerm . collectAsState ( )
val filteredPeers by viewModel . peers . collectAsState ( )
var expanded by rememberSaveable { mutableStateOf ( false ) }
val netmap by viewModel . netmap . collectAsState ( )
val keyboardController = LocalSoftwareKeyboardController . current
val focusRequester = remember { FocusRequester ( ) }
val focusManager = LocalFocusManager . current
Column (
modifier =
Modifier . fillMaxWidth ( ) . focusRequester ( focusRequester ) . clickable {
focusRequester . requestFocus ( )
keyboardController ?. show ( )
} ) {
SearchBar (
modifier = Modifier . fillMaxWidth ( ) . align ( Alignment . CenterHorizontally ) ,
inputField = {
SearchBarDefaults . InputField (
query = searchTerm ,
onQueryChange = { query ->
viewModel . updateSearchTerm ( query )
onSearch ( query )
expanded = query . isNotEmpty ( )
} ,
onSearch = { query ->
viewModel . updateSearchTerm ( query )
onSearch ( query )
expanded = false
} ,
expanded = expanded ,
onExpandedChange = { expanded = it } ,
placeholder = { Text ( " Search " ) } ,
leadingIcon = { Icon ( Icons . Default . Search , contentDescription = null ) } ,
trailingIcon = {
if ( expanded ) {
IconButton (
onClick = {
viewModel . updateSearchTerm ( " " )
onSearch ( " " )
expanded = false
focusManager . clearFocus ( )
keyboardController ?. hide ( )
} ) {
Icon ( Icons . Default . Clear , contentDescription = " Clear search " )
}
}
} )
} ,
expanded = expanded ,
onExpandedChange = { expanded = it } ,
content = {
// Search results or suggestions
Column ( Modifier . verticalScroll ( rememberScrollState ( ) ) . fillMaxSize ( ) ) {
filteredPeers . forEach { peerSet ->
val userName = peerSet . user ?. DisplayName ?: " Unknown User "
peerSet . peers . forEach { peer ->
val deviceName = peer . displayName ?: " Unknown Device "
val ipAddress = peer . Addresses ?. firstOrNull ( ) ?. split ( " / " ) ?. first ( ) ?: " No IP "
ListItem (
headlineContent = { Text ( userName ) } ,
supportingContent = {
Column {
Row ( verticalAlignment = Alignment . CenterVertically ) {
val onlineColor = peer . connectedColor ( netmap )
Box (
modifier =
Modifier . size ( 10. dp )
. background ( onlineColor , shape = RoundedCornerShape ( 50 ) ) )
Spacer ( modifier = Modifier . size ( 8. dp ) )
Text ( deviceName )
}
Text ( ipAddress )
}
} ,
colors = ListItemDefaults . colors ( containerColor = Color . Transparent ) ,
modifier =
Modifier . clickable {
viewModel . updateSearchTerm ( userName )
onSearch ( userName )
expanded = false
focusManager . clearFocus ( )
keyboardController ?. hide ( )
}
. fillMaxWidth ( )
. padding ( horizontal = 16. dp , vertical = 4. dp ) )
}
}
}
} )
}
}
@Preview
@Preview
@Composable
@Composable
fun MainViewPreview ( ) {
fun MainViewPreview ( ) {