@ -3,8 +3,15 @@
package com.tailscale.ipn.ui.view
package com.tailscale.ipn.ui.view
import android.app.Activity
import android.os.Build
import android.util.Log
import android.window.OnBackInvokedCallback
import android.window.OnBackInvokedDispatcher
import androidx.annotation.RequiresApi
import androidx.compose.foundation.background
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Row
@ -13,9 +20,9 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
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.ArrowBack
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material.icons.filled.Clear
@ -23,11 +30,11 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
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.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SearchBar
import androidx.compose.material3.SearchBar
import androidx.compose.material3.Text
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.getValue
@ -39,110 +46,191 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui. graphics.Color
import androidx.compose.ui. platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import androidx.navigation.NavController
import com.tailscale.ipn.R
import com.tailscale.ipn.R
import com.tailscale.ipn.ui.theme.listItem
import com.tailscale.ipn.ui.util.Lists
import com.tailscale.ipn.ui.viewModel.MainViewModel
import com.tailscale.ipn.ui.viewModel.MainViewModel
import kotlinx.coroutines.delay
@RequiresApi ( Build . VERSION_CODES . TIRAMISU )
@OptIn ( ExperimentalMaterial3Api :: class )
@OptIn ( ExperimentalMaterial3Api :: class )
@Composable
@Composable
fun SearchView ( viewModel : MainViewModel , navController : NavController , onNavigateBack : ( ) -> Unit ) {
fun SearchView (
val searchTerm by viewModel . searchTerm . collectAsState ( )
viewModel : MainViewModel ,
val filteredPeers by viewModel . peers . collectAsState ( )
navController : NavController ,
onNavigateBack : ( ) -> Unit ,
autoFocus : Boolean // Pass true if coming from the main view, false otherwise.
) {
// Use TextFieldValue to preserve text and cursor position.
var searchFieldValue by
rememberSaveable ( stateSaver = TextFieldValue . Saver ) { mutableStateOf ( TextFieldValue ( " " ) ) }
val searchTerm = searchFieldValue . text
val filteredPeers by viewModel . searchViewPeers . collectAsState ( )
val netmap by viewModel . netmap . collectAsState ( )
val netmap by viewModel . netmap . collectAsState ( )
val keyboardController = LocalSoftwareKeyboardController . current
val keyboardController = LocalSoftwareKeyboardController . current
val focusRequester = remember { FocusRequester ( ) }
val focusRequester = remember { FocusRequester ( ) }
val focusManager = LocalFocusManager . current
val focusManager = LocalFocusManager . current
var expanded by rememberSaveable { mutableStateOf ( true ) }
var expanded by rememberSaveable { mutableStateOf ( true ) }
val context = LocalContext . current as Activity
val listState = rememberLazyListState ( )
val noResultsBackground =
if ( isSystemInDarkTheme ( ) ) {
MaterialTheme . colorScheme . surface // color for dark mode
} else {
MaterialTheme . colorScheme . surfaceContainer // color for light mode
}
LaunchedEffect ( Unit ) {
val callback = OnBackInvokedCallback {
focusRequester . requestFocus ( )
focusManager . clearFocus ( force = true )
keyboardController ?. show ( )
keyboardController ?. hide ( )
onNavigateBack ( )
viewModel . updateSearchTerm ( " " )
}
}
Column (
DisposableEffect ( Unit ) {
modifier =
val dispatcher = context . onBackInvokedDispatcher
Modifier . fillMaxWidth ( ) . focusRequester ( focusRequester ) . clickable {
dispatcher ?. registerOnBackInvokedCallback ( OnBackInvokedDispatcher . PRIORITY _DEFAULT , callback )
focusRequester . requestFocus ( )
onDispose { dispatcher ?. unregisterOnBackInvokedCallback ( callback ) }
keyboardController ?. show ( )
}
} ) {
SearchBar (
LaunchedEffect ( searchTerm , filteredPeers ) {
modifier = Modifier . fillMaxWidth ( ) ,
if ( searchTerm . isEmpty ( ) && filteredPeers . isNotEmpty ( ) ) {
query = searchTerm ,
delay ( 100 ) // Give Compose time to update list
onQueryChange = { query ->
listState . scrollToItem ( 0 )
viewModel . updateSearchTerm ( query )
}
expanded = query . isNotEmpty ( )
}
} ,
onSearch = { query ->
// Use the autoFocus parameter to decide if we request focus when entering.
viewModel . updateSearchTerm ( query )
LaunchedEffect ( autoFocus ) {
focusManager . clearFocus ( )
if ( autoFocus ) {
keyboardController ?. hide ( )
delay ( 300 ) // Delay to ensure UI is fully composed
} ,
focusRequester . requestFocus ( )
placeholder = { R . string . search } ,
keyboardController ?. show ( )
leadingIcon = {
}
}
Box ( modifier = Modifier . fillMaxSize ( ) ) {
Column ( modifier = Modifier . fillMaxWidth ( ) . focusRequester ( focusRequester ) ) {
SearchBar (
modifier = Modifier . fillMaxWidth ( ) ,
query = searchTerm ,
onQueryChange = { newQuery ->
// Create a new TextFieldValue with updated text and set cursor to the end.
searchFieldValue = TextFieldValue ( newQuery , selection = TextRange ( newQuery . length ) )
viewModel . updateSearchTerm ( newQuery )
expanded = true
} ,
onSearch = { newQuery ->
searchFieldValue = TextFieldValue ( newQuery , selection = TextRange ( newQuery . length ) )
viewModel . updateSearchTerm ( newQuery )
focusManager . clearFocus ( )
keyboardController ?. hide ( )
} ,
placeholder = { Text ( text = stringResource ( R . string . search ) ) } ,
leadingIcon = {
IconButton (
onClick = {
focusManager . clearFocus ( )
onNavigateBack ( )
viewModel . updateSearchTerm ( " " )
} ) {
Icon (
imageVector = Icons . Default . ArrowBack ,
contentDescription = stringResource ( R . string . search ) ,
tint = MaterialTheme . colorScheme . onSurfaceVariant )
}
} ,
trailingIcon = {
if ( searchTerm . isNotEmpty ( ) ) {
IconButton (
IconButton (
onClick = {
onClick = {
searchFieldValue = TextFieldValue ( " " , selection = TextRange ( 0 ) )
viewModel . updateSearchTerm ( " " )
focusManager . clearFocus ( )
focusManager . clearFocus ( )
onNavigateBack ( )
keyboardController?. hide ( )
} ) {
} ) {
Icon (
Icon (
imageVector = Icons . Default . ArrowBack ,
Icons . Default . Clear ,
contentDescription = stringResource ( R . string . search ) ,
contentDescription = stringResource ( R . string . clear _search ) )
tint = MaterialTheme . colorScheme . onSurfaceVariant )
}
}
} ,
}
trailingIcon = {
} ,
if ( searchTerm . isNotEmpty ( ) ) {
active = expanded ,
IconButton (
onActiveChange = { expanded = it } ,
onClick = {
content = {
viewModel . updateSearchTerm ( " " )
LazyColumn ( state = listState , modifier = Modifier . fillMaxSize ( ) ) {
focusManager . clearFocus ( )
if ( filteredPeers . isEmpty ( ) ) {
keyboardController ?. hide ( )
// When there are no filtered peers, show a "No results" message.
} ) {
item {
Icon ( Icons . Default . Clear , stringResource ( R . string . clear _search ) )
Box ( modifier = Modifier . fillMaxWidth ( ) . padding ( 16. dp ) ) {
}
Lists . LargeTitle (
}
stringResource ( id = R . string . no _results ) ,
} ,
bottomPadding = 8. dp ,
active = expanded ,
style = MaterialTheme . typography . bodyMedium ,
onActiveChange = { expanded = it } ,
fontWeight = FontWeight . Light ,
content = {
backgroundColor = noResultsBackground ,
Column ( Modifier . verticalScroll ( rememberScrollState ( ) ) . fillMaxSize ( ) ) {
fontColor = MaterialTheme . colorScheme . onSurfaceVariant )
}
}
} else {
var firstGroup = true
filteredPeers . forEach { peerSet ->
filteredPeers . forEach { peerSet ->
val userName = peerSet . user ?. DisplayName ?: " Unknown User "
if ( ! firstGroup ) {
peerSet . peers . forEach { peer ->
item { Lists . ItemDivider ( ) }
val deviceName = peer . displayName ?: " Unknown Device "
}
val ipAddress = peer . Addresses ?. firstOrNull ( ) ?. split ( " / " ) ?. first ( ) ?: " No IP "
firstGroup = false
ListItem (
val userName = peerSet . user ?. DisplayName ?: " Unknown User "
headlineContent = { Text ( userName ) } ,
peerSet . peers . forEachIndexed { index , peer ->
supportingContent = {
if ( index > 0 ) {
Column {
item ( key = " divider_ ${peer.StableID} " ) { Lists . ItemDivider ( ) }
Row ( verticalAlignment = Alignment . CenterVertically ) {
}
val onlineColor = peer . connectedColor ( netmap )
item ( key = " peer_ ${peer.StableID} " ) {
Box (
ListItem (
modifier =
colors = MaterialTheme . colorScheme . listItem ,
Modifier . size ( 10. dp )
headlineContent = {
. background ( onlineColor , shape = RoundedCornerShape ( 50 ) ) )
Column {
Spacer ( modifier = Modifier . size ( 8. dp ) )
Row ( verticalAlignment = Alignment . CenterVertically ) {
Text ( deviceName )
val onlineColor = peer . connectedColor ( netmap )
Box (
modifier =
Modifier . size ( 10. dp )
. background ( onlineColor , RoundedCornerShape ( 50 ) ) )
Spacer ( modifier = Modifier . size ( 8. dp ) )
Text ( peer . displayName ?: " Unknown Device " )
}
}
} ,
supportingContent = {
Column {
Text ( userName )
Text ( peer . Addresses ?. firstOrNull ( ) ?. split ( " / " ) ?. first ( ) ?: " No IP " )
}
}
Text ( ipAddress )
} ,
}
modifier =
} ,
Modifier . fillMaxWidth ( )
colors = ListItemDefaults . colors ( containerColor = Color . Transparent ) ,
. padding ( horizontal = 4. dp , vertical = 0. dp )
modifier =
. clickable {
Modifier . clickable {
viewModel . disableSearchAutoFocus ( )
navController . navigate ( " peerDetails/ ${peer.StableID} " )
navController . navigate ( " peerDetails/ ${peer.StableID} " )
}
} )
. fillMaxWidth ( )
}
. padding ( horizontal = 16. dp , vertical = 4. dp ) )
}
}
}
}
}
}
} )
}
}
} )
}
}
}
}