Skip to content

Commit da4b3e7

Browse files
committed
android: add search bar to split-tunnel app picker
Adds a search/filter bar at the top of the split-tunnel app selection list. The search matches against both app display names and package names (case-insensitive). Typing a query filters the list in real-time; a clear button (✕) appears when the field is non-empty. Existing strings (search, search_ellipsis, clear_search) are reused. Updates tailscale/tailscale#13816 Signed-off-by: fengzzyun <fengzzyun@outlook.com>
1 parent 92b1afb commit da4b3e7

2 files changed

Lines changed: 52 additions & 1 deletion

File tree

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

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,16 @@ import androidx.compose.foundation.background
88
import androidx.compose.foundation.layout.Box
99
import androidx.compose.foundation.layout.Row
1010
import androidx.compose.foundation.layout.fillMaxSize
11+
import androidx.compose.foundation.layout.fillMaxWidth
1112
import androidx.compose.foundation.layout.height
1213
import androidx.compose.foundation.layout.padding
1314
import androidx.compose.foundation.layout.width
1415
import androidx.compose.foundation.lazy.LazyColumn
1516
import androidx.compose.foundation.lazy.items
1617
import androidx.compose.material.icons.Icons
18+
import androidx.compose.material.icons.filled.Close
1719
import androidx.compose.material.icons.filled.MoreVert
20+
import androidx.compose.material.icons.filled.Search
1821
import androidx.compose.material3.AlertDialog
1922
import androidx.compose.material3.Checkbox
2023
import androidx.compose.material3.CircularProgressIndicator
@@ -23,6 +26,7 @@ import androidx.compose.material3.Icon
2326
import androidx.compose.material3.IconButton
2427
import androidx.compose.material3.ListItem
2528
import androidx.compose.material3.MaterialTheme
29+
import androidx.compose.material3.OutlinedTextField
2630
import androidx.compose.material3.Scaffold
2731
import androidx.compose.material3.Text
2832
import androidx.compose.material3.TextButton
@@ -49,6 +53,8 @@ fun SplitTunnelAppPickerView(
4953
model: SplitTunnelAppPickerViewModel = viewModel(),
5054
) {
5155
val installedApps by model.installedApps.collectAsState()
56+
val filteredApps by model.filteredApps.collectAsState()
57+
val searchQuery by model.searchQuery.collectAsState()
5258
val selectedPackageNames by model.selectedPackageNames.collectAsState()
5359
val allowSelected by model.allowSelected.collectAsState()
5460
val builtInDisallowedPackageNames: List<String> = App.get().builtInDisallowedPackageNames
@@ -118,6 +124,28 @@ fun SplitTunnelAppPickerView(
118124
selectedPackageNames.count(),
119125
))
120126
}
127+
item("searchBar") {
128+
OutlinedTextField(
129+
value = searchQuery,
130+
onValueChange = { model.updateSearchQuery(it) },
131+
placeholder = { Text(stringResource(R.string.search_ellipsis)) },
132+
leadingIcon = {
133+
Icon(Icons.Default.Search, contentDescription = null)
134+
},
135+
trailingIcon = {
136+
if (searchQuery.isNotEmpty()) {
137+
IconButton(onClick = { model.updateSearchQuery("") }) {
138+
Icon(
139+
Icons.Default.Close,
140+
contentDescription = stringResource(R.string.clear_search))
141+
}
142+
}
143+
},
144+
singleLine = true,
145+
modifier =
146+
Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
147+
)
148+
}
121149
if (installedApps.isEmpty()) {
122150
item("spinner") {
123151
Box(
@@ -132,7 +160,7 @@ fun SplitTunnelAppPickerView(
132160
}
133161
}
134162
} else {
135-
items(installedApps, key = { it.packageName }) { app ->
163+
items(filteredApps, key = { it.packageName }) { app ->
136164
ListItem(
137165
headlineContent = { Text(app.name, fontWeight = FontWeight.SemiBold) },
138166
leadingContent = {

android/src/main/java/com/tailscale/ipn/ui/viewModel/SplitTunnelAppPickerViewModel.kt

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import kotlinx.coroutines.delay
1717
import kotlinx.coroutines.flow.MutableStateFlow
1818
import kotlinx.coroutines.flow.SharingStarted
1919
import kotlinx.coroutines.flow.StateFlow
20+
import kotlinx.coroutines.flow.combine
2021
import kotlinx.coroutines.flow.flow
2122
import kotlinx.coroutines.flow.flowOn
2223
import kotlinx.coroutines.flow.stateIn
@@ -38,6 +39,28 @@ class SplitTunnelAppPickerViewModel : ViewModel() {
3839
)
3940
val selectedPackageNames: StateFlow<List<String>> = MutableStateFlow(listOf())
4041

42+
private val _searchQuery = MutableStateFlow("")
43+
val searchQuery: StateFlow<String> = _searchQuery
44+
45+
val filteredApps: StateFlow<List<InstalledApp>> =
46+
combine(installedApps, _searchQuery) { apps, query ->
47+
if (query.isBlank()) apps
48+
else
49+
apps.filter { app ->
50+
app.name.contains(query, ignoreCase = true) ||
51+
app.packageName.contains(query, ignoreCase = true)
52+
}
53+
}
54+
.stateIn(
55+
scope = viewModelScope,
56+
started = SharingStarted.WhileSubscribed(5000),
57+
initialValue = listOf(),
58+
)
59+
60+
fun updateSearchQuery(query: String) {
61+
_searchQuery.value = query
62+
}
63+
4164
val allowSelected: StateFlow<Boolean> = MutableStateFlow(App.get().allowSelectedPackages())
4265
val showHeaderMenu: StateFlow<Boolean> = MutableStateFlow(false)
4366
val showSwitchDialog: StateFlow<Boolean> = MutableStateFlow(false)

0 commit comments

Comments
 (0)