From 11b20fd42a1e670bbbd5b90cd2d328be4a83b2f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Orzech?= Date: Fri, 20 Mar 2026 00:31:53 +0100 Subject: [PATCH] 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. --- .../microblog/ui/navigation/NavGraph.kt | 17 + .../microblog/ui/settings/SettingsScreen.kt | 47 +- .../swoosh/microblog/ui/tags/TagsScreen.kt | 448 ++++++++++++++++++ .../swoosh/microblog/ui/tags/TagsViewModel.kt | 120 +++++ 4 files changed, 631 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/swoosh/microblog/ui/tags/TagsScreen.kt create mode 100644 app/src/main/java/com/swoosh/microblog/ui/tags/TagsViewModel.kt diff --git a/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt b/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt index bb4c93f..41ad8f0 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt @@ -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)) }, diff --git a/app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt index 28e7248..1f97a0e 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt @@ -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)) diff --git a/app/src/main/java/com/swoosh/microblog/ui/tags/TagsScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/tags/TagsScreen.kt new file mode 100644 index 0000000..e5de360 --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/ui/tags/TagsScreen.kt @@ -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) + } +} diff --git a/app/src/main/java/com/swoosh/microblog/ui/tags/TagsViewModel.kt b/app/src/main/java/com/swoosh/microblog/ui/tags/TagsViewModel.kt new file mode 100644 index 0000000..2738194 --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/ui/tags/TagsViewModel.kt @@ -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 = _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 = emptyList(), + val isLoading: Boolean = false, + val error: String? = null, + val searchQuery: String = "", + val editingTag: GhostTagFull? = null +) { + val filteredTags: List + get() = if (searchQuery.isBlank()) tags + else tags.filter { it.name.contains(searchQuery, ignoreCase = true) } +}