feat: add Tags management screen with list/edit modes

TagsViewModel manages tag CRUD state. TagsScreen shows searchable list
of OutlinedCards with accent dot, name, count, description. Edit mode
supports name, slug (read-only), description, accent_color hex,
visibility radio. Wired into NavGraph via Routes.TAGS and accessible
from Settings screen via "Tags" row.
This commit is contained in:
Paweł Orzech 2026-03-20 00:31:53 +01:00
parent 532e04e571
commit 11b20fd42a
4 changed files with 631 additions and 1 deletions

View file

@ -34,6 +34,7 @@ import com.swoosh.microblog.ui.preview.PreviewScreen
import com.swoosh.microblog.ui.settings.SettingsScreen
import com.swoosh.microblog.ui.setup.SetupScreen
import com.swoosh.microblog.ui.stats.StatsScreen
import com.swoosh.microblog.ui.tags.TagsScreen
import com.swoosh.microblog.ui.theme.ThemeViewModel
object Routes {
@ -46,6 +47,7 @@ object Routes {
const val STATS = "stats"
const val PREVIEW = "preview"
const val ADD_ACCOUNT = "add_account"
const val TAGS = "tags"
}
data class BottomNavItem(
@ -255,6 +257,9 @@ fun SwooshNavGraph(
navController.navigate(Routes.SETUP) {
popUpTo(0) { inclusive = true }
}
},
onNavigateToTags = {
navController.navigate(Routes.TAGS)
}
)
}
@ -269,6 +274,18 @@ fun SwooshNavGraph(
StatsScreen()
}
composable(
Routes.TAGS,
enterTransition = { slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(250)) + fadeIn(tween(200)) },
exitTransition = { fadeOut(tween(150)) },
popEnterTransition = { fadeIn(tween(200)) },
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }, animationSpec = tween(200)) + fadeOut(tween(150)) }
) {
TagsScreen(
onBack = { navController.popBackStack() }
)
}
composable(
Routes.PREVIEW,
enterTransition = { slideInVertically(initialOffsetY = { it }, animationSpec = tween(250)) + fadeIn(tween(200)) },

View file

@ -8,8 +8,10 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.BrightnessAuto
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.DarkMode
import androidx.compose.material.icons.filled.LightMode
import androidx.compose.material.icons.filled.Tag
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@ -30,7 +32,8 @@ import com.swoosh.microblog.ui.theme.ThemeViewModel
fun SettingsScreen(
onBack: () -> Unit,
onLogout: () -> Unit,
themeViewModel: ThemeViewModel? = null
themeViewModel: ThemeViewModel? = null,
onNavigateToTags: () -> Unit = {}
) {
val context = LocalContext.current
val accountManager = remember { AccountManager(context) }
@ -75,6 +78,48 @@ fun SettingsScreen(
Spacer(modifier = Modifier.height(24.dp))
}
// --- Content Management section ---
Text("Content", style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(12.dp))
OutlinedCard(
onClick = onNavigateToTags,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Tag,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
"Tags",
style = MaterialTheme.typography.bodyLarge
)
}
Icon(
Icons.Default.ChevronRight,
contentDescription = "Navigate to tags",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp)
)
}
}
Spacer(modifier = Modifier.height(24.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(24.dp))
// --- Current Account section ---
Text("Current Account", style = MaterialTheme.typography.titleMedium)
Spacer(modifier = Modifier.height(12.dp))

View file

@ -0,0 +1,448 @@
package com.swoosh.microblog.ui.tags
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.swoosh.microblog.data.model.GhostTagFull
import com.swoosh.microblog.ui.animation.SwooshMotion
import com.swoosh.microblog.ui.components.ConfirmationDialog
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TagsScreen(
onBack: () -> Unit,
viewModel: TagsViewModel = viewModel()
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
// If editing a tag, show the edit screen
if (state.editingTag != null) {
TagEditScreen(
tag = state.editingTag!!,
isLoading = state.isLoading,
error = state.error,
onSave = viewModel::saveTag,
onDelete = { id -> viewModel.deleteTag(id) },
onBack = viewModel::cancelEditing
)
return
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Tags") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
}
},
actions = {
IconButton(onClick = { viewModel.startCreating() }) {
Icon(Icons.Default.Add, "Create tag")
}
IconButton(onClick = { viewModel.loadTags() }) {
Icon(Icons.Default.Refresh, "Refresh")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
// Search field
OutlinedTextField(
value = state.searchQuery,
onValueChange = viewModel::updateSearchQuery,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
placeholder = { Text("Search tags...") },
singleLine = true,
leadingIcon = {
Icon(Icons.Default.Search, contentDescription = null, modifier = Modifier.size(20.dp))
},
trailingIcon = {
if (state.searchQuery.isNotBlank()) {
IconButton(onClick = { viewModel.updateSearchQuery("") }) {
Icon(Icons.Default.Close, "Clear search", modifier = Modifier.size(18.dp))
}
}
}
)
// Loading indicator
if (state.isLoading) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
// Error message
if (state.error != null) {
Text(
text = state.error!!,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
)
}
// Tags list
if (state.filteredTags.isEmpty() && !state.isLoading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
Icons.Default.Tag,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = if (state.searchQuery.isNotBlank()) "No tags match \"${state.searchQuery}\""
else "No tags yet",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (state.searchQuery.isBlank()) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Tap + to create your first tag",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(
state.filteredTags,
key = { it.id ?: it.name }
) { tag ->
TagCard(
tag = tag,
onClick = { viewModel.startEditing(tag) }
)
}
}
}
}
}
}
@Composable
private fun TagCard(
tag: GhostTagFull,
onClick: () -> Unit
) {
OutlinedCard(
onClick = onClick,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
// Accent color dot
val accentColor = tag.accent_color?.let { parseHexColor(it) }
?: MaterialTheme.colorScheme.primary
Box(
modifier = Modifier
.size(12.dp)
.clip(CircleShape)
.background(accentColor)
)
// Tag info
Column(modifier = Modifier.weight(1f)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = tag.name,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (tag.count?.posts != null) {
Surface(
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.secondaryContainer
) {
Text(
text = "${tag.count.posts} posts",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
}
if (!tag.description.isNullOrBlank()) {
Text(
text = tag.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
Icon(
Icons.Default.ChevronRight,
contentDescription = "Edit",
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TagEditScreen(
tag: GhostTagFull,
isLoading: Boolean,
error: String?,
onSave: (GhostTagFull) -> Unit,
onDelete: (String) -> Unit,
onBack: () -> Unit
) {
val isNew = tag.id == null
var name by remember(tag) { mutableStateOf(tag.name) }
var description by remember(tag) { mutableStateOf(tag.description ?: "") }
var accentColor by remember(tag) { mutableStateOf(tag.accent_color ?: "") }
var visibility by remember(tag) { mutableStateOf(tag.visibility ?: "public") }
var showDeleteDialog by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = { Text(if (isNew) "Create Tag" else "Edit Tag") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
}
},
actions = {
if (isLoading) {
Box(
modifier = Modifier.size(48.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp
)
}
} else {
TextButton(
onClick = {
onSave(
tag.copy(
name = name,
description = description.ifBlank { null },
accent_color = accentColor.ifBlank { null },
visibility = visibility
)
)
},
enabled = name.isNotBlank()
) {
Text("Save")
}
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Name
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
isError = name.isBlank()
)
// Slug (read-only, only for existing tags)
if (!isNew && tag.slug != null) {
OutlinedTextField(
value = tag.slug,
onValueChange = {},
label = { Text("Slug") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
readOnly = true,
enabled = false
)
}
// Description
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text("Description") },
modifier = Modifier.fillMaxWidth(),
minLines = 2,
maxLines = 4
)
// Accent color
OutlinedTextField(
value = accentColor,
onValueChange = { accentColor = it },
label = { Text("Accent Color (hex)") },
placeholder = { Text("#FF5722") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
leadingIcon = {
if (accentColor.isNotBlank()) {
val color = parseHexColor(accentColor)
Box(
modifier = Modifier
.size(20.dp)
.clip(CircleShape)
.background(color)
)
}
}
)
// Visibility radio buttons
Text(
text = "Visibility",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { visibility = "public" }
) {
RadioButton(
selected = visibility == "public",
onClick = { visibility = "public" }
)
Text("Public", style = MaterialTheme.typography.bodyMedium)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { visibility = "internal" }
) {
RadioButton(
selected = visibility == "internal",
onClick = { visibility = "internal" }
)
Text("Internal", style = MaterialTheme.typography.bodyMedium)
}
}
// Error
if (error != null) {
Text(
text = error,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall
)
}
// Delete button (only for existing tags)
if (!isNew) {
Spacer(modifier = Modifier.height(16.dp))
OutlinedButton(
onClick = { showDeleteDialog = true },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Icon(
Icons.Default.Delete,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Delete Tag")
}
}
}
}
if (showDeleteDialog && tag.id != null) {
ConfirmationDialog(
title = "Delete Tag?",
message = "Delete \"${tag.name}\"? This will remove the tag from all posts.",
confirmLabel = "Delete",
onConfirm = {
showDeleteDialog = false
onDelete(tag.id)
},
onDismiss = { showDeleteDialog = false }
)
}
}
/**
* Parses a hex color string (e.g., "#FF5722" or "FF5722") into a Color.
* Returns a default color if parsing fails.
*/
fun parseHexColor(hex: String): Color {
return try {
val cleanHex = hex.removePrefix("#")
if (cleanHex.length == 6) {
Color(android.graphics.Color.parseColor("#$cleanHex"))
} else {
Color(0xFF888888)
}
} catch (e: Exception) {
Color(0xFF888888)
}
}

View file

@ -0,0 +1,120 @@
package com.swoosh.microblog.ui.tags
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.swoosh.microblog.data.model.GhostTagFull
import com.swoosh.microblog.data.repository.TagRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class TagsViewModel(application: Application) : AndroidViewModel(application) {
private val tagRepository = TagRepository(application)
private val _uiState = MutableStateFlow(TagsUiState())
val uiState: StateFlow<TagsUiState> = _uiState.asStateFlow()
init {
loadTags()
}
fun loadTags() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
tagRepository.fetchTags().fold(
onSuccess = { tags ->
_uiState.update { it.copy(tags = tags, isLoading = false) }
},
onFailure = { e ->
_uiState.update { it.copy(isLoading = false, error = e.message) }
}
)
}
}
fun updateSearchQuery(query: String) {
_uiState.update { it.copy(searchQuery = query) }
}
fun startEditing(tag: GhostTagFull) {
_uiState.update { it.copy(editingTag = tag) }
}
fun startCreating() {
_uiState.update {
it.copy(editingTag = GhostTagFull(name = ""))
}
}
fun cancelEditing() {
_uiState.update { it.copy(editingTag = null) }
}
fun saveTag(tag: GhostTagFull) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
if (tag.id != null) {
// Update existing tag
tagRepository.updateTag(tag.id, tag).fold(
onSuccess = {
_uiState.update { it.copy(editingTag = null) }
loadTags()
},
onFailure = { e ->
_uiState.update { it.copy(isLoading = false, error = e.message) }
}
)
} else {
// Create new tag
tagRepository.createTag(
name = tag.name,
description = tag.description,
accentColor = tag.accent_color
).fold(
onSuccess = {
_uiState.update { it.copy(editingTag = null) }
loadTags()
},
onFailure = { e ->
_uiState.update { it.copy(isLoading = false, error = e.message) }
}
)
}
}
}
fun deleteTag(id: String) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true, error = null) }
tagRepository.deleteTag(id).fold(
onSuccess = {
_uiState.update { it.copy(editingTag = null) }
loadTags()
},
onFailure = { e ->
_uiState.update { it.copy(isLoading = false, error = e.message) }
}
)
}
}
fun clearError() {
_uiState.update { it.copy(error = null) }
}
}
data class TagsUiState(
val tags: List<GhostTagFull> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null,
val searchQuery: String = "",
val editingTag: GhostTagFull? = null
) {
val filteredTags: List<GhostTagFull>
get() = if (searchQuery.isBlank()) tags
else tags.filter { it.name.contains(searchQuery, ignoreCase = true) }
}