mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 20:15:41 +00:00
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:
parent
532e04e571
commit
11b20fd42a
4 changed files with 631 additions and 1 deletions
|
|
@ -34,6 +34,7 @@ import com.swoosh.microblog.ui.preview.PreviewScreen
|
||||||
import com.swoosh.microblog.ui.settings.SettingsScreen
|
import com.swoosh.microblog.ui.settings.SettingsScreen
|
||||||
import com.swoosh.microblog.ui.setup.SetupScreen
|
import com.swoosh.microblog.ui.setup.SetupScreen
|
||||||
import com.swoosh.microblog.ui.stats.StatsScreen
|
import com.swoosh.microblog.ui.stats.StatsScreen
|
||||||
|
import com.swoosh.microblog.ui.tags.TagsScreen
|
||||||
import com.swoosh.microblog.ui.theme.ThemeViewModel
|
import com.swoosh.microblog.ui.theme.ThemeViewModel
|
||||||
|
|
||||||
object Routes {
|
object Routes {
|
||||||
|
|
@ -46,6 +47,7 @@ object Routes {
|
||||||
const val STATS = "stats"
|
const val STATS = "stats"
|
||||||
const val PREVIEW = "preview"
|
const val PREVIEW = "preview"
|
||||||
const val ADD_ACCOUNT = "add_account"
|
const val ADD_ACCOUNT = "add_account"
|
||||||
|
const val TAGS = "tags"
|
||||||
}
|
}
|
||||||
|
|
||||||
data class BottomNavItem(
|
data class BottomNavItem(
|
||||||
|
|
@ -255,6 +257,9 @@ fun SwooshNavGraph(
|
||||||
navController.navigate(Routes.SETUP) {
|
navController.navigate(Routes.SETUP) {
|
||||||
popUpTo(0) { inclusive = true }
|
popUpTo(0) { inclusive = true }
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
onNavigateToTags = {
|
||||||
|
navController.navigate(Routes.TAGS)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -269,6 +274,18 @@ fun SwooshNavGraph(
|
||||||
StatsScreen()
|
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(
|
composable(
|
||||||
Routes.PREVIEW,
|
Routes.PREVIEW,
|
||||||
enterTransition = { slideInVertically(initialOffsetY = { it }, animationSpec = tween(250)) + fadeIn(tween(200)) },
|
enterTransition = { slideInVertically(initialOffsetY = { it }, animationSpec = tween(250)) + fadeIn(tween(200)) },
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,10 @@ import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.BrightnessAuto
|
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.DarkMode
|
||||||
import androidx.compose.material.icons.filled.LightMode
|
import androidx.compose.material.icons.filled.LightMode
|
||||||
|
import androidx.compose.material.icons.filled.Tag
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
|
@ -30,7 +32,8 @@ import com.swoosh.microblog.ui.theme.ThemeViewModel
|
||||||
fun SettingsScreen(
|
fun SettingsScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onLogout: () -> Unit,
|
onLogout: () -> Unit,
|
||||||
themeViewModel: ThemeViewModel? = null
|
themeViewModel: ThemeViewModel? = null,
|
||||||
|
onNavigateToTags: () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val accountManager = remember { AccountManager(context) }
|
val accountManager = remember { AccountManager(context) }
|
||||||
|
|
@ -75,6 +78,48 @@ fun SettingsScreen(
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
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 ---
|
// --- Current Account section ---
|
||||||
Text("Current Account", style = MaterialTheme.typography.titleMedium)
|
Text("Current Account", style = MaterialTheme.typography.titleMedium)
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
|
||||||
448
app/src/main/java/com/swoosh/microblog/ui/tags/TagsScreen.kt
Normal file
448
app/src/main/java/com/swoosh/microblog/ui/tags/TagsScreen.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
120
app/src/main/java/com/swoosh/microblog/ui/tags/TagsViewModel.kt
Normal file
120
app/src/main/java/com/swoosh/microblog/ui/tags/TagsViewModel.kt
Normal 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) }
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue