merge: integrate Phase 2 (Tags CRUD) with existing phases

This commit is contained in:
Paweł Orzech 2026-03-20 00:39:31 +01:00
commit 807c6d559e
14 changed files with 1400 additions and 77 deletions

View file

@ -6,6 +6,8 @@ import com.swoosh.microblog.data.model.PageWrapper
import com.swoosh.microblog.data.model.PagesResponse import com.swoosh.microblog.data.model.PagesResponse
import com.swoosh.microblog.data.model.PostWrapper import com.swoosh.microblog.data.model.PostWrapper
import com.swoosh.microblog.data.model.PostsResponse import com.swoosh.microblog.data.model.PostsResponse
import com.swoosh.microblog.data.model.TagsResponse
import com.swoosh.microblog.data.model.TagWrapper
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.RequestBody import okhttp3.RequestBody
import retrofit2.Response import retrofit2.Response
@ -84,6 +86,28 @@ interface GhostApiService {
@DELETE("ghost/api/admin/pages/{id}/") @DELETE("ghost/api/admin/pages/{id}/")
suspend fun deletePage(@Path("id") id: String): Response<Unit> suspend fun deletePage(@Path("id") id: String): Response<Unit>
// --- Tags ---
@GET("ghost/api/admin/tags/")
suspend fun getTags(
@Query("limit") limit: String = "all",
@Query("include") include: String = "count.posts"
): Response<TagsResponse>
@GET("ghost/api/admin/tags/{id}/")
suspend fun getTag(@Path("id") id: String): Response<TagsResponse>
@POST("ghost/api/admin/tags/")
@Headers("Content-Type: application/json")
suspend fun createTag(@Body body: TagWrapper): Response<TagsResponse>
@PUT("ghost/api/admin/tags/{id}/")
@Headers("Content-Type: application/json")
suspend fun updateTag(@Path("id") id: String, @Body body: TagWrapper): Response<TagsResponse>
@DELETE("ghost/api/admin/tags/{id}/")
suspend fun deleteTag(@Path("id") id: String): Response<Unit>
@Multipart @Multipart
@POST("ghost/api/admin/images/upload/") @POST("ghost/api/admin/images/upload/")
suspend fun uploadImage( suspend fun uploadImage(

View file

@ -0,0 +1,26 @@
package com.swoosh.microblog.data.model
data class TagsResponse(
val tags: List<GhostTagFull>,
val meta: Meta?
)
data class TagWrapper(
val tags: List<GhostTagFull>
)
data class GhostTagFull(
val id: String? = null,
val name: String,
val slug: String? = null,
val description: String? = null,
val feature_image: String? = null,
val visibility: String? = "public",
val accent_color: String? = null,
val count: TagCount? = null,
val created_at: String? = null,
val updated_at: String? = null,
val url: String? = null
)
data class TagCount(val posts: Int?)

View file

@ -0,0 +1,86 @@
package com.swoosh.microblog.data.repository
import android.content.Context
import com.swoosh.microblog.data.AccountManager
import com.swoosh.microblog.data.api.ApiClient
import com.swoosh.microblog.data.api.GhostApiService
import com.swoosh.microblog.data.model.GhostTagFull
import com.swoosh.microblog.data.model.TagWrapper
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class TagRepository(private val context: Context) {
private val accountManager = AccountManager(context)
private fun getApi(): GhostApiService {
val account = accountManager.getActiveAccount()
?: throw IllegalStateException("No active account configured")
return ApiClient.getService(account.blogUrl) { account.apiKey }
}
suspend fun fetchTags(): Result<List<GhostTagFull>> =
withContext(Dispatchers.IO) {
try {
val response = getApi().getTags()
if (response.isSuccessful) {
Result.success(response.body()!!.tags)
} else {
Result.failure(Exception("API error ${response.code()}: ${response.errorBody()?.string()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun createTag(
name: String,
description: String? = null,
accentColor: String? = null
): Result<GhostTagFull> =
withContext(Dispatchers.IO) {
try {
val tag = GhostTagFull(
name = name,
description = description,
accent_color = accentColor
)
val response = getApi().createTag(TagWrapper(listOf(tag)))
if (response.isSuccessful) {
Result.success(response.body()!!.tags.first())
} else {
Result.failure(Exception("Create tag failed ${response.code()}: ${response.errorBody()?.string()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun updateTag(id: String, tag: GhostTagFull): Result<GhostTagFull> =
withContext(Dispatchers.IO) {
try {
val response = getApi().updateTag(id, TagWrapper(listOf(tag)))
if (response.isSuccessful) {
Result.success(response.body()!!.tags.first())
} else {
Result.failure(Exception("Update tag failed ${response.code()}: ${response.errorBody()?.string()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
suspend fun deleteTag(id: String): Result<Unit> =
withContext(Dispatchers.IO) {
try {
val response = getApi().deleteTag(id)
if (response.isSuccessful) {
Result.success(Unit)
} else {
Result.failure(Exception("Delete tag failed ${response.code()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
}

View file

@ -49,6 +49,7 @@ import com.swoosh.microblog.data.AccountManager
import com.swoosh.microblog.data.HashtagParser import com.swoosh.microblog.data.HashtagParser
import com.swoosh.microblog.data.SiteMetadataCache import com.swoosh.microblog.data.SiteMetadataCache
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
@ -360,49 +361,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))
@ -915,3 +883,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
)
}
}
}
}
}
}

View file

@ -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,

View file

@ -33,6 +33,7 @@ import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
@ -84,6 +85,7 @@ import com.swoosh.microblog.data.ShareUtils
import com.swoosh.microblog.data.SiteMetadataCache import com.swoosh.microblog.data.SiteMetadataCache
import com.swoosh.microblog.data.model.FeedPost import com.swoosh.microblog.data.model.FeedPost
import com.swoosh.microblog.data.model.GhostAccount import com.swoosh.microblog.data.model.GhostAccount
import com.swoosh.microblog.data.model.GhostTagFull
import com.swoosh.microblog.data.model.PostFilter import com.swoosh.microblog.data.model.PostFilter
import com.swoosh.microblog.data.model.PostStats import com.swoosh.microblog.data.model.PostStats
import com.swoosh.microblog.data.model.QueueStatus import com.swoosh.microblog.data.model.QueueStatus
@ -110,6 +112,7 @@ fun FeedScreen(
val recentSearches by viewModel.recentSearches.collectAsStateWithLifecycle() val recentSearches by viewModel.recentSearches.collectAsStateWithLifecycle()
val accounts by viewModel.accounts.collectAsStateWithLifecycle() val accounts by viewModel.accounts.collectAsStateWithLifecycle()
val activeAccount by viewModel.activeAccount.collectAsStateWithLifecycle() val activeAccount by viewModel.activeAccount.collectAsStateWithLifecycle()
val popularTags by viewModel.popularTags.collectAsStateWithLifecycle()
val listState = rememberLazyListState() val listState = rememberLazyListState()
val context = LocalContext.current val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
@ -325,20 +328,17 @@ fun FeedScreen(
) )
} }
// Active tag filter bar // Tag filter chips
if (state.activeTagFilter != null) { AnimatedVisibility(
FilterChip( visible = !isSearchActive && popularTags.isNotEmpty(),
onClick = { viewModel.clearTagFilter() }, enter = fadeIn(SwooshMotion.quick()) + expandVertically(),
label = { Text("#${state.activeTagFilter}") }, exit = fadeOut(SwooshMotion.quick()) + shrinkVertically()
selected = true, ) {
leadingIcon = { TagFilterChipsBar(
Icon(Icons.Default.Tag, contentDescription = null, modifier = Modifier.size(16.dp)) tags = popularTags,
}, activeTagFilter = state.activeTagFilter,
trailingIcon = { onTagSelected = { viewModel.filterByTag(it) },
Icon(Icons.Default.Close, contentDescription = "Clear filter", modifier = Modifier.size(16.dp)) onClearFilter = { viewModel.clearTagFilter() }
},
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 4.dp)
) )
} }
@ -795,6 +795,58 @@ fun FilterChipsBar(
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TagFilterChipsBar(
tags: List<GhostTagFull>,
activeTagFilter: String?,
onTagSelected: (String) -> Unit,
onClearFilter: () -> Unit
) {
LazyRow(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// "All tags" chip
item {
val isAllSelected = activeTagFilter == null
FilterChip(
selected = isAllSelected,
onClick = { onClearFilter() },
label = { Text("All tags") },
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer
)
)
}
// Tag chips
items(tags, key = { it.id ?: it.name }) { tag ->
val isSelected = activeTagFilter != null && activeTagFilter.equals(tag.name, ignoreCase = true)
FilterChip(
selected = isSelected,
onClick = {
if (isSelected) onClearFilter() else onTagSelected(tag.name)
},
label = {
val postCount = tag.count?.posts
if (postCount != null) {
Text("${tag.name} ($postCount)")
} else {
Text(tag.name)
}
},
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer
)
)
}
}
}
@Composable @Composable
fun SortButton( fun SortButton(
currentSort: SortOrder, currentSort: SortOrder,
@ -1539,24 +1591,20 @@ fun PostCardContent(
} }
} }
// Hashtag tags (bold colored text, not chips) // Tags display
if (post.tags.isNotEmpty()) { if (post.tags.isNotEmpty()) {
Spacer(modifier = Modifier.height(10.dp)) Spacer(modifier = Modifier.height(10.dp))
@OptIn(ExperimentalLayoutApi::class)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
post.tags.forEach { tag ->
Text( Text(
text = "#$tag", text = post.tags.joinToString(" \u00B7 ") { "#$it" },
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold), style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
modifier = Modifier.clickable { onTagClick(tag) } maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.clickable {
post.tags.firstOrNull()?.let { onTagClick(it) }
}
) )
} }
}
}
// Queue status // Queue status
if (post.queueStatus != QueueStatus.NONE) { if (post.queueStatus != QueueStatus.NONE) {

View file

@ -13,6 +13,7 @@ import com.swoosh.microblog.data.api.ApiClient
import com.swoosh.microblog.data.db.Converters 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.PostRepository import com.swoosh.microblog.data.repository.PostRepository
import com.swoosh.microblog.data.repository.TagRepository
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.reflect.TypeToken import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
@ -38,6 +39,7 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
private val accountManager = AccountManager(application) private val accountManager = AccountManager(application)
private var repository = PostRepository(application) private var repository = PostRepository(application)
private var tagRepository = TagRepository(application)
private val feedPreferences = FeedPreferences(application) private val feedPreferences = FeedPreferences(application)
private val searchHistoryManager = SearchHistoryManager(application) private val searchHistoryManager = SearchHistoryManager(application)
@ -71,6 +73,9 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
private val _recentSearches = MutableStateFlow<List<String>>(emptyList()) private val _recentSearches = MutableStateFlow<List<String>>(emptyList())
val recentSearches: StateFlow<List<String>> = _recentSearches.asStateFlow() val recentSearches: StateFlow<List<String>> = _recentSearches.asStateFlow()
private val _popularTags = MutableStateFlow<List<GhostTagFull>>(emptyList())
val popularTags: StateFlow<List<GhostTagFull>> = _popularTags.asStateFlow()
private val _accounts = MutableStateFlow<List<GhostAccount>>(emptyList()) private val _accounts = MutableStateFlow<List<GhostAccount>>(emptyList())
val accounts: StateFlow<List<GhostAccount>> = _accounts.asStateFlow() val accounts: StateFlow<List<GhostAccount>> = _accounts.asStateFlow()
@ -237,8 +242,9 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
accountManager.setActiveAccount(accountId) accountManager.setActiveAccount(accountId)
ApiClient.reset() ApiClient.reset()
// Re-create repository to pick up new account // Re-create repositories to pick up new account
repository = PostRepository(getApplication()) repository = PostRepository(getApplication())
tagRepository = TagRepository(getApplication())
refreshAccountsList() refreshAccountsList()
@ -296,6 +302,18 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
val sort = _sortOrder.value val sort = _sortOrder.value
val tagFilter = _uiState.value.activeTagFilter val tagFilter = _uiState.value.activeTagFilter
// Fetch popular tags in parallel
launch {
tagRepository.fetchTags().fold(
onSuccess = { tags ->
_popularTags.value = tags
.sortedByDescending { it.count?.posts ?: 0 }
.take(10)
},
onFailure = { /* silently ignore tag fetch failures */ }
)
}
repository.fetchPosts(page = 1, filter = filter, sortOrder = sort, tagFilter = tagFilter).fold( repository.fetchPosts(page = 1, filter = filter, sortOrder = sort, tagFilter = tagFilter).fold(
onSuccess = { response -> onSuccess = { response ->
remotePosts = response.posts.map { it.toFeedPost() } remotePosts = response.posts.map { it.toFeedPost() }

View file

@ -38,6 +38,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 {
@ -53,6 +54,7 @@ object Routes {
const val PAGES = "pages" const val PAGES = "pages"
const val MEMBERS = "members" const val MEMBERS = "members"
const val MEMBER_DETAIL = "member_detail" const val MEMBER_DETAIL = "member_detail"
const val TAGS = "tags"
} }
data class BottomNavItem( data class BottomNavItem(
@ -266,6 +268,9 @@ fun SwooshNavGraph(
}, },
onNavigateToPages = { onNavigateToPages = {
navController.navigate(Routes.PAGES) navController.navigate(Routes.PAGES)
},
onNavigateToTags = {
navController.navigate(Routes.TAGS)
} }
) )
} }
@ -284,6 +289,18 @@ fun SwooshNavGraph(
) )
} }
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)) },

View file

@ -13,9 +13,11 @@ 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.automirrored.filled.OpenInNew import androidx.compose.material.icons.automirrored.filled.OpenInNew
import androidx.compose.material.icons.filled.Tag
import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
@ -45,7 +47,8 @@ fun SettingsScreen(
onBack: () -> Unit, onBack: () -> Unit,
onLogout: () -> Unit, onLogout: () -> Unit,
themeViewModel: ThemeViewModel? = null, themeViewModel: ThemeViewModel? = null,
onNavigateToPages: () -> Unit = {} onNavigateToPages: () -> Unit = {},
onNavigateToTags: () -> Unit = {}
) { ) {
val context = LocalContext.current val context = LocalContext.current
val accountManager = remember { AccountManager(context) } val accountManager = remember { AccountManager(context) }
@ -248,6 +251,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))

View file

@ -3,8 +3,11 @@ package com.swoosh.microblog.ui.stats
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.animateIntAsState import androidx.compose.animation.core.animateIntAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Article import androidx.compose.material.icons.automirrored.filled.Article
@ -13,10 +16,15 @@ import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.swoosh.microblog.data.model.GhostTagFull
import com.swoosh.microblog.ui.tags.parseHexColor
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -146,6 +154,57 @@ fun StatsScreen(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
// Tags section
if (state.tagStats.isNotEmpty()) {
Text(
"Tags",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Most used tag
if (state.mostUsedTag != null) {
WritingStatRow(
"Most used tag",
"#${state.mostUsedTag!!.name} (${state.mostUsedTag!!.count?.posts ?: 0})"
)
HorizontalDivider()
}
// Posts without tags
WritingStatRow(
"Posts without tags",
"${state.postsWithoutTags}"
)
HorizontalDivider()
// Total tags
WritingStatRow(
"Total tags",
"${state.tagStats.size}"
)
// Tag progress bars
val maxCount = state.tagStats.maxOfOrNull { it.count?.posts ?: 0 } ?: 1
state.tagStats.take(10).forEach { tag ->
TagProgressBar(
tag = tag,
maxCount = maxCount.coerceAtLeast(1)
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
}
// Writing stats section // Writing stats section
Text( Text(
"Writing Stats", "Writing Stats",
@ -267,6 +326,68 @@ private fun formatMrr(cents: Int): String {
} }
} }
@Composable
private fun TagProgressBar(
tag: GhostTagFull,
maxCount: Int
) {
val postCount = tag.count?.posts ?: 0
val progress = postCount.toFloat() / maxCount.toFloat()
val animatedProgress by animateFloatAsState(
targetValue = progress,
animationSpec = tween(600),
label = "tagProgress_${tag.name}"
)
val barColor = tag.accent_color?.let { parseHexColor(it) }
?: MaterialTheme.colorScheme.primary
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f)
) {
Box(
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(barColor)
)
Text(
text = tag.name,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Text(
text = "$postCount",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(4.dp))
LinearProgressIndicator(
progress = { animatedProgress },
modifier = Modifier
.fillMaxWidth()
.height(6.dp)
.clip(RoundedCornerShape(3.dp)),
color = barColor,
trackColor = MaterialTheme.colorScheme.surfaceVariant
)
}
}
@Composable @Composable
private fun StatsCard( private fun StatsCard(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,

View file

@ -4,10 +4,12 @@ import android.app.Application
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
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.OverallStats import com.swoosh.microblog.data.model.OverallStats
import com.swoosh.microblog.data.repository.MemberRepository import com.swoosh.microblog.data.repository.MemberRepository
import com.swoosh.microblog.data.repository.MemberStats import com.swoosh.microblog.data.repository.MemberStats
import com.swoosh.microblog.data.repository.PostRepository import com.swoosh.microblog.data.repository.PostRepository
import com.swoosh.microblog.data.repository.TagRepository
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@ -18,6 +20,7 @@ class StatsViewModel(application: Application) : AndroidViewModel(application) {
private val repository = PostRepository(application) private val repository = PostRepository(application)
private val memberRepository = MemberRepository(application) private val memberRepository = MemberRepository(application)
private val tagRepository = TagRepository(application)
private val _uiState = MutableStateFlow(StatsUiState()) private val _uiState = MutableStateFlow(StatsUiState())
val uiState: StateFlow<StatsUiState> = _uiState.asStateFlow() val uiState: StateFlow<StatsUiState> = _uiState.asStateFlow()
@ -53,6 +56,7 @@ class StatsViewModel(application: Application) : AndroidViewModel(application) {
linkTitle = null, linkTitle = null,
linkDescription = null, linkDescription = null,
linkImageUrl = null, linkImageUrl = null,
tags = ghost.tags?.map { it.name } ?: emptyList(),
status = ghost.status ?: "draft", status = ghost.status ?: "draft",
publishedAt = ghost.published_at, publishedAt = ghost.published_at,
createdAt = ghost.created_at, createdAt = ghost.created_at,
@ -87,7 +91,33 @@ class StatsViewModel(application: Application) : AndroidViewModel(application) {
null null
} }
_uiState.update { it.copy(stats = stats, memberStats = memberStats, isLoading = false) } // Fetch tag stats
val tagStats = try {
tagRepository.fetchTags().getOrNull()
?.sortedByDescending { it.count?.posts ?: 0 }
?: emptyList()
} catch (e: Exception) {
emptyList()
}
// Count posts without any tags
val totalPosts = localPosts.size + uniqueRemotePosts.size
val postsWithTags = uniqueRemotePosts.count { it.tags.isNotEmpty() }
val postsWithoutTags = totalPosts - postsWithTags
// Most used tag
val mostUsedTag = tagStats.firstOrNull()
_uiState.update {
it.copy(
stats = stats,
memberStats = memberStats,
tagStats = tagStats,
mostUsedTag = mostUsedTag,
postsWithoutTags = postsWithoutTags,
isLoading = false
)
}
} catch (e: Exception) { } catch (e: Exception) {
_uiState.update { it.copy(isLoading = false, error = e.message) } _uiState.update { it.copy(isLoading = false, error = e.message) }
} }
@ -98,6 +128,9 @@ class StatsViewModel(application: Application) : AndroidViewModel(application) {
data class StatsUiState( data class StatsUiState(
val stats: OverallStats = OverallStats(), val stats: OverallStats = OverallStats(),
val memberStats: MemberStats? = null, val memberStats: MemberStats? = null,
val tagStats: List<GhostTagFull> = emptyList(),
val mostUsedTag: GhostTagFull? = null,
val postsWithoutTags: Int = 0,
val isLoading: Boolean = false, val isLoading: Boolean = false,
val error: String? = null val error: String? = null
) )

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) }
}

View file

@ -0,0 +1,196 @@
package com.swoosh.microblog.data.model
import com.google.gson.Gson
import org.junit.Assert.*
import org.junit.Test
class TagModelsTest {
private val gson = Gson()
// --- GhostTagFull defaults ---
@Test
fun `GhostTagFull default id is null`() {
val tag = GhostTagFull(name = "test")
assertNull(tag.id)
}
@Test
fun `GhostTagFull default visibility is public`() {
val tag = GhostTagFull(name = "test")
assertEquals("public", tag.visibility)
}
@Test
fun `GhostTagFull default optional fields are null`() {
val tag = GhostTagFull(name = "test")
assertNull(tag.slug)
assertNull(tag.description)
assertNull(tag.feature_image)
assertNull(tag.accent_color)
assertNull(tag.count)
assertNull(tag.created_at)
assertNull(tag.updated_at)
assertNull(tag.url)
}
@Test
fun `GhostTagFull stores all fields`() {
val tag = GhostTagFull(
id = "tag-1",
name = "kotlin",
slug = "kotlin",
description = "Posts about Kotlin",
feature_image = "https://example.com/kotlin.png",
visibility = "public",
accent_color = "#FF5722",
count = TagCount(posts = 42),
created_at = "2024-01-01T00:00:00.000Z",
updated_at = "2024-06-15T12:00:00.000Z",
url = "https://blog.example.com/tag/kotlin/"
)
assertEquals("tag-1", tag.id)
assertEquals("kotlin", tag.name)
assertEquals("kotlin", tag.slug)
assertEquals("Posts about Kotlin", tag.description)
assertEquals("https://example.com/kotlin.png", tag.feature_image)
assertEquals("public", tag.visibility)
assertEquals("#FF5722", tag.accent_color)
assertEquals(42, tag.count?.posts)
assertEquals("2024-01-01T00:00:00.000Z", tag.created_at)
assertEquals("2024-06-15T12:00:00.000Z", tag.updated_at)
assertEquals("https://blog.example.com/tag/kotlin/", tag.url)
}
// --- TagCount ---
@Test
fun `TagCount stores post count`() {
val count = TagCount(posts = 10)
assertEquals(10, count.posts)
}
@Test
fun `TagCount allows null posts`() {
val count = TagCount(posts = null)
assertNull(count.posts)
}
// --- GSON serialization ---
@Test
fun `GhostTagFull serializes to JSON correctly`() {
val tag = GhostTagFull(
name = "android",
description = "Android development",
accent_color = "#3DDC84"
)
val json = gson.toJson(tag)
assertTrue(json.contains("\"name\":\"android\""))
assertTrue(json.contains("\"description\":\"Android development\""))
assertTrue(json.contains("\"accent_color\":\"#3DDC84\""))
}
@Test
fun `GhostTagFull deserializes from JSON correctly`() {
val json = """{
"id": "abc123",
"name": "tech",
"slug": "tech",
"description": "Technology posts",
"visibility": "public",
"accent_color": "#1E88E5",
"count": {"posts": 15},
"created_at": "2024-01-01T00:00:00.000Z",
"updated_at": "2024-06-01T00:00:00.000Z",
"url": "https://blog.example.com/tag/tech/"
}"""
val tag = gson.fromJson(json, GhostTagFull::class.java)
assertEquals("abc123", tag.id)
assertEquals("tech", tag.name)
assertEquals("tech", tag.slug)
assertEquals("Technology posts", tag.description)
assertEquals("public", tag.visibility)
assertEquals("#1E88E5", tag.accent_color)
assertEquals(15, tag.count?.posts)
assertEquals("2024-01-01T00:00:00.000Z", tag.created_at)
assertEquals("2024-06-01T00:00:00.000Z", tag.updated_at)
assertEquals("https://blog.example.com/tag/tech/", tag.url)
}
@Test
fun `GhostTagFull deserializes with missing optional fields`() {
val json = """{"name": "minimal"}"""
val tag = gson.fromJson(json, GhostTagFull::class.java)
assertEquals("minimal", tag.name)
assertNull(tag.id)
assertNull(tag.slug)
assertNull(tag.description)
assertNull(tag.accent_color)
assertNull(tag.count)
}
// --- TagsResponse ---
@Test
fun `TagsResponse deserializes with tags and meta`() {
val json = """{
"tags": [
{"id": "1", "name": "news", "slug": "news", "count": {"posts": 5}},
{"id": "2", "name": "tech", "slug": "tech", "count": {"posts": 12}}
],
"meta": {"pagination": {"page": 1, "limit": 15, "pages": 1, "total": 2, "next": null, "prev": null}}
}"""
val response = gson.fromJson(json, TagsResponse::class.java)
assertEquals(2, response.tags.size)
assertEquals("news", response.tags[0].name)
assertEquals(5, response.tags[0].count?.posts)
assertEquals("tech", response.tags[1].name)
assertEquals(12, response.tags[1].count?.posts)
assertNotNull(response.meta)
assertEquals(1, response.meta?.pagination?.page)
assertEquals(2, response.meta?.pagination?.total)
}
@Test
fun `TagsResponse deserializes with null meta`() {
val json = """{"tags": [{"name": "solo"}], "meta": null}"""
val response = gson.fromJson(json, TagsResponse::class.java)
assertEquals(1, response.tags.size)
assertNull(response.meta)
}
// --- TagWrapper ---
@Test
fun `TagWrapper wraps tags for API request`() {
val wrapper = TagWrapper(listOf(GhostTagFull(name = "new-tag", description = "A new tag")))
val json = gson.toJson(wrapper)
assertTrue(json.contains("\"tags\""))
assertTrue(json.contains("\"new-tag\""))
assertTrue(json.contains("\"A new tag\""))
}
@Test
fun `TagWrapper serializes accent_color`() {
val wrapper = TagWrapper(listOf(GhostTagFull(
name = "colored",
accent_color = "#FF0000"
)))
val json = gson.toJson(wrapper)
assertTrue(json.contains("\"accent_color\":\"#FF0000\""))
}
@Test
fun `TagCount zero posts`() {
val count = TagCount(posts = 0)
assertEquals(0, count.posts)
}
@Test
fun `GhostTagFull with internal visibility`() {
val tag = GhostTagFull(name = "internal-tag", visibility = "internal")
assertEquals("internal", tag.visibility)
}
}