Improve icon picker, add icon search

This commit is contained in:
MM20 2022-07-31 14:36:49 +02:00
parent 050e8284e7
commit a1e8c20b2b
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
11 changed files with 209 additions and 178 deletions

View File

@ -19,8 +19,8 @@ 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 iconPack = :iconPack AND (type = 'app' OR type = 'calendar') LIMIT :limit OFFSET :offset") @Query("SELECT * FROM Icons WHERE (type = 'app' OR type = 'calendar') AND drawable LIKE :query ORDER BY iconPack, drawable LIMIT :limit")
suspend fun getIcons(iconPack: String, offset: Int, limit: Int): List<IconEntity> suspend fun searchIconPackIcons(query: String, limit: Int = 100): List<IconEntity>
@Query("DELETE FROM Icons WHERE iconPack = :iconPack") @Query("DELETE FROM Icons WHERE iconPack = :iconPack")
fun deleteIcons(iconPack: String) fun deleteIcons(iconPack: String)

View File

@ -623,4 +623,8 @@
<string name="restore_complete">The backup has been restored.</string> <string name="restore_complete">The backup has been restored.</string>
<string name="icon_picker_title">Pick icon</string> <string name="icon_picker_title">Pick icon</string>
<string name="icon_picker_default_icon">Default</string>
<string name="icon_picker_suggestions">Suggestions</string>
<string name="icon_picker_packs">Icon packs</string>
<string name="icon_picker_search_icon">Search icon</string>
</resources> </resources>

View File

@ -40,8 +40,6 @@ dependencies {
implementation(libs.androidx.appcompat) implementation(libs.androidx.appcompat)
implementation(libs.androidx.palette) implementation(libs.androidx.palette)
implementation(libs.androidx.paging.common)
implementation(libs.materialcomponents.core) implementation(libs.materialcomponents.core)
implementation(libs.bundles.androidx.lifecycle) implementation(libs.bundles.androidx.lifecycle)

View File

@ -15,6 +15,7 @@ import android.util.Log
import androidx.core.content.res.ResourcesCompat import androidx.core.content.res.ResourcesCompat
import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.drawable.toBitmap
import de.mm20.launcher2.crashreporter.CrashReporter import de.mm20.launcher2.crashreporter.CrashReporter
import de.mm20.launcher2.customattrs.CustomIconPackIcon
import de.mm20.launcher2.database.AppDatabase import de.mm20.launcher2.database.AppDatabase
import de.mm20.launcher2.ktx.randomElementOrNull import de.mm20.launcher2.ktx.randomElementOrNull
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -185,18 +186,12 @@ class IconPackManager(
) )
} }
suspend fun getIcons(componentName: ComponentName): List<IconPackIcon> { suspend fun getAllIconPackIcons(componentName: ComponentName): List<IconPackIcon> {
val iconDao = appDatabase.iconDao() val iconDao = appDatabase.iconDao()
return iconDao.getIconsFromAllPacks(componentName.flattenToString()) return iconDao.getIconsFromAllPacks(componentName.flattenToString())
.map { IconPackIcon(it) } .map { IconPackIcon(it) }
} }
suspend fun getIcons(iconPack: String, offset: Int, limit: Int): List<IconPackIcon> {
val iconDao = appDatabase.iconDao()
return iconDao.getIcons(iconPack, offset, limit)
.map { IconPackIcon(it) }
}
private suspend fun getIconBack(iconPack: String): String? { private suspend fun getIconBack(iconPack: String): String? {
val iconDao = appDatabase.iconDao() val iconDao = appDatabase.iconDao()
val iconbacks = iconDao.getIconBacks(iconPack) val iconbacks = iconDao.getIconBacks(iconPack)
@ -242,6 +237,13 @@ class IconPackManager(
) )
} }
suspend fun searchIconPackIcon(query: String): List<IconPackIcon> {
val iconDao = appDatabase.iconDao()
return iconDao.searchIconPackIcons("%$query%").map {
IconPackIcon(it)
}
}
} }

View File

@ -1,54 +0,0 @@
package de.mm20.launcher2.icons
import androidx.paging.PagingSource
import androidx.paging.PagingState
import de.mm20.launcher2.customattrs.CustomIconPackIcon
import de.mm20.launcher2.icons.transformations.LauncherIconTransformation
import de.mm20.launcher2.icons.transformations.apply
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
internal class IconPackPagingSource(
private val iconPackManager: IconPackManager,
private val iconPack: String,
private val transformations: List<LauncherIconTransformation>
) : PagingSource<Int, CustomIconWithPreview>() {
override fun getRefreshKey(state: PagingState<Int, CustomIconWithPreview>): Int? {
return null
}
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, CustomIconWithPreview> {
val page = params.key ?: 0
val icons = withContext(Dispatchers.IO) {
iconPackManager.getIcons(iconPack, page, page + params.loadSize)
}
val customIcons = mutableListOf<CustomIconWithPreview>()
withContext(Dispatchers.Default) {
for (icon in icons) {
val data = CustomIconPackIcon(iconPack, icon.componentName?.flattenToString() ?: continue)
val ic = iconPackManager.getIcon(
iconPack,
icon.componentName
) ?: continue
customIcons.add(
CustomIconWithPreview(
preview = transformations.apply(ic),
customIcon = data,
)
)
}
}
return LoadResult.Page(
data = customIcons,
prevKey = if (page > 0) page - 1 else null,
nextKey = if (icons.size == params.loadSize) page + 1 else null
)
}
}

View File

@ -6,12 +6,11 @@ import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.graphics.Color import android.graphics.Color
import android.util.LruCache import android.util.LruCache
import androidx.paging.PagingSource
import de.mm20.launcher2.customattrs.* import de.mm20.launcher2.customattrs.*
import de.mm20.launcher2.icons.providers.* import de.mm20.launcher2.icons.providers.*
import de.mm20.launcher2.icons.transformations.LauncherIconTransformation import de.mm20.launcher2.icons.transformations.LauncherIconTransformation
import de.mm20.launcher2.icons.transformations.LegacyToAdaptiveTransformation import de.mm20.launcher2.icons.transformations.LegacyToAdaptiveTransformation
import de.mm20.launcher2.icons.transformations.apply import de.mm20.launcher2.icons.transformations.transform
import de.mm20.launcher2.preferences.LauncherDataStore import de.mm20.launcher2.preferences.LauncherDataStore
import de.mm20.launcher2.search.data.LauncherApp import de.mm20.launcher2.search.data.LauncherApp
import de.mm20.launcher2.search.data.Searchable import de.mm20.launcher2.search.data.Searchable
@ -117,7 +116,7 @@ class IconRepository(
icon = provs.getFirstIcon(searchable, size) icon = provs.getFirstIcon(searchable, size)
if (icon != null) { if (icon != null) {
icon = transforms.apply(icon) icon = icon.transform(transforms)
cache.put(searchable.key + customIcon.hashCode(), icon) cache.put(searchable.key + customIcon.hashCode(), icon)
send(icon) send(icon)
@ -181,15 +180,6 @@ class IconRepository(
val defaultTransformations = transformations.first() val defaultTransformations = transformations.first()
val defaultTransformedIcon = defaultTransformations.apply(rawIcon)
suggestions.add(
CustomIconWithPreview(
defaultTransformedIcon,
null,
)
)
val customIcons = mutableListOf<CustomIcon>(UnmodifiedSystemDefaultIcon) val customIcons = mutableListOf<CustomIcon>(UnmodifiedSystemDefaultIcon)
if (rawIcon is StaticLauncherIcon && rawIcon.backgroundLayer is TransparentLayer) { if (rawIcon is StaticLauncherIcon && rawIcon.backgroundLayer is TransparentLayer) {
@ -230,7 +220,7 @@ class IconRepository(
val icon = providers.getFirstIcon(searchable, size) ?: rawIcon val icon = providers.getFirstIcon(searchable, size) ?: rawIcon
CustomIconWithPreview( CustomIconWithPreview(
preview = transformations.apply(icon), preview = icon.transform(transformations),
customIcon = it, customIcon = it,
) )
@ -240,7 +230,7 @@ class IconRepository(
val providerOptions = mutableListOf<CustomIcon>() val providerOptions = mutableListOf<CustomIcon>()
if (searchable is LauncherApp) { if (searchable is LauncherApp) {
val iconPackIcons = iconPackManager.getIcons( val iconPackIcons = iconPackManager.getAllIconPackIcons(
searchable.launcherActivityInfo.componentName searchable.launcherActivityInfo.componentName
) )
@ -262,7 +252,7 @@ class IconRepository(
val icon = providers.getFirstIcon(searchable, size) ?: return@mapNotNull null val icon = providers.getFirstIcon(searchable, size) ?: return@mapNotNull null
CustomIconWithPreview( CustomIconWithPreview(
preview = defaultTransformations.apply(icon), preview = icon.transform(defaultTransformations),
customIcon = it, customIcon = it,
) )
@ -273,8 +263,28 @@ class IconRepository(
} }
suspend fun getAllIconsFromPack(iconPack: String): PagingSource<Int, CustomIconWithPreview> { suspend fun getUncustomizedDefaultIcon(searchable: Searchable, size: Int): CustomIconWithPreview? {
return IconPackPagingSource(iconPackManager, iconPack, transformations.first()) val icon = iconProviders.first().getFirstIcon(searchable, size)
?.transform(transformations.first()) ?: return null
return CustomIconWithPreview(
customIcon = null,
preview = icon
)
}
suspend fun searchIconPackIcon(query: String): List<CustomIconWithPreview> {
val transformations = this.transformations.first()
return iconPackManager.searchIconPackIcon(query).mapNotNull {
val componentName = it.componentName ?: return@mapNotNull null
CustomIconWithPreview(
customIcon = CustomIconPackIcon(
iconPackPackage = it.iconPack,
iconComponentName = componentName.flattenToString(),
),
preview = iconPackManager.getIcon(it.iconPack, componentName)?.transform(transformations) ?: return@mapNotNull null
)
}
} }
fun setCustomIcon(searchable: Searchable, icon: CustomIcon?) { fun setCustomIcon(searchable: Searchable, icon: CustomIcon?) {

View File

@ -8,17 +8,17 @@ internal interface LauncherIconTransformation {
suspend fun transform(icon: StaticLauncherIcon): StaticLauncherIcon suspend fun transform(icon: StaticLauncherIcon): StaticLauncherIcon
} }
internal suspend fun Iterable<LauncherIconTransformation>.apply(icon: LauncherIcon): LauncherIcon { internal suspend fun LauncherIcon.transform(transformations: Iterable<LauncherIconTransformation>): LauncherIcon {
if (icon is StaticLauncherIcon) { if (this is StaticLauncherIcon) {
var transformedIcon = icon var transformedIcon = this
for (transformation in this) { for (transformation in transformations) {
transformedIcon = transformation.transform(transformedIcon as StaticLauncherIcon) transformedIcon = transformation.transform(transformedIcon as StaticLauncherIcon)
} }
return transformedIcon return transformedIcon
} }
if (icon is TransformableDynamicLauncherIcon) { if (this is TransformableDynamicLauncherIcon) {
icon.setTransformations(this.toList()) this.setTransformations(transformations.toList())
return icon return this
} }
return icon return this
} }

View File

@ -238,14 +238,6 @@ dependencyResolutionManagement {
.to("androidx.navigation", "navigation-compose") .to("androidx.navigation", "navigation-compose")
.version("2.5.0-rc02") .version("2.5.0-rc02")
alias("androidx.paging.common")
.to("androidx.paging", "paging-common-ktx")
.version("3.2.0-alpha01")
alias("androidx.paging.compose")
.to("androidx.paging", "paging-compose")
.version("1.0.0-alpha15")
alias("materialcomponents.core") alias("materialcomponents.core")
.to("com.google.android.material", "material") .to("com.google.android.material", "material")
.version("1.6.0-beta01") .version("1.6.0-beta01")

View File

@ -64,7 +64,6 @@ dependencies {
implementation(libs.androidx.compose.animationgraphics) implementation(libs.androidx.compose.animationgraphics)
implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.paging.compose)
implementation(libs.composecolorpicker) implementation(libs.composecolorpicker)

View File

@ -2,41 +2,34 @@ package de.mm20.launcher2.ui.launcher.search.common.customattrs
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.Box import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyGridScope 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.paging.compose.itemsIndexed import androidx.compose.material.icons.Icons
import androidx.compose.material3.MaterialTheme import androidx.compose.material.icons.rounded.Clear
import androidx.compose.material3.OutlinedButton import androidx.compose.material.icons.rounded.Search
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.*
import androidx.compose.material3.Text import androidx.compose.runtime.*
import androidx.compose.runtime.Composable
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.remember
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
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
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.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import de.mm20.launcher2.badges.Badge import de.mm20.launcher2.badges.Badge
import de.mm20.launcher2.icons.CustomIconWithPreview
import de.mm20.launcher2.search.data.Searchable import de.mm20.launcher2.search.data.Searchable
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.ShapedLauncherIcon
import de.mm20.launcher2.ui.ktx.toPixels import de.mm20.launcher2.ui.ktx.toPixels
import de.mm20.launcher2.ui.locals.LocalGridColumns import de.mm20.launcher2.ui.locals.LocalGridColumns
import kotlinx.coroutines.launch
@Composable @Composable
fun CustomizeSearchableSheet( fun CustomizeSearchableSheet(
@ -111,69 +104,146 @@ fun CustomizeSearchableSheet(
} else { } else {
val iconSize = 48.dp val iconSize = 48.dp
val iconSizePx = iconSize.toPixels() val iconSizePx = iconSize.toPixels()
val suggestions by
remember { viewModel.getIconSuggestions(iconSizePx.toInt()) } val scope = rememberCoroutineScope()
val suggestions by remember { viewModel.getIconSuggestions(iconSizePx.toInt()) }
.observeAsState(emptyList()) .observeAsState(emptyList())
val iconPackIcons by remember { val defaultIcon by remember {
viewModel.getAllIconsFromAllIconPacks() viewModel.getDefaultIcon(iconSizePx.toInt())
}.observeAsState(emptyList()) }.observeAsState()
val pagingItems = iconPackIcons.map { var query by remember { mutableStateOf("") }
it.flow.collectAsLazyPagingItems() val isSearching by viewModel.isSearchingIcons.observeAsState(initial = false)
} val iconResults by viewModel.iconSearchResults.observeAsState(emptyList())
LazyVerticalGrid(columns = GridCells.Fixed(LocalGridColumns.current)) { val columns = LocalGridColumns.current
items(suggestions) {
Box( LazyVerticalGrid(
contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize(),
modifier = Modifier.padding(vertical = 8.dp) columns = GridCells.Fixed(columns)
) { ) {
ShapedLauncherIcon(
size = iconSize, item(span = { GridItemSpan(columns) }) {
icon = it.preview, OutlinedTextField(
onClick = { modifier = Modifier.padding(bottom = 16.dp),
viewModel.pickIcon(it.customIcon) leadingIcon = {
Icon(
imageVector = Icons.Rounded.Search,
contentDescription = null
)
},
trailingIcon = {
if (query.isNotEmpty()) {
IconButton(onClick = {
query = ""
scope.launch {
viewModel.searchIcon("")
}
}) {
Icon(
imageVector = Icons.Rounded.Clear,
contentDescription = null
)
}
} }
},
value = query,
onValueChange = {
query = it
scope.launch {
viewModel.searchIcon(query)
}
},
label = {
Text(stringResource(R.string.icon_picker_search_icon))
}
)
}
if (query.isEmpty()) {
if (defaultIcon != null) {
item(span = { GridItemSpan(columns) }) {
Separator(stringResource(R.string.icon_picker_default_icon))
}
item {
IconPreview(item = defaultIcon, iconSize = iconSize, onClick = {
viewModel.pickIcon(null)
})
}
}
item(span = { GridItemSpan(columns) }) {
Separator(stringResource(R.string.icon_picker_suggestions))
}
items(suggestions) {
IconPreview(
it,
iconSize,
onClick = { viewModel.pickIcon(it.customIcon) }
) )
} }
} } else {
for (pager in pagingItems) {
itemsIndexed(pager) { index, item -> item(span = { GridItemSpan(columns) }) {
Box( Separator(stringResource(R.string.icon_picker_packs))
contentAlignment = Alignment.Center, }
modifier = Modifier.padding(vertical = 8.dp)
) { items(iconResults) {
ShapedLauncherIcon( IconPreview(
size = iconSize, it,
icon = item?.preview, iconSize,
onClick = { onClick = { viewModel.pickIcon(it.customIcon) }
viewModel.pickIcon(item?.customIcon) )
} }
)
if (isSearching) {
item(span = { GridItemSpan(columns) }) {
Box(
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier
.padding(12.dp)
.size(24.dp)
)
}
} }
} }
} }
} }
} }
} }
} }
fun <T : Any> LazyGridScope.itemsIndexed( @Composable
items: LazyPagingItems<T>, fun IconPreview(
key: ((index: Int, item: T) -> Any)? = null, item: CustomIconWithPreview?,
itemContent: @Composable LazyGridScope.(index: Int, value: T?) -> Unit iconSize: Dp,
onClick: () -> Unit,
) { ) {
items( Box(
count = items.itemCount, contentAlignment = Alignment.Center,
key = if (key == null) null else { index -> modifier = Modifier.padding(vertical = 8.dp)
val item = items.peek(index) ) {
if (item == null) { ShapedLauncherIcon(
} else { size = iconSize,
key(index, item) icon = item?.preview,
} onClick = onClick
} )
) { index ->
this@itemsIndexed.itemContent(index, items[index])
} }
}
@Composable
fun Separator(label: String) {
Text(
label,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier
.padding(top = 16.dp, bottom = 8.dp)
.fillMaxWidth()
)
} }

View File

@ -2,15 +2,16 @@ package de.mm20.launcher2.ui.launcher.search.common.customattrs
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.liveData import androidx.lifecycle.liveData
import androidx.paging.Pager
import androidx.paging.PagingConfig
import de.mm20.launcher2.customattrs.CustomIcon import de.mm20.launcher2.customattrs.CustomIcon
import de.mm20.launcher2.icons.CustomIconWithPreview
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.data.Searchable import de.mm20.launcher2.search.data.Searchable
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
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
class CustomizeSearchableSheetVM( class CustomizeSearchableSheetVM(
private val searchable: Searchable private val searchable: Searchable
@ -40,19 +41,28 @@ class CustomizeSearchableSheetVM(
closeIconPicker() closeIconPicker()
} }
fun getAllIconsFromAllIconPacks() = liveData { fun getDefaultIcon(size: Int) = liveData {
emit(emptyList()) emit(iconRepository.getUncustomizedDefaultIcon(searchable, size))
val iconPacks = iconRepository.getInstalledIconPacks() }
emit(iconPacks.map { val iconSearchResults = MutableLiveData(emptyList<CustomIconWithPreview>())
val source = iconRepository.getAllIconsFromPack(it.packageName) val isSearchingIcons = MutableLiveData(false)
Pager( private var debounceSearchJob: Job? = null
PagingConfig(pageSize = 20, enablePlaceholders = false, maxSize = 200), suspend fun searchIcon(query: String) {
) { debounceSearchJob?.cancelAndJoin()
source if (query.isBlank()) {
iconSearchResults.value = emptyList()
isSearchingIcons.value = false
return
}
withContext(coroutineContext) {
debounceSearchJob = launch {
delay(1000)
isSearchingIcons.value = true
iconSearchResults.value = iconRepository.searchIconPackIcon(query)
isSearchingIcons.value = false
} }
}) }
} }
} }