Add emoji picker for folder cover icons
Folders can now use an emoji as their cover instead of an image URL. The folder editor has a Cover section with three modes: None, Emoji, and Image URL. The emoji picker displays a categorized grid of emojis (streaming, genres, sports, music, nature, animals, food, travel, people, objects, flags, symbols) in horizontally scrollable rows. Selecting an emoji clears any image URL and vice versa — the two modes are mutually exclusive. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
910eb8ebfc
commit
22fbf47d8c
5 changed files with 269 additions and 12 deletions
|
|
@ -102,6 +102,7 @@ class CollectionsDataStore @Inject constructor(
|
|||
val id: String,
|
||||
val title: String,
|
||||
val coverImageUrl: String? = null,
|
||||
val coverEmoji: String? = null,
|
||||
val tileShape: String = "SQUARE",
|
||||
val hideTitle: Boolean = false,
|
||||
val catalogSources: List<SerializableCatalogSource> = emptyList()
|
||||
|
|
@ -121,6 +122,7 @@ class CollectionsDataStore @Inject constructor(
|
|||
id = folder.id,
|
||||
title = folder.title,
|
||||
coverImageUrl = folder.coverImageUrl,
|
||||
coverEmoji = folder.coverEmoji,
|
||||
tileShape = folder.tileShape.name,
|
||||
hideTitle = folder.hideTitle,
|
||||
catalogSources = folder.catalogSources.map { source ->
|
||||
|
|
@ -142,6 +144,7 @@ class CollectionsDataStore @Inject constructor(
|
|||
id = folder.id,
|
||||
title = folder.title,
|
||||
coverImageUrl = folder.coverImageUrl,
|
||||
coverEmoji = folder.coverEmoji,
|
||||
tileShape = PosterShape.fromString(folder.tileShape),
|
||||
hideTitle = folder.hideTitle,
|
||||
catalogSources = folder.catalogSources.map { source ->
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ data class CollectionFolder(
|
|||
val id: String,
|
||||
val title: String,
|
||||
val coverImageUrl: String? = null,
|
||||
val coverEmoji: String? = null,
|
||||
val tileShape: PosterShape = PosterShape.SQUARE,
|
||||
val hideTitle: Boolean = false,
|
||||
val catalogSources: List<CollectionCatalogSource> = emptyList()
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import androidx.compose.ui.text.style.TextAlign
|
|||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Border
|
||||
import androidx.tv.material3.Card
|
||||
import androidx.tv.material3.CardDefaults
|
||||
|
|
@ -154,6 +155,16 @@ private fun FolderCard(
|
|||
.clip(shape),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
} else if (!folder.coverEmoji.isNullOrBlank()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = folder.coverEmoji,
|
||||
fontSize = 48.sp
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ import androidx.compose.foundation.layout.height
|
|||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
|
|
@ -43,6 +45,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
|||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.tv.material3.Border
|
||||
|
|
@ -359,6 +362,18 @@ private fun FolderEditorContent(
|
|||
return
|
||||
}
|
||||
|
||||
if (uiState.showEmojiPicker) {
|
||||
EmojiPickerContent(
|
||||
selectedEmoji = folder.coverEmoji,
|
||||
onSelect = { emoji ->
|
||||
viewModel.updateFolderCoverEmoji(emoji)
|
||||
viewModel.hideEmojiPicker()
|
||||
},
|
||||
onBack = { viewModel.hideEmojiPicker() }
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val titleFocusRequester = remember { FocusRequester() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
|
|
@ -427,16 +442,95 @@ private fun FolderEditorContent(
|
|||
}
|
||||
|
||||
item {
|
||||
Text("Cover Image", style = MaterialTheme.typography.labelLarge, color = NuvioColors.TextSecondary)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text("Enter a URL to a custom cover image, or leave empty to use a folder icon.", style = MaterialTheme.typography.bodySmall, color = NuvioColors.TextTertiary)
|
||||
val hasEmoji = !folder.coverEmoji.isNullOrBlank()
|
||||
val coverMode = when {
|
||||
folder.coverImageUrl != null -> "image"
|
||||
hasEmoji -> "emoji"
|
||||
else -> "none"
|
||||
}
|
||||
|
||||
Text("Cover", style = MaterialTheme.typography.labelLarge, color = NuvioColors.TextSecondary)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
NuvioTextField(
|
||||
value = folder.coverImageUrl ?: "",
|
||||
onValueChange = { viewModel.updateFolderCoverImage(it) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = "https://..."
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Button(
|
||||
onClick = { viewModel.clearFolderCover() },
|
||||
colors = ButtonDefaults.colors(
|
||||
containerColor = if (coverMode == "none") NuvioColors.Secondary.copy(alpha = 0.3f) else NuvioColors.BackgroundCard,
|
||||
contentColor = if (coverMode == "none") NuvioColors.Secondary else NuvioColors.TextSecondary,
|
||||
focusedContainerColor = NuvioColors.FocusBackground,
|
||||
focusedContentColor = NuvioColors.Primary
|
||||
),
|
||||
border = ButtonDefaults.border(
|
||||
border = if (coverMode == "none") Border(
|
||||
border = BorderStroke(2.dp, NuvioColors.Secondary),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) else Border.None,
|
||||
focusedBorder = Border(
|
||||
border = BorderStroke(2.dp, NuvioColors.FocusRing),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
),
|
||||
shape = ButtonDefaults.shape(RoundedCornerShape(12.dp))
|
||||
) { Text("None") }
|
||||
|
||||
Button(
|
||||
onClick = { viewModel.showEmojiPicker() },
|
||||
colors = ButtonDefaults.colors(
|
||||
containerColor = if (coverMode == "emoji") NuvioColors.Secondary.copy(alpha = 0.3f) else NuvioColors.BackgroundCard,
|
||||
contentColor = if (coverMode == "emoji") NuvioColors.Secondary else NuvioColors.TextSecondary,
|
||||
focusedContainerColor = NuvioColors.FocusBackground,
|
||||
focusedContentColor = NuvioColors.Primary
|
||||
),
|
||||
border = ButtonDefaults.border(
|
||||
border = if (coverMode == "emoji") Border(
|
||||
border = BorderStroke(2.dp, NuvioColors.Secondary),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) else Border.None,
|
||||
focusedBorder = Border(
|
||||
border = BorderStroke(2.dp, NuvioColors.FocusRing),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
),
|
||||
shape = ButtonDefaults.shape(RoundedCornerShape(12.dp))
|
||||
) {
|
||||
if (hasEmoji) {
|
||||
Text("${folder.coverEmoji} Emoji")
|
||||
} else {
|
||||
Text("Emoji")
|
||||
}
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { viewModel.switchToImageMode() },
|
||||
colors = ButtonDefaults.colors(
|
||||
containerColor = if (coverMode == "image") NuvioColors.Secondary.copy(alpha = 0.3f) else NuvioColors.BackgroundCard,
|
||||
contentColor = if (coverMode == "image") NuvioColors.Secondary else NuvioColors.TextSecondary,
|
||||
focusedContainerColor = NuvioColors.FocusBackground,
|
||||
focusedContentColor = NuvioColors.Primary
|
||||
),
|
||||
border = ButtonDefaults.border(
|
||||
border = if (coverMode == "image") Border(
|
||||
border = BorderStroke(2.dp, NuvioColors.Secondary),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) else Border.None,
|
||||
focusedBorder = Border(
|
||||
border = BorderStroke(2.dp, NuvioColors.FocusRing),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
),
|
||||
shape = ButtonDefaults.shape(RoundedCornerShape(12.dp))
|
||||
) { Text("Image URL") }
|
||||
}
|
||||
|
||||
if (coverMode == "image") {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
NuvioTextField(
|
||||
value = folder.coverImageUrl ?: "",
|
||||
onValueChange = { viewModel.updateFolderCoverImage(it) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = "https://..."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
|
|
@ -718,3 +812,108 @@ private fun CatalogPickerContent(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val emojiCategories = linkedMapOf(
|
||||
"Streaming" to listOf("🎬", "🎭", "🎥", "📺", "🍿", "🎞️", "📽️", "🎦", "📡", "📻"),
|
||||
"Genres" to listOf("💀", "👻", "🔪", "💣", "🚀", "🛸", "🧙", "🦸", "🧟", "🤖", "💘", "😂", "😱", "🤯", "🥺", "😈"),
|
||||
"Sports" to listOf("⚽", "🏀", "🏈", "⚾", "🎾", "🏐", "🏒", "🥊", "🏎️", "🏆", "🎯", "🏋️"),
|
||||
"Music" to listOf("🎵", "🎶", "🎤", "🎸", "🥁", "🎹", "🎷", "🎺", "🎻", "🪗"),
|
||||
"Nature" to listOf("🌍", "🌊", "🏔️", "🌋", "🌅", "🌙", "⭐", "🔥", "❄️", "🌈", "🌸", "🍀"),
|
||||
"Animals" to listOf("🐕", "🐈", "🦁", "🐻", "🦊", "🐺", "🦅", "🐉", "🦋", "🐬", "🦈", "🐙"),
|
||||
"Food" to listOf("🍕", "🍔", "🍣", "🍜", "🍩", "🍰", "🍷", "🍺", "☕", "🧁", "🌮", "🥗"),
|
||||
"Travel" to listOf("✈️", "🚂", "🚗", "⛵", "🏖️", "🗼", "🏰", "🗽", "🎡", "🏕️", "🌆", "🛣️"),
|
||||
"People" to listOf("👨👩👧👦", "👫", "👶", "🧒", "👩", "👨", "🧓", "💃", "🕺", "🥷", "🧑🚀", "🧑🎨"),
|
||||
"Objects" to listOf("📱", "💻", "🎮", "🕹️", "📷", "🔮", "💡", "🔑", "💎", "🎁", "📚", "✏️"),
|
||||
"Flags" to listOf("🏳️🌈", "🇺🇸", "🇬🇧", "🇨🇦", "🇫🇷", "🇩🇪", "🇯🇵", "🇰🇷", "🇮🇳", "🇧🇷", "🇲🇽", "🇦🇺"),
|
||||
"Symbols" to listOf("❤️", "💜", "💙", "💚", "💛", "🧡", "🖤", "🤍", "✅", "❌", "⚡", "💯")
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
private fun EmojiPickerContent(
|
||||
selectedEmoji: String?,
|
||||
onSelect: (String) -> Unit,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = 48.dp, start = 48.dp, end = 48.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = "Choose Emoji",
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = NuvioColors.TextPrimary
|
||||
)
|
||||
NuvioButton(onClick = onBack) { Text("Back") }
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 48.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp)
|
||||
) {
|
||||
emojiCategories.forEach { (category, emojis) ->
|
||||
item(key = "category_$category") {
|
||||
Text(
|
||||
text = category,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = NuvioColors.TextSecondary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = PaddingValues(horizontal = 4.dp)
|
||||
) {
|
||||
items(
|
||||
count = emojis.size,
|
||||
key = { "${category}_${emojis[it]}" }
|
||||
) { index ->
|
||||
val emoji = emojis[index]
|
||||
val isSelected = emoji == selectedEmoji
|
||||
Card(
|
||||
onClick = { onSelect(emoji) },
|
||||
colors = CardDefaults.colors(
|
||||
containerColor = if (isSelected) NuvioColors.Secondary.copy(alpha = 0.3f) else NuvioColors.BackgroundCard,
|
||||
focusedContainerColor = NuvioColors.FocusBackground
|
||||
),
|
||||
border = CardDefaults.border(
|
||||
border = if (isSelected) Border(
|
||||
border = BorderStroke(2.dp, NuvioColors.Secondary),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) else Border.None,
|
||||
focusedBorder = Border(
|
||||
border = BorderStroke(2.dp, NuvioColors.FocusRing),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
),
|
||||
shape = CardDefaults.shape(RoundedCornerShape(12.dp)),
|
||||
scale = CardDefaults.scale(focusedScale = 1.1f),
|
||||
modifier = Modifier
|
||||
.width(56.dp)
|
||||
.height(56.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = emoji,
|
||||
fontSize = 28.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,8 @@ data class CollectionEditorUiState(
|
|||
val availableCatalogs: List<AvailableCatalog> = emptyList(),
|
||||
val editingFolder: CollectionFolder? = null,
|
||||
val showFolderEditor: Boolean = false,
|
||||
val showCatalogPicker: Boolean = false
|
||||
val showCatalogPicker: Boolean = false,
|
||||
val showEmojiPicker: Boolean = false
|
||||
)
|
||||
|
||||
data class AvailableCatalog(
|
||||
|
|
@ -155,10 +156,48 @@ class CollectionEditorViewModel @Inject constructor(
|
|||
|
||||
fun updateFolderCoverImage(url: String) {
|
||||
_uiState.update { state ->
|
||||
state.copy(editingFolder = state.editingFolder?.copy(coverImageUrl = url.ifBlank { null }))
|
||||
state.copy(editingFolder = state.editingFolder?.copy(
|
||||
coverImageUrl = url,
|
||||
coverEmoji = null
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fun updateFolderCoverEmoji(emoji: String) {
|
||||
_uiState.update { state ->
|
||||
state.copy(editingFolder = state.editingFolder?.copy(
|
||||
coverEmoji = emoji,
|
||||
coverImageUrl = null
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fun switchToImageMode() {
|
||||
_uiState.update { state ->
|
||||
state.copy(editingFolder = state.editingFolder?.copy(
|
||||
coverImageUrl = state.editingFolder.coverImageUrl ?: "",
|
||||
coverEmoji = null
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fun clearFolderCover() {
|
||||
_uiState.update { state ->
|
||||
state.copy(editingFolder = state.editingFolder?.copy(
|
||||
coverImageUrl = null,
|
||||
coverEmoji = null
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fun showEmojiPicker() {
|
||||
_uiState.update { it.copy(showEmojiPicker = true) }
|
||||
}
|
||||
|
||||
fun hideEmojiPicker() {
|
||||
_uiState.update { it.copy(showEmojiPicker = false) }
|
||||
}
|
||||
|
||||
fun updateFolderTileShape(shape: PosterShape) {
|
||||
_uiState.update { state ->
|
||||
state.copy(editingFolder = state.editingFolder?.copy(tileShape = shape))
|
||||
|
|
@ -228,7 +267,11 @@ class CollectionEditorViewModel @Inject constructor(
|
|||
fun saveFolderEdit() {
|
||||
val rawFolder = _uiState.value.editingFolder ?: return
|
||||
if (rawFolder.catalogSources.isEmpty()) return
|
||||
val editingFolder = if (rawFolder.title.isBlank()) rawFolder.copy(title = "Untitled") else rawFolder
|
||||
val cleanedFolder = rawFolder.copy(
|
||||
title = rawFolder.title.ifBlank { "Untitled" },
|
||||
coverImageUrl = rawFolder.coverImageUrl?.ifBlank { null }
|
||||
)
|
||||
val editingFolder = cleanedFolder
|
||||
_uiState.update { state ->
|
||||
val existingIndex = state.folders.indexOfFirst { it.id == editingFolder.id }
|
||||
val newFolders = if (existingIndex >= 0) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue