mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 11:55:47 +00:00
merge: integrate Phase 2 (Tags CRUD) with existing phases
This commit is contained in:
commit
807c6d559e
14 changed files with 1400 additions and 77 deletions
|
|
@ -6,6 +6,8 @@ import com.swoosh.microblog.data.model.PageWrapper
|
|||
import com.swoosh.microblog.data.model.PagesResponse
|
||||
import com.swoosh.microblog.data.model.PostWrapper
|
||||
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.RequestBody
|
||||
import retrofit2.Response
|
||||
|
|
@ -84,6 +86,28 @@ interface GhostApiService {
|
|||
@DELETE("ghost/api/admin/pages/{id}/")
|
||||
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
|
||||
@POST("ghost/api/admin/images/upload/")
|
||||
suspend fun uploadImage(
|
||||
|
|
|
|||
|
|
@ -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?)
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -49,6 +49,7 @@ import com.swoosh.microblog.data.AccountManager
|
|||
import com.swoosh.microblog.data.HashtagParser
|
||||
import com.swoosh.microblog.data.SiteMetadataCache
|
||||
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.ui.animation.SwooshMotion
|
||||
import kotlinx.coroutines.delay
|
||||
|
|
@ -360,49 +361,16 @@ fun ComposerScreen(
|
|||
}
|
||||
)
|
||||
|
||||
// Extracted tags preview chips
|
||||
AnimatedVisibility(
|
||||
visible = state.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))
|
||||
Row(
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Tags section: input + suggestions + chips
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
TagsSection(
|
||||
tagInput = state.tagInput,
|
||||
onTagInputChange = viewModel::updateTagInput,
|
||||
tagSuggestions = state.tagSuggestions,
|
||||
extractedTags = state.extractedTags,
|
||||
onAddTag = viewModel::addTag,
|
||||
onRemoveTag = viewModel::removeTag
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
|
|
@ -915,3 +883,128 @@ class HashtagVisualTransformation(private val hashtagColor: Color) : VisualTrans
|
|||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import com.swoosh.microblog.data.db.Converters
|
|||
import com.swoosh.microblog.data.model.*
|
||||
import com.swoosh.microblog.data.repository.OpenGraphFetcher
|
||||
import com.swoosh.microblog.data.repository.PostRepository
|
||||
import com.swoosh.microblog.data.repository.TagRepository
|
||||
import com.swoosh.microblog.worker.PostUploadWorker
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
|
|
@ -25,6 +26,7 @@ import kotlinx.coroutines.launch
|
|||
class ComposerViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
private val repository = PostRepository(application)
|
||||
private val tagRepository = TagRepository(application)
|
||||
private val appContext = application
|
||||
|
||||
private val _uiState = MutableStateFlow(ComposerUiState())
|
||||
|
|
@ -36,6 +38,47 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
|||
|
||||
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) {
|
||||
editingLocalId = post.localId
|
||||
editingGhostId = post.ghostId
|
||||
|
|
@ -197,8 +240,10 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
|||
_uiState.update { it.copy(isSubmitting = true, error = null) }
|
||||
|
||||
val title = state.text.take(60)
|
||||
val extractedTags = HashtagParser.parse(state.text)
|
||||
val tagsJson = Gson().toJson(extractedTags)
|
||||
// Merge hashtag-parsed tags with manually-added tags (deduplicated)
|
||||
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 }
|
||||
|
||||
|
|
@ -252,7 +297,7 @@ class ComposerViewModel(application: Application) : AndroidViewModel(application
|
|||
state.linkPreview?.url, state.linkPreview?.title, state.linkPreview?.description,
|
||||
altText
|
||||
)
|
||||
val ghostTags = extractedTags.map { GhostTag(name = it) }
|
||||
val ghostTags = allTags.map { GhostTag(name = it) }
|
||||
|
||||
val ghostPost = GhostPost(
|
||||
title = title,
|
||||
|
|
@ -329,6 +374,9 @@ data class ComposerUiState(
|
|||
val scheduledAt: String? = null,
|
||||
val featured: Boolean = false,
|
||||
val extractedTags: List<String> = emptyList(),
|
||||
val availableTags: List<GhostTagFull> = emptyList(),
|
||||
val tagSuggestions: List<GhostTagFull> = emptyList(),
|
||||
val tagInput: String = "",
|
||||
val isSubmitting: Boolean = false,
|
||||
val isSuccess: Boolean = false,
|
||||
val isEditing: Boolean = false,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import androidx.compose.foundation.horizontalScroll
|
|||
import androidx.compose.foundation.layout.*
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
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.model.FeedPost
|
||||
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.PostStats
|
||||
import com.swoosh.microblog.data.model.QueueStatus
|
||||
|
|
@ -110,6 +112,7 @@ fun FeedScreen(
|
|||
val recentSearches by viewModel.recentSearches.collectAsStateWithLifecycle()
|
||||
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
|
||||
val activeAccount by viewModel.activeAccount.collectAsStateWithLifecycle()
|
||||
val popularTags by viewModel.popularTags.collectAsStateWithLifecycle()
|
||||
val listState = rememberLazyListState()
|
||||
val context = LocalContext.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
|
@ -325,20 +328,17 @@ fun FeedScreen(
|
|||
)
|
||||
}
|
||||
|
||||
// Active tag filter bar
|
||||
if (state.activeTagFilter != null) {
|
||||
FilterChip(
|
||||
onClick = { viewModel.clearTagFilter() },
|
||||
label = { Text("#${state.activeTagFilter}") },
|
||||
selected = true,
|
||||
leadingIcon = {
|
||||
Icon(Icons.Default.Tag, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
},
|
||||
trailingIcon = {
|
||||
Icon(Icons.Default.Close, contentDescription = "Clear filter", modifier = Modifier.size(16.dp))
|
||||
},
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
// Tag filter chips
|
||||
AnimatedVisibility(
|
||||
visible = !isSearchActive && popularTags.isNotEmpty(),
|
||||
enter = fadeIn(SwooshMotion.quick()) + expandVertically(),
|
||||
exit = fadeOut(SwooshMotion.quick()) + shrinkVertically()
|
||||
) {
|
||||
TagFilterChipsBar(
|
||||
tags = popularTags,
|
||||
activeTagFilter = state.activeTagFilter,
|
||||
onTagSelected = { viewModel.filterByTag(it) },
|
||||
onClearFilter = { viewModel.clearTagFilter() }
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
fun SortButton(
|
||||
currentSort: SortOrder,
|
||||
|
|
@ -1539,23 +1591,19 @@ fun PostCardContent(
|
|||
}
|
||||
}
|
||||
|
||||
// Hashtag tags (bold colored text, not chips)
|
||||
// Tags display
|
||||
if (post.tags.isNotEmpty()) {
|
||||
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 = "#$tag",
|
||||
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.clickable { onTagClick(tag) }
|
||||
)
|
||||
Text(
|
||||
text = post.tags.joinToString(" \u00B7 ") { "#$it" },
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.clickable {
|
||||
post.tags.firstOrNull()?.let { onTagClick(it) }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Queue status
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import com.swoosh.microblog.data.api.ApiClient
|
|||
import com.swoosh.microblog.data.db.Converters
|
||||
import com.swoosh.microblog.data.model.*
|
||||
import com.swoosh.microblog.data.repository.PostRepository
|
||||
import com.swoosh.microblog.data.repository.TagRepository
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
|
|
@ -38,6 +39,7 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
|||
|
||||
private val accountManager = AccountManager(application)
|
||||
private var repository = PostRepository(application)
|
||||
private var tagRepository = TagRepository(application)
|
||||
private val feedPreferences = FeedPreferences(application)
|
||||
private val searchHistoryManager = SearchHistoryManager(application)
|
||||
|
||||
|
|
@ -71,6 +73,9 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
|||
private val _recentSearches = MutableStateFlow<List<String>>(emptyList())
|
||||
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())
|
||||
val accounts: StateFlow<List<GhostAccount>> = _accounts.asStateFlow()
|
||||
|
||||
|
|
@ -237,8 +242,9 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
|||
accountManager.setActiveAccount(accountId)
|
||||
ApiClient.reset()
|
||||
|
||||
// Re-create repository to pick up new account
|
||||
// Re-create repositories to pick up new account
|
||||
repository = PostRepository(getApplication())
|
||||
tagRepository = TagRepository(getApplication())
|
||||
|
||||
refreshAccountsList()
|
||||
|
||||
|
|
@ -296,6 +302,18 @@ class FeedViewModel(application: Application) : AndroidViewModel(application) {
|
|||
val sort = _sortOrder.value
|
||||
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(
|
||||
onSuccess = { response ->
|
||||
remotePosts = response.posts.map { it.toFeedPost() }
|
||||
|
|
|
|||
|
|
@ -38,6 +38,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 {
|
||||
|
|
@ -53,6 +54,7 @@ object Routes {
|
|||
const val PAGES = "pages"
|
||||
const val MEMBERS = "members"
|
||||
const val MEMBER_DETAIL = "member_detail"
|
||||
const val TAGS = "tags"
|
||||
}
|
||||
|
||||
data class BottomNavItem(
|
||||
|
|
@ -266,6 +268,9 @@ fun SwooshNavGraph(
|
|||
},
|
||||
onNavigateToPages = {
|
||||
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(
|
||||
Routes.PREVIEW,
|
||||
enterTransition = { slideInVertically(initialOffsetY = { it }, animationSpec = tween(250)) + fadeIn(tween(200)) },
|
||||
|
|
|
|||
|
|
@ -13,9 +13,11 @@ 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.automirrored.filled.OpenInNew
|
||||
import androidx.compose.material.icons.filled.Tag
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
|
|
@ -45,7 +47,8 @@ fun SettingsScreen(
|
|||
onBack: () -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
themeViewModel: ThemeViewModel? = null,
|
||||
onNavigateToPages: () -> Unit = {}
|
||||
onNavigateToPages: () -> Unit = {},
|
||||
onNavigateToTags: () -> Unit = {}
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val accountManager = remember { AccountManager(context) }
|
||||
|
|
@ -248,6 +251,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))
|
||||
|
|
|
|||
|
|
@ -3,8 +3,11 @@ package com.swoosh.microblog.ui.stats
|
|||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.animateIntAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Article
|
||||
|
|
@ -13,10 +16,15 @@ 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.tags.parseHexColor
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
|
|
@ -146,6 +154,57 @@ fun StatsScreen(
|
|||
|
||||
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
|
||||
Text(
|
||||
"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
|
||||
private fun StatsCard(
|
||||
modifier: Modifier = Modifier,
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@ import android.app.Application
|
|||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
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.repository.MemberRepository
|
||||
import com.swoosh.microblog.data.repository.MemberStats
|
||||
import com.swoosh.microblog.data.repository.PostRepository
|
||||
import com.swoosh.microblog.data.repository.TagRepository
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
|
@ -18,6 +20,7 @@ class StatsViewModel(application: Application) : AndroidViewModel(application) {
|
|||
|
||||
private val repository = PostRepository(application)
|
||||
private val memberRepository = MemberRepository(application)
|
||||
private val tagRepository = TagRepository(application)
|
||||
|
||||
private val _uiState = MutableStateFlow(StatsUiState())
|
||||
val uiState: StateFlow<StatsUiState> = _uiState.asStateFlow()
|
||||
|
|
@ -53,6 +56,7 @@ class StatsViewModel(application: Application) : AndroidViewModel(application) {
|
|||
linkTitle = null,
|
||||
linkDescription = null,
|
||||
linkImageUrl = null,
|
||||
tags = ghost.tags?.map { it.name } ?: emptyList(),
|
||||
status = ghost.status ?: "draft",
|
||||
publishedAt = ghost.published_at,
|
||||
createdAt = ghost.created_at,
|
||||
|
|
@ -87,7 +91,33 @@ class StatsViewModel(application: Application) : AndroidViewModel(application) {
|
|||
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) {
|
||||
_uiState.update { it.copy(isLoading = false, error = e.message) }
|
||||
}
|
||||
|
|
@ -98,6 +128,9 @@ class StatsViewModel(application: Application) : AndroidViewModel(application) {
|
|||
data class StatsUiState(
|
||||
val stats: OverallStats = OverallStats(),
|
||||
val memberStats: MemberStats? = null,
|
||||
val tagStats: List<GhostTagFull> = emptyList(),
|
||||
val mostUsedTag: GhostTagFull? = null,
|
||||
val postsWithoutTags: Int = 0,
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null
|
||||
)
|
||||
|
|
|
|||
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) }
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue