@@ -6,15 +6,21 @@ package com.tailscale.ipn.ui.view
66import androidx.compose.foundation.Image
77import androidx.compose.foundation.background
88import androidx.compose.foundation.layout.Box
9+ import androidx.compose.foundation.layout.Column
910import androidx.compose.foundation.layout.Row
11+ import androidx.compose.foundation.layout.Spacer
1012import androidx.compose.foundation.layout.fillMaxSize
13+ import androidx.compose.foundation.layout.fillMaxWidth
1114import androidx.compose.foundation.layout.height
1215import androidx.compose.foundation.layout.padding
16+ import androidx.compose.foundation.layout.size
1317import androidx.compose.foundation.layout.width
1418import androidx.compose.foundation.lazy.LazyColumn
1519import androidx.compose.foundation.lazy.items
1620import androidx.compose.material.icons.Icons
1721import androidx.compose.material.icons.filled.MoreVert
22+ import androidx.compose.material.icons.outlined.Close
23+ import androidx.compose.material.icons.outlined.Search
1824import androidx.compose.material3.AlertDialog
1925import androidx.compose.material3.Checkbox
2026import androidx.compose.material3.CircularProgressIndicator
@@ -23,19 +29,22 @@ import androidx.compose.material3.Icon
2329import androidx.compose.material3.IconButton
2430import androidx.compose.material3.ListItem
2531import androidx.compose.material3.MaterialTheme
32+ import androidx.compose.material3.OutlinedTextField
2633import androidx.compose.material3.Scaffold
2734import androidx.compose.material3.Text
2835import androidx.compose.material3.TextButton
2936import androidx.compose.runtime.Composable
37+ import androidx.compose.runtime.LaunchedEffect
3038import androidx.compose.runtime.collectAsState
3139import androidx.compose.runtime.getValue
3240import androidx.compose.ui.Alignment
3341import androidx.compose.ui.Modifier
3442import androidx.compose.ui.graphics.asImageBitmap
43+ import androidx.compose.ui.platform.LocalDensity
3544import androidx.compose.ui.res.stringResource
3645import androidx.compose.ui.text.font.FontWeight
46+ import androidx.compose.ui.text.style.TextOverflow
3747import androidx.compose.ui.unit.dp
38- import androidx.core.graphics.drawable.toBitmap
3948import androidx.lifecycle.viewmodel.compose.viewModel
4049import com.tailscale.ipn.App
4150import com.tailscale.ipn.R
@@ -49,13 +58,20 @@ fun SplitTunnelAppPickerView(
4958 model : SplitTunnelAppPickerViewModel = viewModel(),
5059) {
5160 val installedApps by model.installedApps.collectAsState()
61+ val displayedApps by model.displayedApps.collectAsState()
5262 val selectedPackageNames by model.selectedPackageNames.collectAsState()
63+ val searchQuery by model.searchQuery.collectAsState()
64+ val appIcons by model.appIcons.collectAsState()
5365 val allowSelected by model.allowSelected.collectAsState()
5466 val builtInDisallowedPackageNames: List <String > = App .get().builtInDisallowedPackageNames
5567 val mdmIncludedPackages by model.mdmIncludedPackages.collectAsState()
5668 val mdmExcludedPackages by model.mdmExcludedPackages.collectAsState()
5769 val showHeaderMenu by model.showHeaderMenu.collectAsState()
5870 val showSwitchDialog by model.showSwitchDialog.collectAsState()
71+ val appIconSize = 40 .dp
72+ val appIconSizePx = with (LocalDensity .current) { appIconSize.roundToPx() }
73+
74+ LaunchedEffect (installedApps, appIconSizePx) { model.preloadIcons(installedApps, appIconSizePx) }
5975
6076 if (showSwitchDialog) {
6177 SwitchAlertDialog (
@@ -118,6 +134,26 @@ fun SplitTunnelAppPickerView(
118134 selectedPackageNames.count(),
119135 ))
120136 }
137+ item(" search" ) {
138+ OutlinedTextField (
139+ value = searchQuery,
140+ onValueChange = model::updateSearchQuery,
141+ modifier = Modifier .fillMaxWidth().padding(horizontal = 16 .dp, vertical = 8 .dp),
142+ singleLine = true ,
143+ leadingIcon = { Icon (Icons .Outlined .Search , contentDescription = null ) },
144+ trailingIcon = {
145+ if (searchQuery.isNotEmpty()) {
146+ IconButton (onClick = { model.updateSearchQuery(" " ) }) {
147+ Icon (
148+ Icons .Outlined .Close ,
149+ contentDescription = stringResource(R .string.clear_search),
150+ )
151+ }
152+ }
153+ },
154+ placeholder = { Text (stringResource(R .string.search_ellipsis)) },
155+ )
156+ }
121157 if (installedApps.isEmpty()) {
122158 item(" spinner" ) {
123159 Box (
@@ -132,44 +168,71 @@ fun SplitTunnelAppPickerView(
132168 }
133169 }
134170 } else {
135- items(installedApps, key = { it.packageName }) { app ->
136- ListItem (
137- headlineContent = { Text (app.name, fontWeight = FontWeight .SemiBold ) },
138- leadingContent = {
139- Image (
140- bitmap =
141- model.installedAppsManager.packageManager
142- .getApplicationIcon(app.packageName)
143- .toBitmap()
144- .asImageBitmap(),
145- contentDescription = null ,
146- modifier = Modifier .width(40 .dp).height(40 .dp),
147- )
148- },
149- supportingContent = {
150- Text (
151- app.packageName,
152- color = MaterialTheme .colorScheme.secondary,
153- fontSize = MaterialTheme .typography.bodySmall.fontSize,
154- letterSpacing = MaterialTheme .typography.bodySmall.letterSpacing,
155- )
156- },
157- trailingContent = {
158- Checkbox (
159- checked = selectedPackageNames.contains(app.packageName),
160- enabled = ! builtInDisallowedPackageNames.contains(app.packageName),
161- onCheckedChange = { checked ->
162- if (checked) {
163- model.select(packageName = app.packageName)
164- } else {
165- model.deselect(packageName = app.packageName)
166- }
167- },
168- )
169- },
170- )
171+ items(displayedApps, key = { it.packageName }, contentType = { " app" }) { app ->
172+ val selected = selectedPackageNames.contains(app.packageName)
173+ val enabled = ! builtInDisallowedPackageNames.contains(app.packageName)
174+
175+ Row (
176+ modifier = Modifier .fillMaxWidth().height(72 .dp).padding(horizontal = 16 .dp),
177+ verticalAlignment = Alignment .CenterVertically ,
178+ ) {
179+ val appIcon = appIcons[app.packageName]
180+ if (appIcon != null ) {
181+ Image (
182+ bitmap = appIcon.asImageBitmap(),
183+ contentDescription = null ,
184+ modifier = Modifier .size(appIconSize),
185+ )
186+ } else {
187+ Box (
188+ modifier =
189+ Modifier .size(appIconSize)
190+ .background(MaterialTheme .colorScheme.surfaceContainerHighest),
191+ )
192+ }
193+ Spacer (modifier = Modifier .width(16 .dp))
194+ Column (modifier = Modifier .weight(1f )) {
195+ Text (
196+ app.name,
197+ color = MaterialTheme .colorScheme.onSurface,
198+ fontWeight = FontWeight .SemiBold ,
199+ maxLines = 1 ,
200+ overflow = TextOverflow .Ellipsis ,
201+ )
202+ Text (
203+ app.packageName,
204+ color = MaterialTheme .colorScheme.secondary,
205+ fontSize = MaterialTheme .typography.bodySmall.fontSize,
206+ letterSpacing = MaterialTheme .typography.bodySmall.letterSpacing,
207+ maxLines = 1 ,
208+ overflow = TextOverflow .Ellipsis ,
209+ )
210+ }
211+ Checkbox (
212+ checked = selected,
213+ enabled = enabled,
214+ onCheckedChange = { checked ->
215+ if (checked) {
216+ model.select(packageName = app.packageName)
217+ } else {
218+ model.deselect(packageName = app.packageName)
219+ }
220+ },
221+ )
222+ }
171223 Lists .ItemDivider ()
172224 }
225+ if (displayedApps.isEmpty()) {
226+ item(" noResults" ) {
227+ ListItem (
228+ headlineContent = {
229+ Text (
230+ stringResource(R .string.no_results),
231+ color = MaterialTheme .colorScheme.secondary,
232+ )
233+ })
234+ }
235+ }
173236 }
174237 }
175238 }
0 commit comments