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>
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true"> <inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="composableFile" value="true" /> <option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool> </inspection_tool>
<inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true"> <inspection_tool class="PreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" /> <option name="composableFile" value="true" />

View File

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

View File

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

View File

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

View File

@ -186,7 +186,7 @@ fun LazyVerticalDragAndDropGrid(
horizontalArrangement, horizontalArrangement,
flingBehavior, flingBehavior,
userScrollEnabled, userScrollEnabled,
content, content = content,
) )
} }
@ -215,7 +215,7 @@ fun LazyHorizontalDragAndDropGrid(
verticalArrangement, verticalArrangement,
flingBehavior, flingBehavior,
userScrollEnabled, 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 action = actions.customActions[i]
val tooltipState = rememberTooltipState() val tooltipState = rememberTooltipState()
TooltipBox( TooltipBox(
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), positionProvider = TooltipDefaults.rememberTooltipPositionProvider(),
state = tooltipState, state = tooltipState,
tooltip = { tooltip = {
PlainTooltip { PlainTooltip {
@ -458,7 +458,7 @@ fun CustomActions(
val action = actions.customActions.last() val action = actions.customActions.last()
TooltipBox( TooltipBox(
state = tooltipState, state = tooltipState,
positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), positionProvider = TooltipDefaults.rememberTooltipPositionProvider(),
tooltip = { tooltip = {
PlainTooltip { PlainTooltip {
Text(action.name.toString()) Text(action.name.toString())

View File

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

View File

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

View File

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

View File

@ -6,6 +6,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.offset
@ -22,8 +23,11 @@ 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.Check 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.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
@ -38,6 +42,10 @@ import androidx.compose.runtime.getValue
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
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.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
@ -48,6 +56,7 @@ import de.mm20.launcher2.ui.R
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
import de.mm20.launcher2.ui.component.emojipicker.EmojiPicker
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
@ -87,12 +96,18 @@ fun EditTagSheet(
Text(stringResource(R.string.action_next)) Text(stringResource(R.string.action_next))
} }
} }
} else { } else if (viewModel.page == EditTagSheetPage.PickItems) {
{ {
OutlinedButton(onClick = { viewModel.closeItemPicker() }) { OutlinedButton(onClick = { viewModel.closeItemPicker() }) {
Text(stringResource(id = R.string.ok)) Text(stringResource(id = R.string.ok))
} }
} }
} else {
{
OutlinedButton(onClick = { viewModel.closeIconPicker() }) {
Text(stringResource(id = android.R.string.cancel))
}
}
}, },
onDismissRequest = { onDismissRequest = {
if (viewModel.page == EditTagSheetPage.CustomizeTag) { if (viewModel.page == EditTagSheetPage.CustomizeTag) {
@ -109,6 +124,7 @@ fun EditTagSheet(
EditTagSheetPage.CreateTag -> CreateNewTagPage(viewModel, it) EditTagSheetPage.CreateTag -> CreateNewTagPage(viewModel, it)
EditTagSheetPage.PickItems -> PickItems(viewModel, it) EditTagSheetPage.PickItems -> PickItems(viewModel, it)
EditTagSheetPage.CustomizeTag -> CustomizeTag(viewModel, it) EditTagSheetPage.CustomizeTag -> CustomizeTag(viewModel, it)
EditTagSheetPage.PickIcon -> PickIcon(viewModel, it)
} }
} }
} }
@ -238,19 +254,72 @@ 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
Column( Column(
modifier = Modifier modifier = Modifier
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.fillMaxWidth() .fillMaxWidth()
.padding(paddingValues) .padding(paddingValues)
) { ) {
OutlinedTextField(
Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, ) {
label = { Text(stringResource(R.string.tag_name)) }, val outlineVariant = MaterialTheme.colorScheme.outlineVariant
value = viewModel.tagName, Box(
onValueChange = { viewModel.tagName = it } 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) { val icon1 = remember(viewModel.taggedItems.getOrNull(0)?.key) {
viewModel.taggedItems.getOrNull(0)?.let { viewModel.taggedItems.getOrNull(0)?.let {
viewModel.getIcon(it, iconSize.toInt()) viewModel.getIcon(it, iconSize.toInt())
@ -286,21 +355,27 @@ fun CustomizeTag(viewModel: EditTagSheetVM, paddingValues: PaddingValues) {
.height(32.dp), .height(32.dp),
contentAlignment = Alignment.CenterEnd contentAlignment = Alignment.CenterEnd
) { ) {
ShapedLauncherIcon( icon1?.value?.let {
size = 32.dp, ShapedLauncherIcon(
icon = { icon1?.value }, size = 32.dp,
modifier = Modifier.offset(x = -0.dp) icon = { it },
) modifier = Modifier.offset(x = -0.dp)
ShapedLauncherIcon( )
size = 32.dp, }
icon = { icon2?.value }, icon2?.value?.let {
modifier = Modifier.offset(x = -16.dp) ShapedLauncherIcon(
) size = 32.dp,
ShapedLauncherIcon( icon = { it },
size = 32.dp, modifier = Modifier.offset(x = -16.dp)
icon = { icon3?.value }, )
modifier = Modifier.offset(x = -32.dp) }
) icon3?.value?.let {
ShapedLauncherIcon(
size = 32.dp,
icon = { it },
modifier = Modifier.offset(x = -32.dp)
)
}
} }
} }
AnimatedVisibility(viewModel.tagNameExists || viewModel.taggedItems.isEmpty()) { AnimatedVisibility(viewModel.tagNameExists || viewModel.taggedItems.isEmpty()) {
@ -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.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.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.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
@ -31,6 +35,7 @@ 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 loading by mutableStateOf(true) var loading by mutableStateOf(true)
@ -46,7 +51,6 @@ class EditTagSheetVM : ViewModel(), KoinComponent {
fun init(tag: String?) { fun init(tag: String?) {
Log.d("MM20", "Init with tag: $tag")
loading = true loading = true
this.oldTagName = tag this.oldTagName = tag
this.tagName = tag ?: "" this.tagName = tag ?: ""
@ -55,6 +59,9 @@ 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
tagEmoji = ((icon as? StaticLauncherIcon)?.foregroundLayer as? TextLayer)?.text
val apps = appRepository.findMany().first { it.isNotEmpty() }.sorted() val apps = appRepository.findMany().first { it.isNotEmpty() }.sorted()
taggedItems = items taggedItems = items
taggableApps = apps.map { app -> TaggableItem(app, items.any { app.key == it.key }) } taggableApps = apps.map { app -> TaggableItem(app, items.any { app.key == it.key }) }
@ -69,9 +76,19 @@ class EditTagSheetVM : ViewModel(), KoinComponent {
fun save() { fun save() {
val oldName = oldTagName val oldName = oldTagName
val newName = tagName val newName = tagName
val tagEmoji = tagEmoji
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)
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 loading = true
} }
@ -89,6 +106,19 @@ class EditTagSheetVM : ViewModel(), KoinComponent {
page = EditTagSheetPage.PickItems page = EditTagSheetPage.PickItems
} }
fun openIconPicker() {
page = EditTagSheetPage.PickIcon
}
fun closeIconPicker() {
page = EditTagSheetPage.CustomizeTag
}
fun selectIcon(emoji: String?) {
tagEmoji = emoji
closeIconPicker()
}
fun closeItemPicker() { fun closeItemPicker() {
page = EditTagSheetPage.CustomizeTag page = EditTagSheetPage.CustomizeTag
} }
@ -114,6 +144,7 @@ enum class EditTagSheetPage {
CreateTag, CreateTag,
PickItems, PickItems,
CustomizeTag, CustomizeTag,
PickIcon,
} }
@Stable @Stable

View File

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

View File

@ -119,6 +119,12 @@ sealed class CustomIcon : CustomAttribute {
} }
"force_themed_icon" -> ForceThemedIcon "force_themed_icon" -> ForceThemedIcon
"default_placeholder_icon" -> DefaultPlaceholderIcon "default_placeholder_icon" -> DefaultPlaceholderIcon
"custom_text_icon" -> {
CustomTextIcon(
text = payload.getString("text"),
color = payload.getInt("color")
)
}
else -> null else -> null
} }
} }
@ -234,3 +240,16 @@ data object DefaultPlaceholderIcon: CustomIcon() {
).toString() ).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) ksp(libs.androidx.roomcompiler)
api(libs.androidx.room) api(libs.androidx.room)
implementation(libs.koin.android) implementation(libs.koin.android)
implementation(libs.emoji4j)
implementation(project(":core:i18n")) implementation(project(":core:i18n"))
implementation(project(":core:ktx")) 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_23_24
import de.mm20.launcher2.database.migrations.Migration_24_25 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_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_6_7
import de.mm20.launcher2.database.migrations.Migration_7_8 import de.mm20.launcher2.database.migrations.Migration_7_8
import de.mm20.launcher2.database.migrations.Migration_8_9 import de.mm20.launcher2.database.migrations.Migration_8_9
@ -55,7 +56,7 @@ import java.util.UUID
SearchActionEntity::class, SearchActionEntity::class,
ThemeEntity::class, ThemeEntity::class,
PluginEntity::class, PluginEntity::class,
], version = 26, exportSchema = true ], version = 27, exportSchema = true
) )
@TypeConverters(ComponentNameConverter::class) @TypeConverters(ComponentNameConverter::class)
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
@ -154,6 +155,7 @@ abstract class AppDatabase : RoomDatabase() {
Migration_23_24(), Migration_23_24(),
Migration_24_25(), Migration_24_25(),
Migration_25_26(), Migration_25_26(),
Migration_26_27(),
).build() ).build()
if (_instance == null) _instance = instance if (_instance == null) _instance = instance
return 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.CustomAttributesRepository
import de.mm20.launcher2.data.customattrs.CustomIcon import de.mm20.launcher2.data.customattrs.CustomIcon
import de.mm20.launcher2.data.customattrs.CustomIconPackIcon 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.LegacyCustomIconPackIcon
import de.mm20.launcher2.data.customattrs.CustomThemedIcon import de.mm20.launcher2.data.customattrs.CustomThemedIcon
import de.mm20.launcher2.data.customattrs.DefaultPlaceholderIcon 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.CalendarIconProvider
import de.mm20.launcher2.icons.providers.CompatIconProvider import de.mm20.launcher2.icons.providers.CompatIconProvider
import de.mm20.launcher2.icons.providers.CustomIconPackIconProvider 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.LegacyCustomIconPackIconProvider
import de.mm20.launcher2.icons.providers.CustomThemedIconProvider import de.mm20.launcher2.icons.providers.CustomThemedIconProvider
import de.mm20.launcher2.icons.providers.DynamicClockIconProvider import de.mm20.launcher2.icons.providers.DynamicClockIconProvider
@ -203,6 +205,9 @@ class IconService(
if (customIcon is DefaultPlaceholderIcon) { if (customIcon is DefaultPlaceholderIcon) {
return iconProviders.value.lastOrNull()?.let { listOf(it) } ?: emptyList() return iconProviders.value.lastOrNull()?.let { listOf(it) } ?: emptyList()
} }
if (customIcon is CustomTextIcon) {
return listOf(CustomTextIconProvider(customIcon))
}
return emptyList() 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) { if (newName != null && newName != tag) {
customAttributesRepository.renameTag(tag, newName).join() customAttributesRepository.renameTag(tag, newName).join()
val pinnedTags = searchableRepository.get( searchableRepository.replace(Tag(tag).key, Tag(newName))
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))
}
} }
} }