Skip to content

Commit 45ddef1

Browse files
authored
android: add SearchView (#584)
-Material3 search bar opens up search suggestions/results view under the bar, but we want it to open up a full page, so create SearchView and use placeholder search bar on MainView that navigates to SearchView -Tapping on suggestions/results should open up PeerDetails, so fix PeerDetails navigation to use backstack instead of always going back to Main view Next up: ensuring search filtering adheres to MDM requirements, and UI polish Updates tailscale/corp#18973 Signed-off-by: kari-ts <[email protected]>
1 parent bbe3270 commit 45ddef1

File tree

5 files changed

+205
-111
lines changed

5 files changed

+205
-111
lines changed

android/src/main/java/com/tailscale/ipn/MainActivity.kt

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ import com.tailscale.ipn.ui.view.MullvadInfoView
6666
import com.tailscale.ipn.ui.view.PeerDetails
6767
import com.tailscale.ipn.ui.view.PermissionsView
6868
import com.tailscale.ipn.ui.view.RunExitNodeView
69+
import com.tailscale.ipn.ui.view.SearchView
6970
import com.tailscale.ipn.ui.view.SettingsView
7071
import com.tailscale.ipn.ui.view.SplitTunnelAppPickerView
7172
import com.tailscale.ipn.ui.view.TailnetLockSetupView
@@ -174,7 +175,8 @@ class MainActivity : ComponentActivity() {
174175
navController.navigate("peerDetails/${it.StableID}")
175176
},
176177
onNavigateToExitNodes = { navController.navigate("exitNodes") },
177-
onNavigateToHealth = { navController.navigate("health") })
178+
onNavigateToHealth = { navController.navigate("health") },
179+
onNavigateToSearch = { navController.navigate("search") })
178180

179181
val settingsNav =
180182
SettingsNav(
@@ -214,6 +216,12 @@ class MainActivity : ComponentActivity() {
214216
composable("main", enterTransition = { fadeIn(animationSpec = tween(150)) }) {
215217
MainView(loginAtUrl = ::login, navigation = mainViewNav, viewModel = viewModel)
216218
}
219+
composable("search") {
220+
SearchView(
221+
viewModel = viewModel,
222+
navController = navController,
223+
onNavigateBack = { navController.popBackStack() })
224+
}
217225
composable("settings") { SettingsView(settingsNav) }
218226
composable("exitNodes") { ExitNodePicker(exitNodePickerNav) }
219227
composable("health") { HealthView(backTo("main")) }
@@ -231,7 +239,7 @@ class MainActivity : ComponentActivity() {
231239
"peerDetails/{nodeId}",
232240
arguments = listOf(navArgument("nodeId") { type = NavType.StringType })) {
233241
PeerDetails(
234-
backTo("main"),
242+
{ navController.popBackStack() },
235243
it.arguments?.getString("nodeId") ?: "",
236244
PingViewModel())
237245
}

android/src/main/java/com/tailscale/ipn/ui/view/MainView.kt

Lines changed: 42 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,10 @@ import androidx.compose.foundation.layout.height
2121
import androidx.compose.foundation.layout.padding
2222
import androidx.compose.foundation.layout.size
2323
import androidx.compose.foundation.layout.statusBars
24+
import androidx.compose.foundation.layout.width
2425
import androidx.compose.foundation.lazy.LazyColumn
25-
import androidx.compose.foundation.rememberScrollState
2626
import androidx.compose.foundation.shape.RoundedCornerShape
27-
import androidx.compose.foundation.verticalScroll
2827
import androidx.compose.material.icons.Icons
29-
import androidx.compose.material.icons.filled.Clear
3028
import androidx.compose.material.icons.filled.Search
3129
import androidx.compose.material.icons.outlined.ArrowDropDown
3230
import androidx.compose.material.icons.outlined.Lock
@@ -42,8 +40,6 @@ import androidx.compose.material3.ListItemDefaults
4240
import androidx.compose.material3.MaterialTheme
4341
import androidx.compose.material3.ModalBottomSheet
4442
import androidx.compose.material3.Scaffold
45-
import androidx.compose.material3.SearchBar
46-
import androidx.compose.material3.SearchBarDefaults
4743
import androidx.compose.material3.Text
4844
import androidx.compose.runtime.Composable
4945
import androidx.compose.runtime.LaunchedEffect
@@ -52,19 +48,14 @@ import androidx.compose.runtime.derivedStateOf
5248
import androidx.compose.runtime.getValue
5349
import androidx.compose.runtime.mutableStateOf
5450
import androidx.compose.runtime.remember
55-
import androidx.compose.runtime.saveable.rememberSaveable
5651
import androidx.compose.runtime.setValue
5752
import androidx.compose.ui.Alignment
5853
import androidx.compose.ui.Modifier
5954
import androidx.compose.ui.draw.alpha
6055
import androidx.compose.ui.draw.clip
61-
import androidx.compose.ui.focus.FocusRequester
62-
import androidx.compose.ui.focus.focusRequester
6356
import androidx.compose.ui.focus.onFocusChanged
6457
import androidx.compose.ui.graphics.Color
6558
import androidx.compose.ui.platform.LocalClipboardManager
66-
import androidx.compose.ui.platform.LocalFocusManager
67-
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
6859
import androidx.compose.ui.res.painterResource
6960
import androidx.compose.ui.res.stringResource
7061
import androidx.compose.ui.text.SpanStyle
@@ -113,7 +104,8 @@ data class MainViewNavigation(
113104
val onNavigateToSettings: () -> Unit,
114105
val onNavigateToPeerDetails: (Tailcfg.Node) -> Unit,
115106
val onNavigateToExitNodes: () -> Unit,
116-
val onNavigateToHealth: () -> Unit
107+
val onNavigateToHealth: () -> Unit,
108+
val onNavigateToSearch: () -> Unit,
117109
)
118110

119111
@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class)
@@ -195,7 +187,11 @@ fun MainView(
195187
when (user) {
196188
null -> SettingsButton { navigation.onNavigateToSettings() }
197189
else -> {
198-
Avatar(profile = user, size = 36, { navigation.onNavigateToSettings() }, isFocusable=true)
190+
Avatar(
191+
profile = user,
192+
size = 36,
193+
{ navigation.onNavigateToSettings() },
194+
isFocusable = true)
199195
}
200196
}
201197
}
@@ -220,7 +216,7 @@ fun MainView(
220216
PeerList(
221217
viewModel = viewModel,
222218
onNavigateToPeerDetails = navigation.onNavigateToPeerDetails,
223-
onSearch = { viewModel.searchPeers(it) })
219+
onSearchBarClick = navigation.onNavigateToSearch)
224220
}
225221
Ipn.State.NoState,
226222
Ipn.State.Starting -> StartingView()
@@ -523,24 +519,22 @@ fun ConnectView(
523519
fun PeerList(
524520
viewModel: MainViewModel,
525521
onNavigateToPeerDetails: (Tailcfg.Node) -> Unit,
526-
onSearch: (String) -> Unit
522+
onSearchBarClick: () -> Unit
527523
) {
528524
val peerList by viewModel.peers.collectAsState(initial = emptyList<PeerSet>())
529525
val searchTermStr by viewModel.searchTerm.collectAsState(initial = "")
530526
val showNoResults =
531527
remember { derivedStateOf { searchTermStr.isNotEmpty() && peerList.isEmpty() } }.value
532528

533529
val netmap = viewModel.netmap.collectAsState()
534-
val focusManager = LocalFocusManager.current
535-
var isFocussed by remember { mutableStateOf(false) }
536530
var isListFocussed by remember { mutableStateOf(false) }
537531
val expandedPeer = viewModel.expandedMenuPeer.collectAsState()
538532
val localClipboardManager = LocalClipboardManager.current
539533
val enableSearch = !isAndroidTV()
540534

541535
Column(modifier = Modifier.fillMaxSize()) {
542536
if (enableSearch) {
543-
SearchWithDynamicSuggestions(viewModel, onSearch)
537+
Search(onSearchBarClick)
544538

545539
Spacer(modifier = Modifier.height(if (showNoResults) 0.dp else 8.dp))
546540
}
@@ -701,98 +695,38 @@ fun PromptPermissionsIfNecessary() {
701695

702696
@OptIn(ExperimentalMaterial3Api::class)
703697
@Composable
704-
fun SearchWithDynamicSuggestions(viewModel: MainViewModel, onSearch: (String) -> Unit) {
705-
val searchTerm by viewModel.searchTerm.collectAsState()
706-
val filteredPeers by viewModel.peers.collectAsState()
707-
var expanded by rememberSaveable { mutableStateOf(false) }
708-
val netmap by viewModel.netmap.collectAsState()
709-
710-
val keyboardController = LocalSoftwareKeyboardController.current
711-
val focusRequester = remember { FocusRequester() }
712-
val focusManager = LocalFocusManager.current
698+
fun Search(
699+
onSearchBarClick: () -> Unit // Callback for navigating to SearchView
700+
) {
701+
// Prevent multiple taps
702+
var isNavigating by remember { mutableStateOf(false) }
713703

714-
Column(
704+
// Outer Box to handle clicks
705+
Box(
715706
modifier =
716-
Modifier.fillMaxWidth().focusRequester(focusRequester).clickable {
717-
focusRequester.requestFocus()
718-
keyboardController?.show()
719-
}) {
720-
SearchBar(
721-
modifier = Modifier.fillMaxWidth().align(Alignment.CenterHorizontally),
722-
inputField = {
723-
SearchBarDefaults.InputField(
724-
query = searchTerm,
725-
onQueryChange = { query ->
726-
viewModel.updateSearchTerm(query)
727-
onSearch(query)
728-
expanded = query.isNotEmpty()
729-
},
730-
onSearch = { query ->
731-
viewModel.updateSearchTerm(query)
732-
onSearch(query)
733-
expanded = false
734-
},
735-
expanded = expanded,
736-
onExpandedChange = { expanded = it },
737-
placeholder = { Text("Search") },
738-
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
739-
trailingIcon = {
740-
if (expanded) {
741-
IconButton(
742-
onClick = {
743-
viewModel.updateSearchTerm("")
744-
onSearch("")
745-
expanded = false
746-
focusManager.clearFocus()
747-
keyboardController?.hide()
748-
}) {
749-
Icon(Icons.Default.Clear, contentDescription = "Clear search")
750-
}
751-
}
752-
})
753-
},
754-
expanded = expanded,
755-
onExpandedChange = { expanded = it },
756-
content = {
757-
// Search results or suggestions
758-
Column(Modifier.verticalScroll(rememberScrollState()).fillMaxSize()) {
759-
filteredPeers.forEach { peerSet ->
760-
val userName = peerSet.user?.DisplayName ?: "Unknown User"
761-
peerSet.peers.forEach { peer ->
762-
val deviceName = peer.displayName ?: "Unknown Device"
763-
val ipAddress = peer.Addresses?.firstOrNull()?.split("/")?.first() ?: "No IP"
764-
765-
ListItem(
766-
headlineContent = { Text(userName) },
767-
supportingContent = {
768-
Column {
769-
Row(verticalAlignment = Alignment.CenterVertically) {
770-
val onlineColor = peer.connectedColor(netmap)
771-
Box(
772-
modifier =
773-
Modifier.size(10.dp)
774-
.background(onlineColor, shape = RoundedCornerShape(50)))
775-
Spacer(modifier = Modifier.size(8.dp))
776-
Text(deviceName)
777-
}
778-
Text(ipAddress)
779-
}
780-
},
781-
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
782-
modifier =
783-
Modifier.clickable {
784-
viewModel.updateSearchTerm(userName)
785-
onSearch(userName)
786-
expanded = false
787-
focusManager.clearFocus()
788-
keyboardController?.hide()
789-
}
790-
.fillMaxWidth()
791-
.padding(horizontal = 16.dp, vertical = 4.dp))
792-
}
793-
}
707+
Modifier.fillMaxWidth()
708+
.height(56.dp)
709+
.clip(RoundedCornerShape(28.dp))
710+
.background(MaterialTheme.colorScheme.surface)
711+
.clickable(enabled = !isNavigating) { // Intercept taps
712+
isNavigating = true
713+
onSearchBarClick() // Trigger navigation
794714
}
795-
})
715+
.padding(horizontal = 16.dp)) {
716+
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize()) {
717+
Icon(
718+
imageVector = Icons.Default.Search,
719+
contentDescription = stringResource(R.string.search),
720+
tint = MaterialTheme.colorScheme.onSurfaceVariant,
721+
modifier = Modifier.padding(start = 16.dp))
722+
Spacer(modifier = Modifier.width(8.dp))
723+
// Placeholder Text
724+
Text(
725+
text = stringResource(R.string.search_ellipsis),
726+
style = MaterialTheme.typography.bodyMedium,
727+
color = MaterialTheme.colorScheme.onSurfaceVariant,
728+
modifier = Modifier.weight(1f))
729+
}
796730
}
797731
}
798732

@@ -808,6 +742,7 @@ fun MainViewPreview() {
808742
onNavigateToSettings = {},
809743
onNavigateToPeerDetails = {},
810744
onNavigateToExitNodes = {},
811-
onNavigateToHealth = {}),
745+
onNavigateToHealth = {},
746+
onNavigateToSearch = {}),
812747
vm)
813748
}

android/src/main/java/com/tailscale/ipn/ui/view/PeerDetails.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ import com.tailscale.ipn.ui.viewModel.PingViewModel
4747
@OptIn(ExperimentalMaterial3Api::class)
4848
@Composable
4949
fun PeerDetails(
50-
backToHome: BackNavigation,
50+
onNavigateBack: () -> Unit,
5151
nodeId: String,
5252
pingViewModel: PingViewModel,
5353
model: PeerDetailsViewModel =
@@ -90,7 +90,7 @@ fun PeerDetails(
9090
contentDescription = "Ping device")
9191
}
9292
},
93-
onBack = backToHome)
93+
onBack = onNavigateBack)
9494
},
9595
) { innerPadding ->
9696
LazyColumn(

0 commit comments

Comments
 (0)