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 coil.compose.AsyncImage
|
||||||
import com.swoosh.microblog.data.HashtagParser
|
import com.swoosh.microblog.data.HashtagParser
|
||||||
import com.swoosh.microblog.data.model.FeedPost
|
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.data.model.PostStats
|
||||||
import com.swoosh.microblog.ui.animation.SwooshMotion
|
import com.swoosh.microblog.ui.animation.SwooshMotion
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
|
@ -323,49 +324,16 @@ fun ComposerScreen(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Extracted tags preview chips
|
// Tags section: input + suggestions + chips
|
||||||
AnimatedVisibility(
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
visible = state.extractedTags.isNotEmpty(),
|
TagsSection(
|
||||||
enter = fadeIn(SwooshMotion.quick()) + expandVertically(animationSpec = SwooshMotion.snappy()),
|
tagInput = state.tagInput,
|
||||||
exit = fadeOut(SwooshMotion.quick()) + shrinkVertically(animationSpec = SwooshMotion.snappy())
|
onTagInputChange = viewModel::updateTagInput,
|
||||||
) {
|
tagSuggestions = state.tagSuggestions,
|
||||||
Column {
|
extractedTags = state.extractedTags,
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
onAddTag = viewModel::addTag,
|
||||||
Row(
|
onRemoveTag = viewModel::removeTag
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
|
@ -878,3 +846,128 @@ class HashtagVisualTransformation(private val hashtagColor: Color) : VisualTrans
|
||||||
return TransformedText(annotated, OffsetMapping.Identity)
|
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.model.*
|
||||||
import com.swoosh.microblog.data.repository.OpenGraphFetcher
|
import com.swoosh.microblog.data.repository.OpenGraphFetcher
|
||||||
import com.swoosh.microblog.data.repository.PostRepository
|
import com.swoosh.microblog.data.repository.PostRepository
|
||||||
|
import com.swoosh.microblog.data.repository.TagRepository
|
||||||
import com.swoosh.microblog.worker.PostUploadWorker
|
import com.swoosh.microblog.worker.PostUploadWorker
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import com.google.gson.reflect.TypeToken
|
import com.google.gson.reflect.TypeToken
|
||||||
|
|
@ -25,6 +26,7 @@ import kotlinx.coroutines.launch
|
||||||
class ComposerViewModel(application: Application) : AndroidViewModel(application) {
|
class ComposerViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
|
|
||||||
private val repository = PostRepository(application)
|
private val repository = PostRepository(application)
|
||||||
|
private val tagRepository = TagRepository(application)
|
||||||
private val appContext = application
|
private val appContext = application
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(ComposerUiState())
|
private val _uiState = MutableStateFlow(ComposerUiState())
|
||||||
|
|
@ -36,6 +38,47 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
||||||
|
|
||||||
private var previewDebounceJob: Job? = null
|
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) {
|
fun loadForEdit(post: FeedPost) {
|
||||||
editingLocalId = post.localId
|
editingLocalId = post.localId
|
||||||
editingGhostId = post.ghostId
|
editingGhostId = post.ghostId
|
||||||
|
|
@ -197,8 +240,10 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
||||||
_uiState.update { it.copy(isSubmitting = true, error = null) }
|
_uiState.update { it.copy(isSubmitting = true, error = null) }
|
||||||
|
|
||||||
val title = state.text.take(60)
|
val title = state.text.take(60)
|
||||||
val extractedTags = HashtagParser.parse(state.text)
|
// Merge hashtag-parsed tags with manually-added tags (deduplicated)
|
||||||
val tagsJson = Gson().toJson(extractedTags)
|
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 }
|
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,
|
state.linkPreview?.url, state.linkPreview?.title, state.linkPreview?.description,
|
||||||
altText
|
altText
|
||||||
)
|
)
|
||||||
val ghostTags = extractedTags.map { GhostTag(name = it) }
|
val ghostTags = allTags.map { GhostTag(name = it) }
|
||||||
|
|
||||||
val ghostPost = GhostPost(
|
val ghostPost = GhostPost(
|
||||||
title = title,
|
title = title,
|
||||||
|
|
@ -329,6 +374,9 @@ data class ComposerUiState(
|
||||||
val scheduledAt: String? = null,
|
val scheduledAt: String? = null,
|
||||||
val featured: Boolean = false,
|
val featured: Boolean = false,
|
||||||
val extractedTags: List<String> = emptyList(),
|
val extractedTags: List<String> = emptyList(),
|
||||||
|
val availableTags: List<GhostTagFull> = emptyList(),
|
||||||
|
val tagSuggestions: List<GhostTagFull> = emptyList(),
|
||||||
|
val tagInput: String = "",
|
||||||
val isSubmitting: Boolean = false,
|
val isSubmitting: Boolean = false,
|
||||||
val isSuccess: Boolean = false,
|
val isSuccess: Boolean = false,
|
||||||
val isEditing: Boolean = false,
|
val isEditing: Boolean = false,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue