Improve tag emoji assignment

This commit is contained in:
MM20 2024-11-11 20:48:04 +01:00
parent 76ee00f404
commit a21dde5741
No known key found for this signature in database
GPG Key ID: 0B61A8F2DEAFA389
20 changed files with 569 additions and 69 deletions

View File

@ -39,6 +39,7 @@
</inspection_tool>
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />

View File

@ -100,6 +100,7 @@ dependencies {
implementation(libs.androidx.appcompat)
implementation(libs.androidx.browser)
implementation(libs.androidx.emojipicker)
implementation(libs.androidx.lifecycle.viewmodelcompose)
implementation(libs.androidx.lifecycle.runtimecompose)

View File

@ -1,5 +1,6 @@
package de.mm20.launcher2.ui.common
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColor
import androidx.compose.animation.core.animateDp
import androidx.compose.animation.core.tween
@ -8,16 +9,20 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
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.material.icons.Icons
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.Tag
import androidx.compose.material.minimumInteractiveComponentSize
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.InputChipDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.minimumInteractiveComponentSize
@ -30,8 +35,13 @@ import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import de.mm20.launcher2.icons.IconService
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.ktx.splitLeadingEmoji
import de.mm20.launcher2.ui.ktx.toPixels
import org.koin.androidx.compose.inject
@Composable
fun TagChip(
@ -39,15 +49,12 @@ fun TagChip(
tag: Tag,
selected: Boolean = false,
dragged: Boolean = false,
compact: Boolean = false,
onClick: () -> Unit = {},
onLongClick: (() -> Unit)? = null,
clearable: Boolean = false,
onClear: (() -> Unit)? = null,
) {
val (emoji, tagName) = remember(tag.tag) {
tag.tag.splitLeadingEmoji()
}
val shape = MaterialTheme.shapes.small
val transition = updateTransition(
@ -56,10 +63,10 @@ fun TagChip(
val backgroundColor by transition.animateColor(
transitionSpec = {
if (targetState == 0) tween(100, 200) else tween(100, 0)
if (targetState == 0 && initialState >= 2) tween(100, 200) else tween(100, 0)
}
) {
when(it) {
when (it) {
0 -> Color.Transparent
2 -> MaterialTheme.colorScheme.surfaceContainerLow
else -> MaterialTheme.colorScheme.secondaryContainer
@ -79,17 +86,28 @@ fun TagChip(
}
val elevation by transition.animateDp(
transitionSpec = {
if (targetState >=2) tween(100, 200) else tween(100, 0)
if (targetState >= 2) tween(100, 200) else tween(100, 0)
}
) {
if (it >= 2) 8.dp else 0.dp
}
val iconService by inject<IconService>()
val iconSize = InputChipDefaults.AvatarSize.toPixels()
val icon by remember(tag, iconSize) {
iconService.getIcon(
tag,
iconSize.toInt()
)
}.collectAsState(null)
Row(
modifier = modifier
.minimumInteractiveComponentSize()
.padding(vertical = 8.dp)
.height(32.dp)
.widthIn(min = 48.dp)
.shadow(elevation, shape, true)
.border(borderWidth, borderColor, shape)
.background(backgroundColor)
@ -99,28 +117,34 @@ fun TagChip(
)
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
if (emoji != null && tagName != null) {
val foregroundLayer = (icon as? StaticLauncherIcon)?.foregroundLayer
AnimatedVisibility(foregroundLayer != null && (!compact || foregroundLayer is TextLayer)) {
if (foregroundLayer is TextLayer) {
Text(
emoji,
text = foregroundLayer.text,
modifier = Modifier.width(FilterChipDefaults.IconSize),
textAlign = TextAlign.Center,
)
} else {
} else if (foregroundLayer is VectorLayer && !compact) {
Icon(
modifier = Modifier
.size(FilterChipDefaults.IconSize),
imageVector = Icons.Rounded.Tag,
imageVector = foregroundLayer.vector,
contentDescription = null,
tint = iconColor
)
}
}
AnimatedVisibility(foregroundLayer != null && (!compact || foregroundLayer is VectorLayer)) {
Text(
tagName ?: emoji ?: "",
tag.tag,
style = MaterialTheme.typography.labelLarge,
color = textColor,
modifier = Modifier.padding(horizontal = 8.dp)
)
}
if (clearable) {
Icon(
modifier = Modifier

View File

@ -84,7 +84,7 @@ fun Icons(actions: List<ToolbarAction>, slots: Int) {
val action = actions[i]
val tooltipState = rememberTooltipState()
TooltipBox(
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(),
tooltip = {
PlainTooltip {
Text(action.label)

View File

@ -186,7 +186,7 @@ fun LazyVerticalDragAndDropGrid(
horizontalArrangement,
flingBehavior,
userScrollEnabled,
content,
content = content,
)
}
@ -215,7 +215,7 @@ fun LazyHorizontalDragAndDropGrid(
verticalArrangement,
flingBehavior,
userScrollEnabled,
content,
content = content,
)
}

View File

@ -0,0 +1,206 @@
package de.mm20.launcher2.ui.component.emojipicker
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.Icon
import androidx.compose.material3.IconToggleButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.RichTooltip
import androidx.compose.material3.Text
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import de.mm20.launcher2.ui.R
import kotlinx.coroutines.launch
@Composable
fun EmojiPicker(
modifier: Modifier = Modifier,
onEmojiSelected: (String) -> Unit = {},
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var selectedCategory = remember {
mutableIntStateOf(0)
}
val categories = remember {
val typedArray =
context.resources.obtainTypedArray(R.array.emoji_by_category_raw_resources_gender_inclusive)
IntArray(typedArray.length()) { typedArray.getResourceId(it, 0) }.also {
typedArray.recycle()
}
}
val categoryNames = stringArrayResource(R.array.category_names)
val categoryIcons = remember {
val typedArray = context.resources.obtainTypedArray(R.array.emoji_categories_icons)
IntArray(typedArray.length()) { typedArray.getResourceId(it, 0) }.also {
typedArray.recycle()
}
}
val emojis = remember {
mutableStateMapOf<Int, Array<Array<String>>>()
}
LaunchedEffect(selectedCategory.intValue) {
if (selectedCategory.intValue in emojis) return@LaunchedEffect
val categoryEmojis =
context.resources.openRawResource(categories[selectedCategory.intValue])
.bufferedReader().use {
it.readLines().map { line ->
line.split(",").toTypedArray()
}.toTypedArray()
}
emojis[selectedCategory.intValue] = categoryEmojis
}
LazyVerticalGrid(
GridCells.Adaptive(48.dp),
modifier = modifier,
) {
stickyHeader {
Row(
modifier = Modifier
.padding(bottom = 8.dp)
.background(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = MaterialTheme.shapes.medium
)
.clip(MaterialTheme.shapes.medium)
.horizontalScroll(rememberScrollState())
) {
for (i in categories.indices) {
IconToggleButton(
checked = selectedCategory.intValue == i,
onCheckedChange = {
if (it) selectedCategory.intValue = i
},
) {
if (i < categoryIcons.size && categoryIcons[i] != 0) {
Icon(
painterResource(id = categoryIcons[i]),
contentDescription = categoryNames[i]
)
}
}
}
}
}
items(
emojis[selectedCategory.intValue]?.size ?: 0,
key = { selectedCategory.intValue.toString() + "-" + emojis[selectedCategory.intValue]?.get(it)?.get(0) }
) { index ->
val emoji = emojis[selectedCategory.intValue]?.get(index) ?: return@items
if (emoji.size > 1) {
val tooltipState = rememberTooltipState(
isPersistent = true,
)
TooltipBox(
modifier = Modifier.animateItem(),
positionProvider = TooltipDefaults
.rememberTooltipPositionProvider(),
state = tooltipState,
tooltip = {
RichTooltip {
Row(
modifier = Modifier.horizontalScroll(rememberScrollState())
) {
for (e in emoji) {
EmojiButton(
emoji = e,
onClick = {
onEmojiSelected(e)
},
)
}
}
}
},
) {
EmojiButton(
emoji = emoji[0],
onClick = {
onEmojiSelected(emoji[0])
},
onLongClick = {
scope.launch {
tooltipState.show()
}
},
)
}
} else {
EmojiButton(
modifier = Modifier.animateItem(),
emoji = emoji[0],
onClick = {
onEmojiSelected(emoji[0])
},
)
}
}
}
}
@Composable
private fun EmojiButton(
emoji: String,
onClick: () -> Unit,
onLongClick: (() -> Unit)? = null,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.clip(MaterialTheme.shapes.medium)
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
)
.minimumInteractiveComponentSize(),
contentAlignment = Alignment.Center,
) {
Text(
text = emoji,
style = MaterialTheme.typography.titleLarge,
)
}
}
@Preview
@Composable
fun EmojiPickerPreview() {
EmojiPicker(
modifier = Modifier
.height(500.dp)
.fillMaxWidth()
)
}

View File

@ -398,7 +398,7 @@ fun CustomActions(
val action = actions.customActions[i]
val tooltipState = rememberTooltipState()
TooltipBox(
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(),
state = tooltipState,
tooltip = {
PlainTooltip {
@ -458,7 +458,7 @@ fun CustomActions(
val action = actions.customActions.last()
TooltipBox(
state = tooltipState,
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(),
tooltip = {
PlainTooltip {
Text(action.name.toString())

View File

@ -184,7 +184,7 @@ fun NotesWidget(
val tooltipState = rememberTooltipState()
TooltipBox(
state = tooltipState,
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(),
tooltip = {
PlainTooltip {
Text(
@ -227,7 +227,7 @@ fun NotesWidget(
if (isLastWidget == false) {
TooltipBox(
state = tooltipState,
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(),
tooltip = {
PlainTooltip {
Text(stringResource(R.string.notes_widget_action_dismiss))

View File

@ -56,7 +56,7 @@ fun CorePaletteColorPreference(
TooltipBox(
state = tooltipState,
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(),
tooltip = {
PlainTooltip {
Text(title)

View File

@ -85,7 +85,7 @@ fun ThemeColorPreference(
TooltipBox(
state = tooltipState,
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(),
tooltip = { PlainTooltip { Text(title) } }
) {
ColorSwatch(

View File

@ -6,6 +6,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
@ -22,8 +23,11 @@ import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.Delete
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
@ -38,6 +42,10 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
@ -48,6 +56,7 @@ import de.mm20.launcher2.ui.R
import de.mm20.launcher2.ui.component.BottomSheetDialog
import de.mm20.launcher2.ui.component.ShapedLauncherIcon
import de.mm20.launcher2.ui.component.SmallMessage
import de.mm20.launcher2.ui.component.emojipicker.EmojiPicker
import de.mm20.launcher2.ui.ktx.toPixels
import de.mm20.launcher2.ui.locals.LocalGridSettings
@ -87,12 +96,18 @@ fun EditTagSheet(
Text(stringResource(R.string.action_next))
}
}
} else {
} else if (viewModel.page == EditTagSheetPage.PickItems) {
{
OutlinedButton(onClick = { viewModel.closeItemPicker() }) {
Text(stringResource(id = R.string.ok))
}
}
} else {
{
OutlinedButton(onClick = { viewModel.closeIconPicker() }) {
Text(stringResource(id = android.R.string.cancel))
}
}
},
onDismissRequest = {
if (viewModel.page == EditTagSheetPage.CustomizeTag) {
@ -109,6 +124,7 @@ fun EditTagSheet(
EditTagSheetPage.CreateTag -> CreateNewTagPage(viewModel, it)
EditTagSheetPage.PickItems -> PickItems(viewModel, it)
EditTagSheetPage.CustomizeTag -> CustomizeTag(viewModel, it)
EditTagSheetPage.PickIcon -> PickIcon(viewModel, it)
}
}
}
@ -238,19 +254,72 @@ fun ListItem(
@Composable
fun CustomizeTag(viewModel: EditTagSheetVM, paddingValues: PaddingValues) {
val iconSize = 32.dp.toPixels()
val tagEmoji = viewModel.tagEmoji
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.fillMaxWidth()
.padding(paddingValues)
) {
OutlinedTextField(
Row(
modifier = Modifier.fillMaxWidth(),
singleLine = true,
label = { Text(stringResource(R.string.tag_name)) },
value = viewModel.tagName,
onValueChange = { viewModel.tagName = it }
) {
val outlineVariant = MaterialTheme.colorScheme.outlineVariant
Box(
modifier = Modifier
.padding(end = 16.dp)
.clip(CircleShape)
.clickable {
viewModel.openIconPicker()
}
.size(56.dp)
then (
if (tagEmoji != null) {
Modifier.background(
MaterialTheme.colorScheme.secondaryContainer,
CircleShape
)
} else {
Modifier.drawBehind {
val w = with(density) { 2.dp.toPx() }
drawCircle(
color = outlineVariant,
style = Stroke(
width = w,
pathEffect = PathEffect.dashPathEffect(
floatArrayOf(
w * 2,
w * 2
)
)
)
)
}
}
),
contentAlignment = Alignment.Center,
) {
if (tagEmoji != null) {
Text(tagEmoji)
} else {
Icon(
Icons.Rounded.Tag,
null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
OutlinedTextField(
modifier = Modifier.weight(1f),
singleLine = true,
placeholder = { Text(stringResource(R.string.tag_name)) },
value = viewModel.tagName,
onValueChange = { viewModel.tagName = it },
)
}
val icon1 = remember(viewModel.taggedItems.getOrNull(0)?.key) {
viewModel.taggedItems.getOrNull(0)?.let {
viewModel.getIcon(it, iconSize.toInt())
@ -286,23 +355,29 @@ fun CustomizeTag(viewModel: EditTagSheetVM, paddingValues: PaddingValues) {
.height(32.dp),
contentAlignment = Alignment.CenterEnd
) {
icon1?.value?.let {
ShapedLauncherIcon(
size = 32.dp,
icon = { icon1?.value },
icon = { it },
modifier = Modifier.offset(x = -0.dp)
)
}
icon2?.value?.let {
ShapedLauncherIcon(
size = 32.dp,
icon = { icon2?.value },
icon = { it },
modifier = Modifier.offset(x = -16.dp)
)
}
icon3?.value?.let {
ShapedLauncherIcon(
size = 32.dp,
icon = { icon3?.value },
icon = { it },
modifier = Modifier.offset(x = -32.dp)
)
}
}
}
AnimatedVisibility(viewModel.tagNameExists || viewModel.taggedItems.isEmpty()) {
SmallMessage(
modifier = Modifier.fillMaxWidth(),
@ -314,3 +389,43 @@ fun CustomizeTag(viewModel: EditTagSheetVM, paddingValues: PaddingValues) {
}
}
}
@Composable
fun PickIcon(
viewModel: EditTagSheetVM,
paddingValues: PaddingValues
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(paddingValues)
) {
OutlinedButton(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
onClick = {
viewModel.selectIcon(null)
},
contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
) {
Icon(
Icons.Rounded.Delete,
null,
modifier = Modifier
.padding(end = ButtonDefaults.IconSpacing)
.size(ButtonDefaults.IconSize)
)
Text(stringResource(R.string.reset_icon))
}
EmojiPicker(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
onEmojiSelected = {
viewModel.selectIcon(it)
}
)
}
}

View File

@ -9,10 +9,14 @@ 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.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
@ -31,6 +35,7 @@ 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 loading by mutableStateOf(true)
@ -46,7 +51,6 @@ class EditTagSheetVM : ViewModel(), KoinComponent {
fun init(tag: String?) {
Log.d("MM20", "Init with tag: $tag")
loading = true
this.oldTagName = tag
this.tagName = tag ?: ""
@ -55,6 +59,9 @@ 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
val apps = appRepository.findMany().first { it.isNotEmpty() }.sorted()
taggedItems = items
taggableApps = apps.map { app -> TaggableItem(app, items.any { app.key == it.key }) }
@ -69,9 +76,19 @@ class EditTagSheetVM : ViewModel(), KoinComponent {
fun save() {
val oldName = oldTagName
val newName = tagName
val tagEmoji = tagEmoji
if (taggedItems.isEmpty() && oldName != null) tagService.deleteTag(oldName)
else if (oldName != null) tagService.updateTag(oldName, newName = newName, items = taggedItems)
else tagService.createTag(tagName, taggedItems)
if (oldName != null && oldName != newName) {
iconService.setCustomIcon(Tag(oldName), null)
}
if (tagEmoji != null) {
iconService.setCustomIcon(Tag(newName), CustomTextIcon(tagEmoji))
} else {
iconService.setCustomIcon(Tag(newName), null)
}
loading = true
}
@ -89,6 +106,19 @@ class EditTagSheetVM : ViewModel(), KoinComponent {
page = EditTagSheetPage.PickItems
}
fun openIconPicker() {
page = EditTagSheetPage.PickIcon
}
fun closeIconPicker() {
page = EditTagSheetPage.CustomizeTag
}
fun selectIcon(emoji: String?) {
tagEmoji = emoji
closeIconPicker()
}
fun closeItemPicker() {
page = EditTagSheetPage.CustomizeTag
}
@ -114,6 +144,7 @@ enum class EditTagSheetPage {
CreateTag,
PickItems,
CustomizeTag,
PickIcon,
}
@Stable

View File

@ -1002,4 +1002,5 @@
<item quantity="one">%1$s list selected</item>
<item quantity="other">%1$s lists selected</item>
</plurals>
<string name="reset_icon">Reset icon</string>
</resources>

View File

@ -119,6 +119,12 @@ sealed class CustomIcon : CustomAttribute {
}
"force_themed_icon" -> ForceThemedIcon
"default_placeholder_icon" -> DefaultPlaceholderIcon
"custom_text_icon" -> {
CustomTextIcon(
text = payload.getString("text"),
color = payload.getInt("color")
)
}
else -> null
}
}
@ -234,3 +240,16 @@ data object DefaultPlaceholderIcon: CustomIcon() {
).toString()
}
}
data class CustomTextIcon(
val text: String,
val color: Int = 0,
): CustomIcon() {
override fun toDatabaseValue(): String {
return jsonObjectOf(
"type" to "custom_text_icon",
"text" to text,
"color" to color,
).toString()
}
}

View File

@ -49,6 +49,7 @@ dependencies {
ksp(libs.androidx.roomcompiler)
api(libs.androidx.room)
implementation(libs.koin.android)
implementation(libs.emoji4j)
implementation(project(":core:i18n"))
implementation(project(":core:ktx"))

View File

@ -36,6 +36,7 @@ import de.mm20.launcher2.database.migrations.Migration_22_23
import de.mm20.launcher2.database.migrations.Migration_23_24
import de.mm20.launcher2.database.migrations.Migration_24_25
import de.mm20.launcher2.database.migrations.Migration_25_26
import de.mm20.launcher2.database.migrations.Migration_26_27
import de.mm20.launcher2.database.migrations.Migration_6_7
import de.mm20.launcher2.database.migrations.Migration_7_8
import de.mm20.launcher2.database.migrations.Migration_8_9
@ -55,7 +56,7 @@ import java.util.UUID
SearchActionEntity::class,
ThemeEntity::class,
PluginEntity::class,
], version = 26, exportSchema = true
], version = 27, exportSchema = true
)
@TypeConverters(ComponentNameConverter::class)
abstract class AppDatabase : RoomDatabase() {
@ -154,6 +155,7 @@ abstract class AppDatabase : RoomDatabase() {
Migration_23_24(),
Migration_24_25(),
Migration_25_26(),
Migration_26_27(),
).build()
if (_instance == null) _instance = instance
return instance

View File

@ -0,0 +1,72 @@
package de.mm20.launcher2.database.migrations
import android.content.ContentValues
import android.database.sqlite.SQLiteDatabase
import android.util.Log
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.sigpwned.emoji4j.core.Grapheme
import com.sigpwned.emoji4j.core.GraphemeMatcher
import de.mm20.launcher2.ktx.jsonObjectOf
class Migration_26_27 : Migration(26, 27) {
override fun migrate(db: SupportSQLiteDatabase) {
Log.d("Tags-Migration", "Migrating tags")
db.query("SELECT DISTINCT value FROM CustomAttributes WHERE type = 'tag'").use { cursor ->
while (cursor.moveToNext()) {
val oldName = cursor.getString(0)
val (emoji, newName) = oldName.splitLeadingEmoji()
if (emoji != null) {
db.update(
"CustomAttributes",
SQLiteDatabase.CONFLICT_FAIL,
ContentValues().apply {
put("value", newName)
}, "type = 'tag' AND value = ?",
arrayOf(oldName)
)
db.insert(
"CustomAttributes",
SQLiteDatabase.CONFLICT_REPLACE,
ContentValues().apply {
put("key", "tag://$newName")
put("type", "icon")
put("value", jsonObjectOf(
"type" to "custom_text_icon",
"text" to emoji,
"color" to 0,
).toString())
}
)
db.update(
"Searchable",
SQLiteDatabase.CONFLICT_IGNORE,
ContentValues().apply {
put("key", "tag://$newName")
put("searchable", jsonObjectOf(
"tag" to newName
).toString())
},
"key = ?",
arrayOf("tag://$oldName")
)
}
}
}
}
fun String.splitLeadingEmoji(): Pair<String?, String> {
val matcher = GraphemeMatcher(this)
if (!matcher.find()) return null to this.trim()
val grapheme = matcher.grapheme()
if (grapheme?.type == Grapheme.Type.EMOJI && matcher.start() == 0) {
val end = matcher.end()
val emoji = this.substring(0, end)
val tagName = this.substring(end).takeIf { it.isNotBlank() }
if (tagName == null) return emoji to emoji
return emoji to tagName
}
return null to this.trim()
}
}

View File

@ -11,6 +11,7 @@ import de.mm20.launcher2.data.customattrs.AdaptifiedLegacyIcon
import de.mm20.launcher2.data.customattrs.CustomAttributesRepository
import de.mm20.launcher2.data.customattrs.CustomIcon
import de.mm20.launcher2.data.customattrs.CustomIconPackIcon
import de.mm20.launcher2.data.customattrs.CustomTextIcon
import de.mm20.launcher2.data.customattrs.LegacyCustomIconPackIcon
import de.mm20.launcher2.data.customattrs.CustomThemedIcon
import de.mm20.launcher2.data.customattrs.DefaultPlaceholderIcon
@ -19,6 +20,7 @@ import de.mm20.launcher2.data.customattrs.UnmodifiedSystemDefaultIcon
import de.mm20.launcher2.icons.providers.CalendarIconProvider
import de.mm20.launcher2.icons.providers.CompatIconProvider
import de.mm20.launcher2.icons.providers.CustomIconPackIconProvider
import de.mm20.launcher2.icons.providers.CustomTextIconProvider
import de.mm20.launcher2.icons.providers.LegacyCustomIconPackIconProvider
import de.mm20.launcher2.icons.providers.CustomThemedIconProvider
import de.mm20.launcher2.icons.providers.DynamicClockIconProvider
@ -203,6 +205,9 @@ class IconService(
if (customIcon is DefaultPlaceholderIcon) {
return iconProviders.value.lastOrNull()?.let { listOf(it) } ?: emptyList()
}
if (customIcon is CustomTextIcon) {
return listOf(CustomTextIconProvider(customIcon))
}
return emptyList()
}

View File

@ -0,0 +1,29 @@
package de.mm20.launcher2.icons.providers
import de.mm20.launcher2.data.customattrs.CustomIconPackIcon
import de.mm20.launcher2.data.customattrs.CustomTextIcon
import de.mm20.launcher2.icons.ColorLayer
import de.mm20.launcher2.icons.LauncherIcon
import de.mm20.launcher2.icons.StaticLauncherIcon
import de.mm20.launcher2.icons.TextLayer
import de.mm20.launcher2.search.SavableSearchable
class CustomTextIconProvider(
private val customIcon: CustomTextIcon,
): IconProvider {
override suspend fun getIcon(
searchable: SavableSearchable,
size: Int
): LauncherIcon? {
return StaticLauncherIcon(
foregroundLayer = TextLayer(
text = customIcon.text,
color = customIcon.color,
),
backgroundLayer = ColorLayer(
color = customIcon.color,
),
)
}
}

View File

@ -45,14 +45,7 @@ internal class TagsServiceImpl(
}
if (newName != null && newName != tag) {
customAttributesRepository.renameTag(tag, newName).join()
val pinnedTags = searchableRepository.get(
includeTypes = listOf(Tag.Domain),
minPinnedLevel = PinnedLevel.AutomaticallySorted,
).first()
val oldTag = Tag(tag)
if (pinnedTags.any { it.key == oldTag.key }) {
searchableRepository.replace(oldTag.key, Tag(newName))
}
searchableRepository.replace(Tag(tag).key, Tag(newName))
}
}