Add support for custom icon pack tag icons

This commit is contained in:
MM20 2024-12-03 19:08:50 +01:00
parent 25551859c5
commit ca36f3cac1
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
12 changed files with 469 additions and 289 deletions

View File

@ -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" />

View 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)
)
}
}
}
}
}
}

View File

@ -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
}
}
}
}

View File

@ -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) {

View File

@ -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)
)
}
}
}
}
}
)
}
}
}

View File

@ -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)

View File

@ -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

View File

@ -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))
},
)
}
}
)
}
}
}

View File

@ -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()
}

View File

@ -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() {

View File

@ -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() {

View File

@ -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 {