From 532e04e571fa6a9e4e8ef4f5835d8ec735bee5c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Orzech?= Date: Fri, 20 Mar 2026 00:28:21 +0100 Subject: [PATCH] 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. --- .../microblog/ui/composer/ComposerScreen.kt | 179 +++++++++++++----- .../ui/composer/ComposerViewModel.kt | 54 +++++- 2 files changed, 187 insertions(+), 46 deletions(-) diff --git a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt index 560cd7b..863765e 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt @@ -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, + extractedTags: List, + 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 + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt index 65b4282..be46fc8 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt @@ -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 = emptyList(), + val availableTags: List = emptyList(), + val tagSuggestions: List = emptyList(), + val tagInput: String = "", val isSubmitting: Boolean = false, val isSuccess: Boolean = false, val isEditing: Boolean = false,