mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 11:55:47 +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.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)) },
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
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