mirror of
https://github.com/pawelorzech/Swoosh.git
synced 2026-03-31 20:15:41 +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.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(
|
||||||
|
|
|
||||||
|
|
@ -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.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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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() }
|
||||||
|
|
|
||||||
|
|
@ -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)) },
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
||||||
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