Skip to content

Commit

Permalink
Add support for custom icon pack tag icons
Browse files Browse the repository at this point in the history
  • Loading branch information
MM2-0 committed Dec 3, 2024
1 parent 2555185 commit ca36f3c
Show file tree
Hide file tree
Showing 12 changed files with 469 additions and 289 deletions.
3 changes: 3 additions & 0 deletions .idea/inspectionProfiles/Project_Default.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

263 changes: 263 additions & 0 deletions app/ui/src/main/java/de/mm20/launcher2/ui/common/IconPicker.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
package de.mm20.launcher2.ui.common

import android.content.pm.PackageManager
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.ArrowDropDown
import androidx.compose.material.icons.rounded.FilterAlt
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SearchBar
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
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.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import de.mm20.launcher2.data.customattrs.CustomIcon
import de.mm20.launcher2.icons.IconPack
import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.ktx.toPixels
import de.mm20.launcher2.ui.launcher.sheets.IconPreview
import de.mm20.launcher2.ui.launcher.sheets.Separator
import de.mm20.launcher2.ui.locals.LocalGridSettings
import kotlinx.coroutines.launch

@Composable
fun IconPicker(
searchable: SavableSearchable,
onSelect: (CustomIcon?) -> Unit,
contentPadding: PaddingValues = PaddingValues(0.dp)
) {
val iconSize = 48.dp
val iconSizePx = iconSize.toPixels()

val context = LocalContext.current

val scope = rememberCoroutineScope()

val viewModel: IconPickerVM =
remember(searchable.key) { IconPickerVM(searchable) }

val suggestions by remember { viewModel.getIconSuggestions(iconSizePx.toInt()) }
.collectAsState(emptyList())

val defaultIcon by remember {
viewModel.getDefaultIcon(iconSizePx.toInt())
}.collectAsState(null)

var query by remember { mutableStateOf("") }
var filterIconPack by remember { mutableStateOf<IconPack?>(null) }
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

LazyVerticalGrid(
modifier = Modifier.fillMaxSize(),
columns = GridCells.Fixed(columns),
contentPadding = contentPadding,
) {

item(span = { GridItemSpan(columns) }) {
SearchBar(
modifier = Modifier.padding(bottom = 16.dp),
expanded = false,
onExpandedChange = {},
inputField = {
SearchBarDefaults.InputField(
enabled = !noPacksInstalled,
leadingIcon = {
Icon(
imageVector = Icons.Rounded.Search,
contentDescription = null
)
},
onSearch = {},
expanded = false,
onExpandedChange = {},
placeholder = {
Text(
stringResource(
if (noPacksInstalled) R.string.icon_picker_no_packs_installed else R.string.icon_picker_search_icon
)
)
},
query = query,
onQueryChange = {
query = it
scope.launch {
viewModel.searchIcon(query, filterIconPack)
}
},
)
}
) {

}
}

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 = {
onSelect(null)
})
}
}
item(span = { GridItemSpan(columns) }) {
Separator(stringResource(R.string.icon_picker_suggestions))
}

if (suggestions.isNotEmpty()) {
items(suggestions) {
IconPreview(
it,
iconSize,
onClick = { onSelect(it.customIcon) }
)
}
}
} else {

if (!installedIconPacks.isNullOrEmpty()) {
item(
span = { GridItemSpan(columns) },
) {
Button(
onClick = { showIconPackFilter = !showIconPackFilter },
modifier = Modifier
.wrapContentWidth(align = Alignment.CenterHorizontally)
.padding(bottom = 16.dp),
contentPadding = PaddingValues(
horizontal = 16.dp,
vertical = 8.dp
)
) {
if (filterIconPack == null) {
Icon(
modifier = Modifier
.padding(end = ButtonDefaults.IconSpacing)
.size(ButtonDefaults.IconSize),
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
.padding(end = ButtonDefaults.IconSpacing)
.size(ButtonDefaults.IconSize),
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)
})
}
}
Text(
text = filterIconPack?.name
?: stringResource(id = R.string.icon_picker_filter_all_packs),
modifier = Modifier.animateContentSize()
)
Icon(
Icons.Rounded.ArrowDropDown,
modifier = Modifier
.padding(start = ButtonDefaults.IconSpacing)
.size(ButtonDefaults.IconSize),
contentDescription = null
)
}
}
}

items(iconResults) {
IconPreview(
it,
iconSize,
onClick = { onSelect(it.customIcon) }
)
}

if (isSearching) {
item(span = { GridItemSpan(columns) }) {
Box(
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier
.padding(12.dp)
.size(24.dp)
)
}
}
}
}

}
}
56 changes: 56 additions & 0 deletions app/ui/src/main/java/de/mm20/launcher2/ui/common/IconPickerVM.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package de.mm20.launcher2.ui.common

import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import de.mm20.launcher2.icons.CustomIconWithPreview
import de.mm20.launcher2.icons.IconPack
import de.mm20.launcher2.icons.IconService
import de.mm20.launcher2.search.SavableSearchable
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import kotlin.coroutines.coroutineContext

class IconPickerVM(
private val searchable: SavableSearchable
): KoinComponent {
private val iconService: IconService by inject()

fun getDefaultIcon(size: Int) = flow {
emit(iconService.getUncustomizedDefaultIcon(searchable, size))
}

fun getIconSuggestions(size: Int) = flow {
emit(iconService.getCustomIconSuggestions(searchable, size))
}

val installedIconPacks = iconService.getInstalledIconPacks()

val iconSearchResults = mutableStateOf(emptyList<CustomIconWithPreview>())
val isSearchingIcons = mutableStateOf(false)


private var debounceSearchJob: Job? = null
suspend fun searchIcon(query: String, iconPack: IconPack?) {
debounceSearchJob?.cancelAndJoin()
if (query.isBlank()) {
iconSearchResults.value = emptyList()
isSearchingIcons.value = false
return
}
withContext(coroutineContext) {
debounceSearchJob = launch {
delay(500)
isSearchingIcons.value = true
iconSearchResults.value = emptyList()
iconSearchResults.value = iconService.searchCustomIcons(query, iconPack)
isSearchingIcons.value = false
}
}
}
}
22 changes: 17 additions & 5 deletions app/ui/src/main/java/de/mm20/launcher2/ui/common/TagChip.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material3.FilterChipDefaults
Expand All @@ -38,6 +39,7 @@ import de.mm20.launcher2.icons.StaticLauncherIcon
import de.mm20.launcher2.icons.TextLayer
import de.mm20.launcher2.icons.VectorLayer
import de.mm20.launcher2.search.Tag
import de.mm20.launcher2.ui.component.ShapedLauncherIcon
import de.mm20.launcher2.ui.ktx.toPixels
import org.koin.androidx.compose.inject

Expand Down Expand Up @@ -113,21 +115,31 @@ fun TagChip(
onClick = onClick,
onLongClick = onLongClick
)
.padding(horizontal = 8.dp),
.padding(start = 4.dp, end = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
val foregroundLayer = (icon as? StaticLauncherIcon)?.foregroundLayer
AnimatedVisibility(!compact || foregroundLayer is TextLayer) {
AnimatedVisibility(!compact || foregroundLayer !is VectorLayer) {
if (foregroundLayer is TextLayer) {
Text(
text = foregroundLayer.text,
modifier = Modifier.width(FilterChipDefaults.IconSize),
modifier = Modifier
.padding(start = 4.dp)
.width(FilterChipDefaults.IconSize),
textAlign = TextAlign.Center,
)
} else if (foregroundLayer is VectorLayer && !compact) {
} else if (foregroundLayer !is VectorLayer) {
ShapedLauncherIcon(
modifier = Modifier.padding(start = if(compact) 4.dp else 0.dp),
size = InputChipDefaults.AvatarSize,
icon = { icon },
shape = CircleShape,
)
} else if (!compact) {
Icon(
modifier = Modifier
.padding(start = 4.dp)
.size(FilterChipDefaults.IconSize),
imageVector = foregroundLayer.vector,
contentDescription = null,
Expand All @@ -140,7 +152,7 @@ fun TagChip(
tag.tag,
style = MaterialTheme.typography.labelLarge,
color = textColor,
modifier = Modifier.padding(horizontal = 8.dp)
modifier = Modifier.padding(start = if (compact) 12.dp else 8.dp, end = 8.dp)
)
}
if (clearable) {
Expand Down
Loading

0 comments on commit ca36f3c

Please sign in to comment.