Add support for custom icon pack tag icons
This commit is contained in:
parent
25551859c5
commit
ca36f3cac1
3
.idea/inspectionProfiles/Project_Default.xml
generated
3
.idea/inspectionProfiles/Project_Default.xml
generated
@ -65,6 +65,9 @@
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
|
||||
263
app/ui/src/main/java/de/mm20/launcher2/ui/common/IconPicker.kt
Normal file
263
app/ui/src/main/java/de/mm20/launcher2/ui/common/IconPicker.kt
Normal file
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
@ -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) {
|
||||
|
||||
@ -65,6 +65,7 @@ import de.mm20.launcher2.search.CalendarEvent
|
||||
import de.mm20.launcher2.search.SavableSearchable
|
||||
import de.mm20.launcher2.searchable.VisibilityLevel
|
||||
import de.mm20.launcher2.ui.R
|
||||
import de.mm20.launcher2.ui.common.IconPicker
|
||||
import de.mm20.launcher2.ui.component.BottomSheetDialog
|
||||
import de.mm20.launcher2.ui.component.OutlinedTagsInputField
|
||||
import de.mm20.launcher2.ui.component.ShapedLauncherIcon
|
||||
@ -80,7 +81,6 @@ fun CustomizeSearchableSheet(
|
||||
) {
|
||||
val viewModel: CustomizeSearchableSheetVM =
|
||||
remember(searchable.key) { CustomizeSearchableSheetVM(searchable) }
|
||||
val context = LocalContext.current
|
||||
|
||||
val pickIcon by viewModel.isIconPickerOpen
|
||||
|
||||
@ -312,194 +312,13 @@ fun CustomizeSearchableSheet(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val iconSize = 48.dp
|
||||
val iconSizePx = iconSize.toPixels()
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
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),
|
||||
IconPicker(
|
||||
searchable = searchable,
|
||||
onSelect = {
|
||||
viewModel.pickIcon(it)
|
||||
},
|
||||
contentPadding = it,
|
||||
) {
|
||||
|
||||
item(span = { GridItemSpan(columns) }) {
|
||||
OutlinedTextField(
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Search,
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
enabled = !noPacksInstalled,
|
||||
placeholder = {
|
||||
Text(
|
||||
stringResource(
|
||||
if (noPacksInstalled) R.string.icon_picker_no_packs_installed else R.string.icon_picker_search_icon
|
||||
)
|
||||
)
|
||||
},
|
||||
value = query,
|
||||
onValueChange = {
|
||||
query = it
|
||||
scope.launch {
|
||||
viewModel.searchIcon(query, filterIconPack)
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
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 = { viewModel.pickIcon(it.customIcon) }
|
||||
)
|
||||
}
|
||||
|
||||
if (isSearching) {
|
||||
item(span = { GridItemSpan(columns) }) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier
|
||||
.padding(12.dp)
|
||||
.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -35,10 +35,6 @@ class CustomizeSearchableSheetVM(
|
||||
return iconService.getIcon(searchable, size)
|
||||
}
|
||||
|
||||
fun getIconSuggestions(size: Int) = flow {
|
||||
emit(iconService.getCustomIconSuggestions(searchable, size))
|
||||
}
|
||||
|
||||
fun openIconPicker() {
|
||||
isIconPickerOpen.value = true
|
||||
}
|
||||
@ -52,34 +48,6 @@ class CustomizeSearchableSheetVM(
|
||||
closeIconPicker()
|
||||
}
|
||||
|
||||
fun getDefaultIcon(size: Int) = flow {
|
||||
emit(iconService.getUncustomizedDefaultIcon(searchable, size))
|
||||
}
|
||||
|
||||
val iconSearchResults = mutableStateOf(emptyList<CustomIconWithPreview>())
|
||||
val isSearchingIcons = mutableStateOf(false)
|
||||
|
||||
val installedIconPacks = iconService.getInstalledIconPacks()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setCustomLabel(label: String) {
|
||||
if (label.isBlank()) {
|
||||
customAttributesRepository.clearCustomLabel(searchable)
|
||||
|
||||
@ -91,7 +91,6 @@ import de.mm20.launcher2.ui.component.dragndrop.rememberLazyDragAndDropListState
|
||||
import de.mm20.launcher2.ui.ktx.splitLeadingEmoji
|
||||
import de.mm20.launcher2.ui.ktx.toPixels
|
||||
import de.mm20.launcher2.ui.locals.LocalGridSettings
|
||||
import de.mm20.launcher2.ui.settings.tags.EditTagSheet
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
package de.mm20.launcher2.ui.settings.tags
|
||||
package de.mm20.launcher2.ui.launcher.sheets
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
@ -22,16 +23,19 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Apps
|
||||
import androidx.compose.material.icons.rounded.Check
|
||||
import androidx.compose.material.icons.rounded.Delete
|
||||
import androidx.compose.material.icons.rounded.EmojiEmotions
|
||||
import androidx.compose.material.icons.rounded.Tag
|
||||
import androidx.compose.material.icons.rounded.Warning
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
@ -39,6 +43,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@ -46,13 +51,17 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.graphics.PathEffect
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import de.mm20.launcher2.data.customattrs.CustomTextIcon
|
||||
import de.mm20.launcher2.icons.LauncherIcon
|
||||
import de.mm20.launcher2.search.Tag
|
||||
import de.mm20.launcher2.ui.R
|
||||
import de.mm20.launcher2.ui.common.IconPicker
|
||||
import de.mm20.launcher2.ui.component.BottomSheetDialog
|
||||
import de.mm20.launcher2.ui.component.ShapedLauncherIcon
|
||||
import de.mm20.launcher2.ui.component.SmallMessage
|
||||
@ -70,8 +79,10 @@ fun EditTagSheet(
|
||||
|
||||
val isCreatingNewTag = tag == null
|
||||
|
||||
val density = LocalDensity.current
|
||||
|
||||
LaunchedEffect(tag) {
|
||||
viewModel.init(tag)
|
||||
viewModel.init(tag, with(density) { 56.dp.toPx().toInt() })
|
||||
}
|
||||
|
||||
if (viewModel.loading) return
|
||||
@ -254,7 +265,7 @@ fun ListItem(
|
||||
@Composable
|
||||
fun CustomizeTag(viewModel: EditTagSheetVM, paddingValues: PaddingValues) {
|
||||
val iconSize = 32.dp.toPixels()
|
||||
val tagEmoji = viewModel.tagEmoji
|
||||
val tagIcon by remember(viewModel.tagCustomIcon) { viewModel.tagCustomIcon }.collectAsState()
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
@ -275,11 +286,8 @@ fun CustomizeTag(viewModel: EditTagSheetVM, paddingValues: PaddingValues) {
|
||||
}
|
||||
.size(56.dp)
|
||||
then (
|
||||
if (tagEmoji != null) {
|
||||
Modifier.background(
|
||||
MaterialTheme.colorScheme.secondaryContainer,
|
||||
CircleShape
|
||||
)
|
||||
if (tagIcon != null) {
|
||||
Modifier
|
||||
} else {
|
||||
Modifier.drawBehind {
|
||||
val w = with(density) { 2.dp.toPx() }
|
||||
@ -300,8 +308,13 @@ fun CustomizeTag(viewModel: EditTagSheetVM, paddingValues: PaddingValues) {
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (tagEmoji != null) {
|
||||
Text(tagEmoji)
|
||||
if (tagIcon != null) {
|
||||
var icon = remember(viewModel.tagIcon) { viewModel.tagIcon }.collectAsState(null)
|
||||
ShapedLauncherIcon(
|
||||
size = 56.dp,
|
||||
icon = { icon.value },
|
||||
shape = CircleShape,
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
Icons.Rounded.Tag,
|
||||
@ -395,37 +408,63 @@ fun PickIcon(
|
||||
viewModel: EditTagSheetVM,
|
||||
paddingValues: PaddingValues
|
||||
) {
|
||||
val icon by remember (viewModel.tagCustomIcon) { viewModel.tagCustomIcon }.collectAsState()
|
||||
val tag = Tag(viewModel.tagName)
|
||||
var selectedTabIndex = remember {
|
||||
mutableIntStateOf(
|
||||
when (icon) {
|
||||
is CustomTextIcon -> 1
|
||||
else -> 0
|
||||
}
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
OutlinedButton(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp),
|
||||
onClick = {
|
||||
viewModel.selectIcon(null)
|
||||
},
|
||||
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
|
||||
SingleChoiceSegmentedButtonRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Rounded.Delete,
|
||||
null,
|
||||
modifier = Modifier
|
||||
.padding(end = ButtonDefaults.IconSpacing)
|
||||
.size(ButtonDefaults.IconSize)
|
||||
SegmentedButton(
|
||||
selected = selectedTabIndex.intValue == 0,
|
||||
icon = { Icon(Icons.Rounded.Apps, null) },
|
||||
label = { Text("Icon") },
|
||||
onClick = { selectedTabIndex.intValue = 0 },
|
||||
shape = SegmentedButtonDefaults.itemShape(0, 2)
|
||||
)
|
||||
SegmentedButton(
|
||||
selected = selectedTabIndex.intValue == 1,
|
||||
icon = { Icon(Icons.Rounded.EmojiEmotions, null) },
|
||||
label = { Text("Emoji") },
|
||||
onClick = { selectedTabIndex.intValue = 1 },
|
||||
shape = SegmentedButtonDefaults.itemShape(1, 2)
|
||||
)
|
||||
Text(stringResource(R.string.reset_icon))
|
||||
}
|
||||
EmojiPicker(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
onEmojiSelected = {
|
||||
viewModel.selectIcon(it)
|
||||
AnimatedContent(
|
||||
selectedTabIndex.intValue,
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
) {
|
||||
when (it) {
|
||||
0 -> {
|
||||
IconPicker(
|
||||
searchable = tag,
|
||||
onSelect = { viewModel.selectIcon(it) },
|
||||
)
|
||||
}
|
||||
|
||||
1 -> {
|
||||
EmojiPicker(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
onEmojiSelected = {
|
||||
viewModel.selectIcon(CustomTextIcon(text = it))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,6 +1,5 @@
|
||||
package de.mm20.launcher2.ui.settings.tags
|
||||
package de.mm20.launcher2.ui.launcher.sheets
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
@ -9,18 +8,21 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import de.mm20.launcher2.applications.AppRepository
|
||||
import de.mm20.launcher2.data.customattrs.CustomTextIcon
|
||||
import de.mm20.launcher2.data.customattrs.CustomIcon
|
||||
import de.mm20.launcher2.icons.IconService
|
||||
import de.mm20.launcher2.icons.LauncherIcon
|
||||
import de.mm20.launcher2.icons.StaticLauncherIcon
|
||||
import de.mm20.launcher2.icons.TextLayer
|
||||
import de.mm20.launcher2.search.SavableSearchable
|
||||
import de.mm20.launcher2.search.SearchService
|
||||
import de.mm20.launcher2.search.Tag
|
||||
import de.mm20.launcher2.services.tags.TagsService
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
@ -35,7 +37,8 @@ class EditTagSheetVM : ViewModel(), KoinComponent {
|
||||
private var oldTagName by mutableStateOf<String?>(null)
|
||||
private var allTags by mutableStateOf(emptySet<String>())
|
||||
var tagName by mutableStateOf("")
|
||||
var tagEmoji by mutableStateOf<String?>(null)
|
||||
var tagCustomIcon = MutableStateFlow<CustomIcon?>(null)
|
||||
var tagIcon = emptyFlow<LauncherIcon?>()
|
||||
|
||||
var loading by mutableStateOf(true)
|
||||
|
||||
@ -50,7 +53,7 @@ class EditTagSheetVM : ViewModel(), KoinComponent {
|
||||
}
|
||||
|
||||
|
||||
fun init(tag: String?) {
|
||||
fun init(tag: String?, iconSize: Int) {
|
||||
loading = true
|
||||
this.oldTagName = tag
|
||||
this.tagName = tag ?: ""
|
||||
@ -59,8 +62,11 @@ class EditTagSheetVM : ViewModel(), KoinComponent {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
allTags = tagService.getAllTags().first().toSet()
|
||||
val items = if (tag != null) tagService.getTaggedItems(tag).first() else emptyList()
|
||||
val icon = if (tag != null) iconService.getIcon(Tag(tag), 0).first() else null
|
||||
tagEmoji = ((icon as? StaticLauncherIcon)?.foregroundLayer as? TextLayer)?.text
|
||||
tagCustomIcon.value = if (tag != null) iconService.getCustomIcon(Tag(tag)).first() else null
|
||||
tagIcon = tagCustomIcon.map {
|
||||
if (tag != null) iconService.resolveCustomIcon(Tag(tag), iconSize, it).first()
|
||||
else null
|
||||
}
|
||||
|
||||
val apps = appRepository.findMany().first { it.isNotEmpty() }.sorted()
|
||||
taggedItems = items
|
||||
@ -76,7 +82,7 @@ class EditTagSheetVM : ViewModel(), KoinComponent {
|
||||
fun save() {
|
||||
val oldName = oldTagName
|
||||
val newName = tagName
|
||||
val tagEmoji = tagEmoji
|
||||
val tagIcon = tagCustomIcon
|
||||
if (taggedItems.isEmpty() && oldName != null) tagService.deleteTag(oldName)
|
||||
else if (oldName != null) tagService.updateTag(oldName, newName = newName, items = taggedItems)
|
||||
else tagService.createTag(tagName, taggedItems)
|
||||
@ -84,8 +90,8 @@ class EditTagSheetVM : ViewModel(), KoinComponent {
|
||||
if (oldName != null && oldName != newName) {
|
||||
iconService.setCustomIcon(Tag(oldName), null)
|
||||
}
|
||||
if (tagEmoji != null) {
|
||||
iconService.setCustomIcon(Tag(newName), CustomTextIcon(tagEmoji))
|
||||
if (tagIcon != null) {
|
||||
iconService.setCustomIcon(Tag(newName), tagCustomIcon.value)
|
||||
} else {
|
||||
iconService.setCustomIcon(Tag(newName), null)
|
||||
}
|
||||
@ -114,8 +120,8 @@ class EditTagSheetVM : ViewModel(), KoinComponent {
|
||||
page = EditTagSheetPage.CustomizeTag
|
||||
}
|
||||
|
||||
fun selectIcon(emoji: String?) {
|
||||
tagEmoji = emoji
|
||||
fun selectIcon(icon: CustomIcon?) {
|
||||
tagCustomIcon.value = icon
|
||||
closeIconPicker()
|
||||
}
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
package de.mm20.launcher2.ui.launcher.sheets
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import de.mm20.launcher2.ui.settings.tags.EditTagSheet
|
||||
|
||||
@Composable
|
||||
fun LauncherBottomSheets() {
|
||||
|
||||
@ -5,7 +5,6 @@ import androidx.compose.material.icons.rounded.Add
|
||||
import androidx.compose.material.icons.rounded.ContentCopy
|
||||
import androidx.compose.material.icons.rounded.Delete
|
||||
import androidx.compose.material.icons.rounded.MoreVert
|
||||
import androidx.compose.material.icons.rounded.Tag
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
@ -26,7 +25,7 @@ import de.mm20.launcher2.ui.component.ShapedLauncherIcon
|
||||
import de.mm20.launcher2.ui.component.preferences.Preference
|
||||
import de.mm20.launcher2.ui.component.preferences.PreferenceCategory
|
||||
import de.mm20.launcher2.ui.component.preferences.PreferenceScreen
|
||||
import de.mm20.launcher2.ui.ktx.splitLeadingEmoji
|
||||
import de.mm20.launcher2.ui.launcher.sheets.EditTagSheet
|
||||
|
||||
@Composable
|
||||
fun TagsSettingsScreen() {
|
||||
|
||||
@ -38,6 +38,8 @@ import de.mm20.launcher2.ktx.isAtLeastApiLevel
|
||||
import de.mm20.launcher2.preferences.ui.IconSettings
|
||||
import de.mm20.launcher2.search.Application
|
||||
import de.mm20.launcher2.search.SavableSearchable
|
||||
import de.mm20.launcher2.search.Searchable
|
||||
import de.mm20.launcher2.search.Tag
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@ -48,6 +50,8 @@ import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMap
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
@ -143,6 +147,10 @@ class IconService(
|
||||
}
|
||||
}
|
||||
|
||||
fun getCustomIcon(searchable: SavableSearchable) : Flow<CustomIcon?> {
|
||||
return customAttributesRepository.getCustomIcon(searchable)
|
||||
}
|
||||
|
||||
|
||||
fun getIcon(searchable: SavableSearchable, size: Int): Flow<LauncherIcon?> {
|
||||
if (searchable is Application && searchable.isPrivate) {
|
||||
@ -151,22 +159,29 @@ class IconService(
|
||||
}
|
||||
}
|
||||
|
||||
val customIcon = customAttributesRepository.getCustomIcon(searchable)
|
||||
return combine(iconProviders, transformations, customIcon) { providers, transformations, ci ->
|
||||
var icon = cache.get(searchable.key + ci.hashCode() + providers.hashCode() + transformations.hashCode())
|
||||
val customIcon = getCustomIcon(searchable)
|
||||
|
||||
return customIcon.flatMapLatest {
|
||||
resolveCustomIcon(searchable, size, it)
|
||||
}
|
||||
}
|
||||
|
||||
fun resolveCustomIcon(searchable: SavableSearchable, size: Int, customIcon: CustomIcon?): Flow<LauncherIcon> {
|
||||
return combine(iconProviders, transformations) { providers, transformations ->
|
||||
var icon = cache.get(searchable.key + customIcon.hashCode() + providers.hashCode() + transformations.hashCode())
|
||||
if (icon != null) {
|
||||
return@combine icon
|
||||
}
|
||||
|
||||
val provs = if (ci != null) getProviders(ci) + providers else providers
|
||||
val transforms = getTransformations(ci) ?: transformations
|
||||
val provs = if (customIcon != null) getProviders(customIcon) + providers else providers
|
||||
val transforms = getTransformations(customIcon) ?: transformations
|
||||
|
||||
icon = provs.getFirstIcon(searchable, size)
|
||||
|
||||
if (icon != null) {
|
||||
icon = icon.transform(transforms)
|
||||
|
||||
cache.put(searchable.key + ci.hashCode() + providers.hashCode() + transformations.hashCode(), icon)
|
||||
cache.put(searchable.key + customIcon.hashCode() + providers.hashCode() + transformations.hashCode(), icon)
|
||||
}
|
||||
return@combine icon
|
||||
}
|
||||
@ -262,7 +277,11 @@ class IconService(
|
||||
|
||||
val defaultTransformations = transformations.first()
|
||||
|
||||
val transformationOptions = mutableListOf<CustomIcon>(UnmodifiedSystemDefaultIcon)
|
||||
val transformationOptions = mutableListOf<CustomIcon>()
|
||||
|
||||
if (searchable is Application) {
|
||||
transformationOptions.add(UnmodifiedSystemDefaultIcon)
|
||||
}
|
||||
|
||||
if (rawIcon is StaticLauncherIcon && rawIcon.backgroundLayer is TransparentLayer) {
|
||||
// Legacy icons that simply fill the entire canvas
|
||||
@ -325,13 +344,11 @@ class IconService(
|
||||
transformationOptions.add(
|
||||
ForceThemedIcon
|
||||
)
|
||||
} else {
|
||||
transformationOptions.add(
|
||||
ForceThemedIcon
|
||||
)
|
||||
}
|
||||
|
||||
providerOptions.add(DefaultPlaceholderIcon)
|
||||
if (searchable !is Tag) {
|
||||
providerOptions.add(DefaultPlaceholderIcon)
|
||||
}
|
||||
|
||||
suggestions.addAll(
|
||||
transformationOptions.map {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user