Icon picker: add icon pack filter

This commit is contained in:
MM20 2023-02-27 14:39:18 +01:00
parent 81c542d777
commit 6adbee224e
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
6 changed files with 128 additions and 33 deletions

View File

@ -1,18 +1,40 @@
package de.mm20.launcher2.ui.launcher.sheets package de.mm20.launcher2.ui.launcher.sheets
import android.content.pm.PackageManager
import android.graphics.drawable.InsetDrawable import android.graphics.drawable.InsetDrawable
import androidx.appcompat.content.res.AppCompatResources import androidx.appcompat.content.res.AppCompatResources
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Clear import androidx.compose.material.icons.rounded.FilterAlt
import androidx.compose.material.icons.rounded.Search import androidx.compose.material.icons.rounded.Search
import androidx.compose.material3.* import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.* import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.graphics.toArgb import androidx.compose.ui.graphics.toArgb
@ -21,13 +43,15 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import de.mm20.launcher2.badges.Badge import de.mm20.launcher2.badges.Badge
import de.mm20.launcher2.icons.CustomIconWithPreview import de.mm20.launcher2.icons.CustomIconWithPreview
import de.mm20.launcher2.icons.IconPack
import de.mm20.launcher2.search.SavableSearchable import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.ui.R import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.BottomSheetDialog import de.mm20.launcher2.ui.component.BottomSheetDialog
import de.mm20.launcher2.ui.component.ShapedLauncherIcon
import de.mm20.launcher2.ui.component.OutlinedTagsInputField import de.mm20.launcher2.ui.component.OutlinedTagsInputField
import de.mm20.launcher2.ui.component.ShapedLauncherIcon
import de.mm20.launcher2.ui.ktx.toPixels import de.mm20.launcher2.ui.ktx.toPixels
import de.mm20.launcher2.ui.locals.LocalGridSettings import de.mm20.launcher2.ui.locals.LocalGridSettings
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@ -42,7 +66,7 @@ fun CustomizeSearchableSheet(
remember(searchable.key) { CustomizeSearchableSheetVM(searchable) } remember(searchable.key) { CustomizeSearchableSheetVM(searchable) }
val context = LocalContext.current val context = LocalContext.current
val pickIcon by viewModel.isIconPickerOpen.observeAsState(false) val pickIcon by viewModel.isIconPickerOpen
BottomSheetDialog( BottomSheetDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
@ -156,8 +180,13 @@ fun CustomizeSearchableSheet(
}.observeAsState() }.observeAsState()
var query by remember { mutableStateOf("") } var query by remember { mutableStateOf("") }
val isSearching by viewModel.isSearchingIcons.observeAsState(initial = false) var filterIconPack by remember { mutableStateOf<IconPack?>(null) }
val iconResults by viewModel.iconSearchResults.observeAsState(emptyList()) val isSearching by viewModel.isSearchingIcons
val iconResults by viewModel.iconSearchResults
var showIconPackFilter by remember { mutableStateOf(false) }
val installedIconPacks by viewModel.installedIconPacks.collectAsState(null)
val noPacksInstalled = installedIconPacks?.isEmpty() == true
val columns = LocalGridSettings.current.columnCount val columns = LocalGridSettings.current.columnCount
@ -176,18 +205,67 @@ fun CustomizeSearchableSheet(
contentDescription = null contentDescription = null
) )
}, },
enabled = !noPacksInstalled,
placeholder = {
Text(
stringResource(
if (noPacksInstalled) R.string.icon_picker_no_packs_installed else R.string.icon_picker_search_icon
)
)
},
trailingIcon = { trailingIcon = {
if (query.isNotEmpty()) { if (query.isNotEmpty() && !installedIconPacks.isNullOrEmpty()) {
IconButton(onClick = { IconButton(onClick = {
query = "" showIconPackFilter = !showIconPackFilter
scope.launch {
viewModel.searchIcon("")
}
}) { }) {
Icon( if (filterIconPack == null) {
imageVector = Icons.Rounded.Clear, Icon(
contentDescription = null imageVector = Icons.Rounded.FilterAlt,
contentDescription = null
)
} else {
val icon = remember(filterIconPack?.packageName) {
try {
filterIconPack?.packageName?.let { pkg ->
context.packageManager.getApplicationIcon(pkg)
}
} catch (e: PackageManager.NameNotFoundException) {
null
}
}
AsyncImage(
modifier = Modifier.size(24.dp),
model = icon,
contentDescription = null
)
}
}
DropdownMenu(
expanded = showIconPackFilter,
onDismissRequest = { showIconPackFilter = false }) {
DropdownMenuItem(
text = { Text(stringResource(id = R.string.icon_picker_filter_all_packs)) },
onClick = {
showIconPackFilter = false
filterIconPack = null
scope.launch {
viewModel.searchIcon(query, filterIconPack)
}
}
) )
installedIconPacks?.forEach { iconPack ->
DropdownMenuItem(
onClick = {
showIconPackFilter = false
filterIconPack = iconPack
scope.launch {
viewModel.searchIcon(query, filterIconPack)
}
},
text = {
Text(iconPack.name)
})
}
} }
} }
}, },
@ -195,12 +273,9 @@ fun CustomizeSearchableSheet(
onValueChange = { onValueChange = {
query = it query = it
scope.launch { scope.launch {
viewModel.searchIcon(query) viewModel.searchIcon(query, filterIconPack)
} }
}, },
label = {
Text(stringResource(R.string.icon_picker_search_icon))
},
singleLine = true, singleLine = true,
) )
} }

View File

@ -1,16 +1,22 @@
package de.mm20.launcher2.ui.launcher.sheets package de.mm20.launcher2.ui.launcher.sheets
import androidx.lifecycle.MutableLiveData import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.liveData import androidx.lifecycle.liveData
import de.mm20.launcher2.data.customattrs.CustomAttributesRepository import de.mm20.launcher2.data.customattrs.CustomAttributesRepository
import de.mm20.launcher2.data.customattrs.CustomIcon import de.mm20.launcher2.data.customattrs.CustomIcon
import de.mm20.launcher2.icons.CustomIconWithPreview import de.mm20.launcher2.icons.CustomIconWithPreview
import de.mm20.launcher2.icons.IconPack
import de.mm20.launcher2.icons.IconRepository import de.mm20.launcher2.icons.IconRepository
import de.mm20.launcher2.icons.LauncherIcon import de.mm20.launcher2.icons.LauncherIcon
import de.mm20.launcher2.search.SavableSearchable import de.mm20.launcher2.search.SavableSearchable
import kotlinx.coroutines.* import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
import kotlin.coroutines.coroutineContext import kotlin.coroutines.coroutineContext
@ -21,7 +27,7 @@ class CustomizeSearchableSheetVM(
private val iconRepository: IconRepository by inject() private val iconRepository: IconRepository by inject()
private val customAttributesRepository: CustomAttributesRepository by inject() private val customAttributesRepository: CustomAttributesRepository by inject()
val isIconPickerOpen = MutableLiveData(false) val isIconPickerOpen = mutableStateOf(false)
fun getIcon(size: Int): Flow<LauncherIcon> { fun getIcon(size: Int): Flow<LauncherIcon> {
return iconRepository.getIcon(searchable, size) return iconRepository.getIcon(searchable, size)
@ -48,11 +54,15 @@ class CustomizeSearchableSheetVM(
emit(iconRepository.getUncustomizedDefaultIcon(searchable, size)) emit(iconRepository.getUncustomizedDefaultIcon(searchable, size))
} }
val iconSearchResults = MutableLiveData(emptyList<CustomIconWithPreview>()) val iconSearchResults = mutableStateOf(emptyList<CustomIconWithPreview>())
val isSearchingIcons = MutableLiveData(false) val isSearchingIcons = mutableStateOf(false)
val installedIconPacks = flow {
emit(iconRepository.getInstalledIconPacks().sortedBy { it.name })
}
private var debounceSearchJob: Job? = null private var debounceSearchJob: Job? = null
suspend fun searchIcon(query: String) { suspend fun searchIcon(query: String, iconPack: IconPack?) {
debounceSearchJob?.cancelAndJoin() debounceSearchJob?.cancelAndJoin()
if (query.isBlank()) { if (query.isBlank()) {
iconSearchResults.value = emptyList() iconSearchResults.value = emptyList()
@ -61,9 +71,10 @@ class CustomizeSearchableSheetVM(
} }
withContext(coroutineContext) { withContext(coroutineContext) {
debounceSearchJob = launch { debounceSearchJob = launch {
delay(1000) delay(500)
isSearchingIcons.value = true isSearchingIcons.value = true
iconSearchResults.value = iconRepository.searchCustomIcons(query) iconSearchResults.value = emptyList()
iconSearchResults.value = iconRepository.searchCustomIcons(query, iconPack)
isSearchingIcons.value = false isSearchingIcons.value = false
} }
} }

View File

@ -19,8 +19,14 @@ interface IconDao {
@Query("SELECT * FROM Icons WHERE componentName = :componentName AND (type = 'app' OR type = 'calendar')") @Query("SELECT * FROM Icons WHERE componentName = :componentName AND (type = 'app' OR type = 'calendar')")
suspend fun getIconsFromAllPacks(componentName: String): List<IconEntity> suspend fun getIconsFromAllPacks(componentName: String): List<IconEntity>
@Query("SELECT * FROM Icons WHERE (type = 'app' OR type = 'calendar') AND (drawable LIKE :drawableQuery OR componentName LIKE :componentQuery OR name LIKE :nameQuery) ORDER BY iconPack, drawable LIMIT :limit") @Query("SELECT * FROM Icons WHERE (type = 'app' OR type = 'calendar') AND (drawable LIKE :drawableQuery OR componentName LIKE :componentQuery OR name LIKE :nameQuery) AND (:iconPack IS NULL OR iconPack = :iconPack) ORDER BY iconPack, drawable LIMIT :limit")
suspend fun searchIconPackIcons(componentQuery: String, nameQuery: String, drawableQuery: String, limit: Int = 100): List<IconEntity> suspend fun searchIconPackIcons(
componentQuery: String,
nameQuery: String,
drawableQuery: String,
iconPack: String?,
limit: Int = 100
): List<IconEntity>
@Query("SELECT * FROM Icons WHERE (type = 'greyscale_icon') AND componentName LIKE :query GROUP BY componentName ORDER BY drawable LIMIT :limit") @Query("SELECT * FROM Icons WHERE (type = 'greyscale_icon') AND componentName LIKE :query GROUP BY componentName ORDER BY drawable LIMIT :limit")
suspend fun searchGreyscaleIcons(query: String, limit: Int = 100): List<IconEntity> suspend fun searchGreyscaleIcons(query: String, limit: Int = 100): List<IconEntity>

View File

@ -681,6 +681,8 @@
<string name="icon_picker_default_icon">Default</string> <string name="icon_picker_default_icon">Default</string>
<string name="icon_picker_suggestions">Suggestions</string> <string name="icon_picker_suggestions">Suggestions</string>
<string name="icon_picker_search_icon">Search icon</string> <string name="icon_picker_search_icon">Search icon</string>
<string name="icon_picker_no_packs_installed">No icon packs installed</string>
<string name="icon_picker_filter_all_packs">All icon packs</string>
<string name="apps_profile_main">Personal</string> <string name="apps_profile_main">Personal</string>
<string name="apps_profile_work">Work</string> <string name="apps_profile_work">Work</string>
<string name="favorites">Favorites</string> <string name="favorites">Favorites</string>

View File

@ -427,13 +427,14 @@ class IconPackManager(
return null return null
} }
suspend fun searchIconPackIcon(query: String): List<IconPackIcon> { suspend fun searchIconPackIcon(query: String, iconPack: IconPack?): List<IconPackIcon> {
val iconDao = appDatabase.iconDao() val iconDao = appDatabase.iconDao()
val drawableQuery = query.replace(" ", "_").lowercase() val drawableQuery = query.replace(" ", "_").lowercase()
return iconDao.searchIconPackIcons( return iconDao.searchIconPackIcons(
drawableQuery = "%$drawableQuery%", drawableQuery = "%$drawableQuery%",
componentQuery = "%$query%", componentQuery = "%$query%",
nameQuery = "%$query%", nameQuery = "%$query%",
iconPack = iconPack?.packageName,
).map { ).map {
IconPackIcon(it) IconPackIcon(it)
} }

View File

@ -356,9 +356,9 @@ class IconRepository(
) )
} }
suspend fun searchCustomIcons(query: String): List<CustomIconWithPreview> { suspend fun searchCustomIcons(query: String, iconPack: IconPack?): List<CustomIconWithPreview> {
val transformations = this.transformations.first() val transformations = this.transformations.first()
val iconPackIcons = iconPackManager.searchIconPackIcon(query).mapNotNull { val iconPackIcons = iconPackManager.searchIconPackIcon(query, iconPack).mapNotNull {
val componentName = it.componentName ?: return@mapNotNull null val componentName = it.componentName ?: return@mapNotNull null
CustomIconWithPreview( CustomIconWithPreview(