feat: add tag autocomplete in Composer with suggestions and chips

ComposerViewModel fetches available tags from TagRepository on init,
filters suggestions as user types, supports addTag/removeTag. ComposerScreen
shows tag input field with dropdown suggestions (name + post count),
"Create new" option, and FlowRow of InputChip tags with close icons.
This commit is contained in:
Paweł Orzech 2026-03-20 00:28:21 +01:00
parent 2dbb4ad005
commit 532e04e571
2 changed files with 187 additions and 46 deletions

View file

@ -45,6 +45,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import com.swoosh.microblog.data.HashtagParser
import com.swoosh.microblog.data.model.FeedPost
import com.swoosh.microblog.data.model.GhostTagFull
import com.swoosh.microblog.data.model.PostStats
import com.swoosh.microblog.ui.animation.SwooshMotion
import kotlinx.coroutines.delay
@ -323,49 +324,16 @@ fun ComposerScreen(
}
)
// Extracted tags preview chips
AnimatedVisibility(
visible = state.extractedTags.isNotEmpty(),
enter = fadeIn(SwooshMotion.quick()) + expandVertically(animationSpec = SwooshMotion.snappy()),
exit = fadeOut(SwooshMotion.quick()) + shrinkVertically(animationSpec = SwooshMotion.snappy())
) {
Column {
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Icon(
Icons.Default.Tag,
contentDescription = "Tags",
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
@OptIn(ExperimentalLayoutApi::class)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
state.extractedTags.forEach { tag ->
SuggestionChip(
onClick = {},
label = {
Text(
"#$tag",
style = MaterialTheme.typography.labelSmall
)
},
colors = SuggestionChipDefaults.suggestionChipColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
labelColor = MaterialTheme.colorScheme.onPrimaryContainer
),
border = null
)
}
}
}
}
}
// Tags section: input + suggestions + chips
Spacer(modifier = Modifier.height(12.dp))
TagsSection(
tagInput = state.tagInput,
onTagInputChange = viewModel::updateTagInput,
tagSuggestions = state.tagSuggestions,
extractedTags = state.extractedTags,
onAddTag = viewModel::addTag,
onRemoveTag = viewModel::removeTag
)
Spacer(modifier = Modifier.height(12.dp))
@ -878,3 +846,128 @@ class HashtagVisualTransformation(private val hashtagColor: Color) : VisualTrans
return TransformedText(annotated, OffsetMapping.Identity)
}
}
/**
* Tag input section with autocomplete suggestions and tag chips.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun TagsSection(
tagInput: String,
onTagInputChange: (String) -> Unit,
tagSuggestions: List<GhostTagFull>,
extractedTags: List<String>,
onAddTag: (String) -> Unit,
onRemoveTag: (String) -> Unit
) {
Column {
Text(
text = "Tags:",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
// Tag input field
Box {
OutlinedTextField(
value = tagInput,
onValueChange = onTagInputChange,
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("Add a tag...") },
singleLine = true,
leadingIcon = {
Icon(
Icons.Default.Tag,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
}
)
// Suggestions dropdown
DropdownMenu(
expanded = tagSuggestions.isNotEmpty() || tagInput.isNotBlank(),
onDismissRequest = { onTagInputChange("") },
modifier = Modifier.fillMaxWidth(0.9f)
) {
tagSuggestions.take(5).forEach { tag ->
DropdownMenuItem(
text = {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(tag.name)
if (tag.count?.posts != null) {
Text(
"(${tag.count.posts})",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
},
onClick = { onAddTag(tag.name) }
)
}
// "Create new" option when input doesn't exactly match an existing tag
if (tagInput.isNotBlank() && tagSuggestions.none {
it.name.equals(tagInput, ignoreCase = true)
}) {
DropdownMenuItem(
text = {
Text(
"+ Create '$tagInput' as new",
color = MaterialTheme.colorScheme.primary
)
},
onClick = { onAddTag(tagInput) }
)
}
}
}
// Added tags as chips
AnimatedVisibility(
visible = extractedTags.isNotEmpty(),
enter = fadeIn(SwooshMotion.quick()) + expandVertically(animationSpec = SwooshMotion.snappy()),
exit = fadeOut(SwooshMotion.quick()) + shrinkVertically(animationSpec = SwooshMotion.snappy())
) {
Column {
Spacer(modifier = Modifier.height(8.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
extractedTags.forEach { tag ->
InputChip(
selected = false,
onClick = { onRemoveTag(tag) },
label = {
Text(
"#$tag",
style = MaterialTheme.typography.labelSmall
)
},
trailingIcon = {
Icon(
Icons.Default.Close,
contentDescription = "Remove tag $tag",
modifier = Modifier.size(14.dp)
)
},
colors = InputChipDefaults.inputChipColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
labelColor = MaterialTheme.colorScheme.onPrimaryContainer
),
border = null
)
}
}
}
}
}
}

View file

@ -11,6 +11,7 @@ import com.swoosh.microblog.data.db.Converters
import com.swoosh.microblog.data.model.*
import com.swoosh.microblog.data.repository.OpenGraphFetcher
import com.swoosh.microblog.data.repository.PostRepository
import com.swoosh.microblog.data.repository.TagRepository
import com.swoosh.microblog.worker.PostUploadWorker
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
@ -25,6 +26,7 @@ import kotlinx.coroutines.launch
class ComposerViewModel(application: Application) : AndroidViewModel(application) {
private val repository = PostRepository(application)
private val tagRepository = TagRepository(application)
private val appContext = application
private val _uiState = MutableStateFlow(ComposerUiState())
@ -36,6 +38,47 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
private var previewDebounceJob: Job? = null
init {
loadAvailableTags()
}
private fun loadAvailableTags() {
viewModelScope.launch {
tagRepository.fetchTags().fold(
onSuccess = { tags ->
_uiState.update { it.copy(availableTags = tags) }
},
onFailure = { /* silently ignore - tags are optional */ }
)
}
}
fun updateTagInput(input: String) {
val suggestions = if (input.isBlank()) {
emptyList()
} else {
_uiState.value.availableTags.filter { tag ->
tag.name.contains(input, ignoreCase = true) &&
!_uiState.value.extractedTags.any { it.equals(tag.name, ignoreCase = true) }
}.take(5)
}
_uiState.update { it.copy(tagInput = input, tagSuggestions = suggestions) }
}
fun addTag(tagName: String) {
val currentTags = _uiState.value.extractedTags.toMutableList()
if (!currentTags.any { it.equals(tagName, ignoreCase = true) }) {
currentTags.add(tagName)
}
_uiState.update { it.copy(extractedTags = currentTags, tagInput = "", tagSuggestions = emptyList()) }
}
fun removeTag(tagName: String) {
val currentTags = _uiState.value.extractedTags.toMutableList()
currentTags.removeAll { it.equals(tagName, ignoreCase = true) }
_uiState.update { it.copy(extractedTags = currentTags) }
}
fun loadForEdit(post: FeedPost) {
editingLocalId = post.localId
editingGhostId = post.ghostId
@ -197,8 +240,10 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
_uiState.update { it.copy(isSubmitting = true, error = null) }
val title = state.text.take(60)
val extractedTags = HashtagParser.parse(state.text)
val tagsJson = Gson().toJson(extractedTags)
// Merge hashtag-parsed tags with manually-added tags (deduplicated)
val hashtagTags = HashtagParser.parse(state.text)
val allTags = (state.extractedTags + hashtagTags).distinctBy { it.lowercase() }
val tagsJson = Gson().toJson(allTags)
val altText = state.imageAlt.ifBlank { null }
@ -252,7 +297,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
state.linkPreview?.url, state.linkPreview?.title, state.linkPreview?.description,
altText
)
val ghostTags = extractedTags.map { GhostTag(name = it) }
val ghostTags = allTags.map { GhostTag(name = it) }
val ghostPost = GhostPost(
title = title,
@ -329,6 +374,9 @@ data class ComposerUiState(
val scheduledAt: String? = null,
val featured: Boolean = false,
val extractedTags: List<String> = emptyList(),
val availableTags: List<GhostTagFull> = emptyList(),
val tagSuggestions: List<GhostTagFull> = emptyList(),
val tagInput: String = "",
val isSubmitting: Boolean = false,
val isSuccess: Boolean = false,
val isEditing: Boolean = false,