mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 20:15:41 +00:00
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:
parent
2dbb4ad005
commit
532e04e571
2 changed files with 187 additions and 46 deletions
|
|
@ -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
|
||||
// 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
|
||||
)
|
||||
@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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue