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="composableFile" value="true" />
<option name="previewFile" value="true" /> <option name="previewFile" value="true" />
</inspection_tool> </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"> <inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" /> <option name="composableFile" value="true" />
<option name="previewFile" 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.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Close
import androidx.compose.material3.FilterChipDefaults 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.TextLayer
import de.mm20.launcher2.icons.VectorLayer import de.mm20.launcher2.icons.VectorLayer
import de.mm20.launcher2.search.Tag import de.mm20.launcher2.search.Tag
import de.mm20.launcher2.ui.component.ShapedLauncherIcon
import de.mm20.launcher2.ui.ktx.toPixels import de.mm20.launcher2.ui.ktx.toPixels
import org.koin.androidx.compose.inject import org.koin.androidx.compose.inject
@ -113,21 +115,31 @@ fun TagChip(
onClick = onClick, onClick = onClick,
onLongClick = onLongClick onLongClick = onLongClick
) )
.padding(horizontal = 8.dp), .padding(start = 4.dp, end = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.Center,
) { ) {
val foregroundLayer = (icon as? StaticLauncherIcon)?.foregroundLayer val foregroundLayer = (icon as? StaticLauncherIcon)?.foregroundLayer
AnimatedVisibility(!compact || foregroundLayer is TextLayer) { AnimatedVisibility(!compact || foregroundLayer !is VectorLayer) {
if (foregroundLayer is TextLayer) { if (foregroundLayer is TextLayer) {
Text( Text(
text = foregroundLayer.text, text = foregroundLayer.text,
modifier = Modifier.width(FilterChipDefaults.IconSize), modifier = Modifier
.padding(start = 4.dp)
.width(FilterChipDefaults.IconSize),
textAlign = TextAlign.Center, 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( Icon(
modifier = Modifier modifier = Modifier
.padding(start = 4.dp)
.size(FilterChipDefaults.IconSize), .size(FilterChipDefaults.IconSize),
imageVector = foregroundLayer.vector, imageVector = foregroundLayer.vector,
contentDescription = null, contentDescription = null,
@ -140,7 +152,7 @@ fun TagChip(
tag.tag, tag.tag,
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
color = textColor, color = textColor,
modifier = Modifier.padding(horizontal = 8.dp) modifier = Modifier.padding(start = if (compact) 12.dp else 8.dp, end = 8.dp)
) )
} }
if (clearable) { if (clearable) {

View File

@ -65,6 +65,7 @@ import de.mm20.launcher2.search.CalendarEvent
import de.mm20.launcher2.search.SavableSearchable import de.mm20.launcher2.search.SavableSearchable
import de.mm20.launcher2.searchable.VisibilityLevel import de.mm20.launcher2.searchable.VisibilityLevel
import de.mm20.launcher2.ui.R 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.BottomSheetDialog
import de.mm20.launcher2.ui.component.OutlinedTagsInputField import de.mm20.launcher2.ui.component.OutlinedTagsInputField
import de.mm20.launcher2.ui.component.ShapedLauncherIcon import de.mm20.launcher2.ui.component.ShapedLauncherIcon
@ -80,7 +81,6 @@ fun CustomizeSearchableSheet(
) { ) {
val viewModel: CustomizeSearchableSheetVM = val viewModel: CustomizeSearchableSheetVM =
remember(searchable.key) { CustomizeSearchableSheetVM(searchable) } remember(searchable.key) { CustomizeSearchableSheetVM(searchable) }
val context = LocalContext.current
val pickIcon by viewModel.isIconPickerOpen val pickIcon by viewModel.isIconPickerOpen
@ -312,194 +312,13 @@ fun CustomizeSearchableSheet(
} }
} }
} else { } else {
val iconSize = 48.dp IconPicker(
val iconSizePx = iconSize.toPixels() searchable = searchable,
onSelect = {
val scope = rememberCoroutineScope() viewModel.pickIcon(it)
},
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 = 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) return iconService.getIcon(searchable, size)
} }
fun getIconSuggestions(size: Int) = flow {
emit(iconService.getCustomIconSuggestions(searchable, size))
}
fun openIconPicker() { fun openIconPicker() {
isIconPickerOpen.value = true isIconPickerOpen.value = true
} }
@ -52,34 +48,6 @@ class CustomizeSearchableSheetVM(
closeIconPicker() 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) { fun setCustomLabel(label: String) {
if (label.isBlank()) { if (label.isBlank()) {
customAttributesRepository.clearCustomLabel(searchable) 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.splitLeadingEmoji
import de.mm20.launcher2.ui.ktx.toPixels import de.mm20.launcher2.ui.ktx.toPixels
import de.mm20.launcher2.ui.locals.LocalGridSettings import de.mm20.launcher2.ui.locals.LocalGridSettings
import de.mm20.launcher2.ui.settings.tags.EditTagSheet
import kotlin.math.roundToInt import kotlin.math.roundToInt
@Composable @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.animation.AnimatedVisibility
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable 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.text.KeyboardActions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons 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.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.Tag
import androidx.compose.material.icons.rounded.Warning import androidx.compose.material.icons.rounded.Warning
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField 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.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@ -39,6 +43,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember 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
@ -46,13 +51,17 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import de.mm20.launcher2.data.customattrs.CustomTextIcon
import de.mm20.launcher2.icons.LauncherIcon import de.mm20.launcher2.icons.LauncherIcon
import de.mm20.launcher2.search.Tag
import de.mm20.launcher2.ui.R 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.BottomSheetDialog
import de.mm20.launcher2.ui.component.ShapedLauncherIcon import de.mm20.launcher2.ui.component.ShapedLauncherIcon
import de.mm20.launcher2.ui.component.SmallMessage import de.mm20.launcher2.ui.component.SmallMessage
@ -70,8 +79,10 @@ fun EditTagSheet(
val isCreatingNewTag = tag == null val isCreatingNewTag = tag == null
val density = LocalDensity.current
LaunchedEffect(tag) { LaunchedEffect(tag) {
viewModel.init(tag) viewModel.init(tag, with(density) { 56.dp.toPx().toInt() })
} }
if (viewModel.loading) return if (viewModel.loading) return
@ -254,7 +265,7 @@ fun ListItem(
@Composable @Composable
fun CustomizeTag(viewModel: EditTagSheetVM, paddingValues: PaddingValues) { fun CustomizeTag(viewModel: EditTagSheetVM, paddingValues: PaddingValues) {
val iconSize = 32.dp.toPixels() val iconSize = 32.dp.toPixels()
val tagEmoji = viewModel.tagEmoji val tagIcon by remember(viewModel.tagCustomIcon) { viewModel.tagCustomIcon }.collectAsState()
Column( Column(
modifier = Modifier modifier = Modifier
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
@ -275,11 +286,8 @@ fun CustomizeTag(viewModel: EditTagSheetVM, paddingValues: PaddingValues) {
} }
.size(56.dp) .size(56.dp)
then ( then (
if (tagEmoji != null) { if (tagIcon != null) {
Modifier.background( Modifier
MaterialTheme.colorScheme.secondaryContainer,
CircleShape
)
} else { } else {
Modifier.drawBehind { Modifier.drawBehind {
val w = with(density) { 2.dp.toPx() } val w = with(density) { 2.dp.toPx() }
@ -300,8 +308,13 @@ fun CustomizeTag(viewModel: EditTagSheetVM, paddingValues: PaddingValues) {
), ),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
if (tagEmoji != null) { if (tagIcon != null) {
Text(tagEmoji) var icon = remember(viewModel.tagIcon) { viewModel.tagIcon }.collectAsState(null)
ShapedLauncherIcon(
size = 56.dp,
icon = { icon.value },
shape = CircleShape,
)
} else { } else {
Icon( Icon(
Icons.Rounded.Tag, Icons.Rounded.Tag,
@ -395,37 +408,63 @@ fun PickIcon(
viewModel: EditTagSheetVM, viewModel: EditTagSheetVM,
paddingValues: PaddingValues 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( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(paddingValues) .padding(paddingValues)
) { ) {
OutlinedButton( SingleChoiceSegmentedButtonRow(
modifier = Modifier modifier = Modifier.fillMaxWidth(),
.fillMaxWidth()
.padding(bottom = 16.dp),
onClick = {
viewModel.selectIcon(null)
},
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
) { ) {
Icon( SegmentedButton(
Icons.Rounded.Delete, selected = selectedTabIndex.intValue == 0,
null, icon = { Icon(Icons.Rounded.Apps, null) },
modifier = Modifier label = { Text("Icon") },
.padding(end = ButtonDefaults.IconSpacing) onClick = { selectedTabIndex.intValue = 0 },
.size(ButtonDefaults.IconSize) 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( AnimatedContent(
modifier = Modifier selectedTabIndex.intValue,
.fillMaxWidth() modifier = Modifier.padding(top = 16.dp)
.weight(1f), ) {
onEmojiSelected = { when (it) {
viewModel.selectIcon(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.Stable
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -9,18 +8,21 @@ import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import de.mm20.launcher2.applications.AppRepository 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.IconService
import de.mm20.launcher2.icons.LauncherIcon 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.SavableSearchable
import de.mm20.launcher2.search.SearchService import de.mm20.launcher2.search.SearchService
import de.mm20.launcher2.search.Tag import de.mm20.launcher2.search.Tag
import de.mm20.launcher2.services.tags.TagsService import de.mm20.launcher2.services.tags.TagsService
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.first 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 kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
@ -35,7 +37,8 @@ class EditTagSheetVM : ViewModel(), KoinComponent {
private var oldTagName by mutableStateOf<String?>(null) private var oldTagName by mutableStateOf<String?>(null)
private var allTags by mutableStateOf(emptySet<String>()) private var allTags by mutableStateOf(emptySet<String>())
var tagName by mutableStateOf("") var tagName by mutableStateOf("")
var tagEmoji by mutableStateOf<String?>(null) var tagCustomIcon = MutableStateFlow<CustomIcon?>(null)
var tagIcon = emptyFlow<LauncherIcon?>()
var loading by mutableStateOf(true) 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 loading = true
this.oldTagName = tag this.oldTagName = tag
this.tagName = tag ?: "" this.tagName = tag ?: ""
@ -59,8 +62,11 @@ class EditTagSheetVM : ViewModel(), KoinComponent {
viewModelScope.launch(Dispatchers.Default) { viewModelScope.launch(Dispatchers.Default) {
allTags = tagService.getAllTags().first().toSet() allTags = tagService.getAllTags().first().toSet()
val items = if (tag != null) tagService.getTaggedItems(tag).first() else emptyList() val items = if (tag != null) tagService.getTaggedItems(tag).first() else emptyList()
val icon = if (tag != null) iconService.getIcon(Tag(tag), 0).first() else null tagCustomIcon.value = if (tag != null) iconService.getCustomIcon(Tag(tag)).first() else null
tagEmoji = ((icon as? StaticLauncherIcon)?.foregroundLayer as? TextLayer)?.text tagIcon = tagCustomIcon.map {
if (tag != null) iconService.resolveCustomIcon(Tag(tag), iconSize, it).first()
else null
}
val apps = appRepository.findMany().first { it.isNotEmpty() }.sorted() val apps = appRepository.findMany().first { it.isNotEmpty() }.sorted()
taggedItems = items taggedItems = items
@ -76,7 +82,7 @@ class EditTagSheetVM : ViewModel(), KoinComponent {
fun save() { fun save() {
val oldName = oldTagName val oldName = oldTagName
val newName = tagName val newName = tagName
val tagEmoji = tagEmoji val tagIcon = tagCustomIcon
if (taggedItems.isEmpty() && oldName != null) tagService.deleteTag(oldName) if (taggedItems.isEmpty() && oldName != null) tagService.deleteTag(oldName)
else if (oldName != null) tagService.updateTag(oldName, newName = newName, items = taggedItems) else if (oldName != null) tagService.updateTag(oldName, newName = newName, items = taggedItems)
else tagService.createTag(tagName, taggedItems) else tagService.createTag(tagName, taggedItems)
@ -84,8 +90,8 @@ class EditTagSheetVM : ViewModel(), KoinComponent {
if (oldName != null && oldName != newName) { if (oldName != null && oldName != newName) {
iconService.setCustomIcon(Tag(oldName), null) iconService.setCustomIcon(Tag(oldName), null)
} }
if (tagEmoji != null) { if (tagIcon != null) {
iconService.setCustomIcon(Tag(newName), CustomTextIcon(tagEmoji)) iconService.setCustomIcon(Tag(newName), tagCustomIcon.value)
} else { } else {
iconService.setCustomIcon(Tag(newName), null) iconService.setCustomIcon(Tag(newName), null)
} }
@ -114,8 +120,8 @@ class EditTagSheetVM : ViewModel(), KoinComponent {
page = EditTagSheetPage.CustomizeTag page = EditTagSheetPage.CustomizeTag
} }
fun selectIcon(emoji: String?) { fun selectIcon(icon: CustomIcon?) {
tagEmoji = emoji tagCustomIcon.value = icon
closeIconPicker() closeIconPicker()
} }

View File

@ -1,7 +1,6 @@
package de.mm20.launcher2.ui.launcher.sheets package de.mm20.launcher2.ui.launcher.sheets
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import de.mm20.launcher2.ui.settings.tags.EditTagSheet
@Composable @Composable
fun LauncherBottomSheets() { 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.ContentCopy
import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.material.icons.rounded.MoreVert
import androidx.compose.material.icons.rounded.Tag
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FloatingActionButton 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.Preference
import de.mm20.launcher2.ui.component.preferences.PreferenceCategory import de.mm20.launcher2.ui.component.preferences.PreferenceCategory
import de.mm20.launcher2.ui.component.preferences.PreferenceScreen import de.mm20.launcher2.ui.component.preferences.PreferenceScreen
import de.mm20.launcher2.ui.ktx.splitLeadingEmoji import de.mm20.launcher2.ui.launcher.sheets.EditTagSheet
@Composable @Composable
fun TagsSettingsScreen() { 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.preferences.ui.IconSettings
import de.mm20.launcher2.search.Application import de.mm20.launcher2.search.Application
import de.mm20.launcher2.search.SavableSearchable 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.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -48,6 +50,8 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMap
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map 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?> { fun getIcon(searchable: SavableSearchable, size: Int): Flow<LauncherIcon?> {
if (searchable is Application && searchable.isPrivate) { if (searchable is Application && searchable.isPrivate) {
@ -151,22 +159,29 @@ class IconService(
} }
} }
val customIcon = customAttributesRepository.getCustomIcon(searchable) val customIcon = getCustomIcon(searchable)
return combine(iconProviders, transformations, customIcon) { providers, transformations, ci ->
var icon = cache.get(searchable.key + ci.hashCode() + providers.hashCode() + transformations.hashCode()) 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) { if (icon != null) {
return@combine icon return@combine icon
} }
val provs = if (ci != null) getProviders(ci) + providers else providers val provs = if (customIcon != null) getProviders(customIcon) + providers else providers
val transforms = getTransformations(ci) ?: transformations val transforms = getTransformations(customIcon) ?: transformations
icon = provs.getFirstIcon(searchable, size) icon = provs.getFirstIcon(searchable, size)
if (icon != null) { if (icon != null) {
icon = icon.transform(transforms) 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 return@combine icon
} }
@ -262,7 +277,11 @@ class IconService(
val defaultTransformations = transformations.first() 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) { if (rawIcon is StaticLauncherIcon && rawIcon.backgroundLayer is TransparentLayer) {
// Legacy icons that simply fill the entire canvas // Legacy icons that simply fill the entire canvas
@ -325,13 +344,11 @@ class IconService(
transformationOptions.add( transformationOptions.add(
ForceThemedIcon ForceThemedIcon
) )
} else {
transformationOptions.add(
ForceThemedIcon
)
} }
providerOptions.add(DefaultPlaceholderIcon) if (searchable !is Tag) {
providerOptions.add(DefaultPlaceholderIcon)
}
suggestions.addAll( suggestions.addAll(
transformationOptions.map { transformationOptions.map {