From d0019947f80a8b592f54d52c2e4fef7241429494 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Orzech?= Date: Fri, 20 Mar 2026 00:25:34 +0100 Subject: [PATCH 1/6] feat: add extended tag model (GhostTagFull) and tag CRUD API endpoints Add TagModels.kt with GhostTagFull, TagsResponse, TagWrapper, TagCount data classes for full Ghost tag management. Add getTags, getTag, createTag, updateTag, deleteTag endpoints to GhostApiService. --- .../microblog/data/api/GhostApiService.kt | 22 ++ .../swoosh/microblog/data/model/TagModels.kt | 26 +++ .../microblog/data/model/TagModelsTest.kt | 196 ++++++++++++++++++ 3 files changed, 244 insertions(+) create mode 100644 app/src/main/java/com/swoosh/microblog/data/model/TagModels.kt create mode 100644 app/src/test/java/com/swoosh/microblog/data/model/TagModelsTest.kt diff --git a/app/src/main/java/com/swoosh/microblog/data/api/GhostApiService.kt b/app/src/main/java/com/swoosh/microblog/data/api/GhostApiService.kt index cd41155..199b886 100644 --- a/app/src/main/java/com/swoosh/microblog/data/api/GhostApiService.kt +++ b/app/src/main/java/com/swoosh/microblog/data/api/GhostApiService.kt @@ -2,6 +2,8 @@ package com.swoosh.microblog.data.api 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 @@ -40,6 +42,26 @@ interface GhostApiService { @GET("ghost/api/admin/users/me/") suspend fun getCurrentUser(): Response + @GET("ghost/api/admin/tags/") + suspend fun getTags( + @Query("limit") limit: String = "all", + @Query("include") include: String = "count.posts" + ): Response + + @GET("ghost/api/admin/tags/{id}/") + suspend fun getTag(@Path("id") id: String): Response + + @POST("ghost/api/admin/tags/") + @Headers("Content-Type: application/json") + suspend fun createTag(@Body body: TagWrapper): Response + + @PUT("ghost/api/admin/tags/{id}/") + @Headers("Content-Type: application/json") + suspend fun updateTag(@Path("id") id: String, @Body body: TagWrapper): Response + + @DELETE("ghost/api/admin/tags/{id}/") + suspend fun deleteTag(@Path("id") id: String): Response + @Multipart @POST("ghost/api/admin/images/upload/") suspend fun uploadImage( diff --git a/app/src/main/java/com/swoosh/microblog/data/model/TagModels.kt b/app/src/main/java/com/swoosh/microblog/data/model/TagModels.kt new file mode 100644 index 0000000..3b21270 --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/data/model/TagModels.kt @@ -0,0 +1,26 @@ +package com.swoosh.microblog.data.model + +data class TagsResponse( + val tags: List, + val meta: Meta? +) + +data class TagWrapper( + val tags: List +) + +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?) diff --git a/app/src/test/java/com/swoosh/microblog/data/model/TagModelsTest.kt b/app/src/test/java/com/swoosh/microblog/data/model/TagModelsTest.kt new file mode 100644 index 0000000..83630ef --- /dev/null +++ b/app/src/test/java/com/swoosh/microblog/data/model/TagModelsTest.kt @@ -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) + } +} From 2dbb4ad0052ba61e3baec5b3aa3137d13d493083 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Orzech?= Date: Fri, 20 Mar 2026 00:26:15 +0100 Subject: [PATCH 2/6] feat: add TagRepository for tag CRUD operations Follows PostRepository pattern: constructor takes Context, creates AccountManager, uses ApiClient.getService, wraps calls in withContext(Dispatchers.IO), returns Result. --- .../data/repository/TagRepository.kt | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 app/src/main/java/com/swoosh/microblog/data/repository/TagRepository.kt diff --git a/app/src/main/java/com/swoosh/microblog/data/repository/TagRepository.kt b/app/src/main/java/com/swoosh/microblog/data/repository/TagRepository.kt new file mode 100644 index 0000000..229e940 --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/data/repository/TagRepository.kt @@ -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> = + 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 = + 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 = + 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 = + 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) + } + } +} From 532e04e571fa6a9e4e8ef4f5835d8ec735bee5c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Orzech?= Date: Fri, 20 Mar 2026 00:28:21 +0100 Subject: [PATCH 3/6] feat: add tag autocomplete in Composer with suggestions and chips ComposerViewModel fetches available tags from TagRepository on init, filters suggestions as user types, supports addTag/removeTag. ComposerScreen shows tag input field with dropdown suggestions (name + post count), "Create new" option, and FlowRow of InputChip tags with close icons. --- .../microblog/ui/composer/ComposerScreen.kt | 179 +++++++++++++----- .../ui/composer/ComposerViewModel.kt | 54 +++++- 2 files changed, 187 insertions(+), 46 deletions(-) diff --git a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt index 560cd7b..863765e 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerScreen.kt @@ -45,6 +45,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage import com.swoosh.microblog.data.HashtagParser 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 @@ -323,49 +324,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)) @@ -878,3 +846,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, + extractedTags: List, + 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 + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt index 65b4282..be46fc8 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/composer/ComposerViewModel.kt @@ -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 = emptyList(), + val availableTags: List = emptyList(), + val tagSuggestions: List = emptyList(), + val tagInput: String = "", val isSubmitting: Boolean = false, val isSuccess: Boolean = false, val isEditing: Boolean = false, From 11b20fd42a1e670bbbd5b90cd2d328be4a83b2f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Orzech?= Date: Fri, 20 Mar 2026 00:31:53 +0100 Subject: [PATCH 4/6] feat: add Tags management screen with list/edit modes TagsViewModel manages tag CRUD state. TagsScreen shows searchable list of OutlinedCards with accent dot, name, count, description. Edit mode supports name, slug (read-only), description, accent_color hex, visibility radio. Wired into NavGraph via Routes.TAGS and accessible from Settings screen via "Tags" row. --- .../microblog/ui/navigation/NavGraph.kt | 17 + .../microblog/ui/settings/SettingsScreen.kt | 47 +- .../swoosh/microblog/ui/tags/TagsScreen.kt | 448 ++++++++++++++++++ .../swoosh/microblog/ui/tags/TagsViewModel.kt | 120 +++++ 4 files changed, 631 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/swoosh/microblog/ui/tags/TagsScreen.kt create mode 100644 app/src/main/java/com/swoosh/microblog/ui/tags/TagsViewModel.kt diff --git a/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt b/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt index bb4c93f..41ad8f0 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/navigation/NavGraph.kt @@ -34,6 +34,7 @@ import com.swoosh.microblog.ui.preview.PreviewScreen import com.swoosh.microblog.ui.settings.SettingsScreen import com.swoosh.microblog.ui.setup.SetupScreen import com.swoosh.microblog.ui.stats.StatsScreen +import com.swoosh.microblog.ui.tags.TagsScreen import com.swoosh.microblog.ui.theme.ThemeViewModel object Routes { @@ -46,6 +47,7 @@ object Routes { const val STATS = "stats" const val PREVIEW = "preview" const val ADD_ACCOUNT = "add_account" + const val TAGS = "tags" } data class BottomNavItem( @@ -255,6 +257,9 @@ fun SwooshNavGraph( navController.navigate(Routes.SETUP) { popUpTo(0) { inclusive = true } } + }, + onNavigateToTags = { + navController.navigate(Routes.TAGS) } ) } @@ -269,6 +274,18 @@ fun SwooshNavGraph( StatsScreen() } + composable( + Routes.TAGS, + enterTransition = { slideInHorizontally(initialOffsetX = { it }, animationSpec = tween(250)) + fadeIn(tween(200)) }, + exitTransition = { fadeOut(tween(150)) }, + popEnterTransition = { fadeIn(tween(200)) }, + popExitTransition = { slideOutHorizontally(targetOffsetX = { it }, animationSpec = tween(200)) + fadeOut(tween(150)) } + ) { + TagsScreen( + onBack = { navController.popBackStack() } + ) + } + composable( Routes.PREVIEW, enterTransition = { slideInVertically(initialOffsetY = { it }, animationSpec = tween(250)) + fadeIn(tween(200)) }, diff --git a/app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt index 28e7248..1f97a0e 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/settings/SettingsScreen.kt @@ -8,8 +8,10 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.BrightnessAuto +import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.DarkMode import androidx.compose.material.icons.filled.LightMode +import androidx.compose.material.icons.filled.Tag import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -30,7 +32,8 @@ import com.swoosh.microblog.ui.theme.ThemeViewModel fun SettingsScreen( onBack: () -> Unit, onLogout: () -> Unit, - themeViewModel: ThemeViewModel? = null + themeViewModel: ThemeViewModel? = null, + onNavigateToTags: () -> Unit = {} ) { val context = LocalContext.current val accountManager = remember { AccountManager(context) } @@ -75,6 +78,48 @@ fun SettingsScreen( Spacer(modifier = Modifier.height(24.dp)) } + // --- Content Management section --- + Text("Content", style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(12.dp)) + + OutlinedCard( + onClick = onNavigateToTags, + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Tag, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + "Tags", + style = MaterialTheme.typography.bodyLarge + ) + } + Icon( + Icons.Default.ChevronRight, + contentDescription = "Navigate to tags", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp) + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(24.dp)) + // --- Current Account section --- Text("Current Account", style = MaterialTheme.typography.titleMedium) Spacer(modifier = Modifier.height(12.dp)) diff --git a/app/src/main/java/com/swoosh/microblog/ui/tags/TagsScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/tags/TagsScreen.kt new file mode 100644 index 0000000..e5de360 --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/ui/tags/TagsScreen.kt @@ -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) + } +} diff --git a/app/src/main/java/com/swoosh/microblog/ui/tags/TagsViewModel.kt b/app/src/main/java/com/swoosh/microblog/ui/tags/TagsViewModel.kt new file mode 100644 index 0000000..2738194 --- /dev/null +++ b/app/src/main/java/com/swoosh/microblog/ui/tags/TagsViewModel.kt @@ -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 = _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 = emptyList(), + val isLoading: Boolean = false, + val error: String? = null, + val searchQuery: String = "", + val editingTag: GhostTagFull? = null +) { + val filteredTags: List + get() = if (searchQuery.isBlank()) tags + else tags.filter { it.name.contains(searchQuery, ignoreCase = true) } +} From aaf29f1512f85885db971e604b2ea248df3719c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Orzech?= Date: Fri, 20 Mar 2026 00:33:57 +0100 Subject: [PATCH 5/6] feat: add tag filter chips in Feed with popular tags LazyRow FeedViewModel fetches tags on refresh(), takes top 10 by post count. FeedScreen shows LazyRow of FilterChip below status filter: "All tags" first, then popular tags with post counts. Tapping filters posts by tag. Post cards now show tags in compact labelSmall format joined by dots. --- .../swoosh/microblog/ui/feed/FeedScreen.kt | 104 +++++++++++++----- .../swoosh/microblog/ui/feed/FeedViewModel.kt | 20 +++- 2 files changed, 95 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt index d46edb6..6d414b6 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedScreen.kt @@ -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 @@ -83,6 +84,7 @@ import com.swoosh.microblog.data.CredentialsManager import com.swoosh.microblog.data.ShareUtils 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 @@ -109,6 +111,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() } @@ -305,20 +308,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() } ) } @@ -775,6 +775,58 @@ fun FilterChipsBar( } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TagFilterChipsBar( + tags: List, + 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, @@ -1519,23 +1571,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 diff --git a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt index 92d3e63..744a3cb 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/feed/FeedViewModel.kt @@ -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>(emptyList()) val recentSearches: StateFlow> = _recentSearches.asStateFlow() + private val _popularTags = MutableStateFlow>(emptyList()) + val popularTags: StateFlow> = _popularTags.asStateFlow() + private val _accounts = MutableStateFlow>(emptyList()) val accounts: StateFlow> = _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() } From a81a65281f9a0152b274a4f71d5be7b5c7ff5299 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Orzech?= Date: Fri, 20 Mar 2026 00:35:23 +0100 Subject: [PATCH 6/6] feat: add tag statistics section in Stats screen StatsViewModel fetches tags from TagRepository, computes most used tag and posts-without-tags count. StatsScreen shows "Tags" section with horizontal progress bars (LinearProgressIndicator per tag, colored by accent_color), most used tag, total tags, and posts without tags count. --- .../swoosh/microblog/ui/stats/StatsScreen.kt | 125 +++++++++++++++++- .../microblog/ui/stats/StatsViewModel.kt | 35 ++++- 2 files changed, 158 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/swoosh/microblog/ui/stats/StatsScreen.kt b/app/src/main/java/com/swoosh/microblog/ui/stats/StatsScreen.kt index 457eb87..e4ad5ee 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/stats/StatsScreen.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/stats/StatsScreen.kt @@ -1,24 +1,34 @@ 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 import androidx.compose.material.icons.filled.Create import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Tag import androidx.compose.material.icons.filled.TextFields 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 @@ -27,7 +37,7 @@ fun StatsScreen( ) { val state by viewModel.uiState.collectAsStateWithLifecycle() - // Animated counters — numbers count up from 0 when data loads + // Animated counters -- numbers count up from 0 when data loads val animatedTotal by animateIntAsState( targetValue = state.stats.totalPosts, animationSpec = tween(400), @@ -114,6 +124,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", @@ -152,6 +213,68 @@ fun StatsScreen( } } +@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, diff --git a/app/src/main/java/com/swoosh/microblog/ui/stats/StatsViewModel.kt b/app/src/main/java/com/swoosh/microblog/ui/stats/StatsViewModel.kt index 790a7c1..51fd305 100644 --- a/app/src/main/java/com/swoosh/microblog/ui/stats/StatsViewModel.kt +++ b/app/src/main/java/com/swoosh/microblog/ui/stats/StatsViewModel.kt @@ -4,8 +4,10 @@ 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.PostRepository +import com.swoosh.microblog.data.repository.TagRepository import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -15,6 +17,7 @@ import kotlinx.coroutines.launch class StatsViewModel(application: Application) : AndroidViewModel(application) { private val repository = PostRepository(application) + private val tagRepository = TagRepository(application) private val _uiState = MutableStateFlow(StatsUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -50,6 +53,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, @@ -73,7 +77,33 @@ class StatsViewModel(application: Application) : AndroidViewModel(application) { val uniqueRemotePosts = remotePosts.filter { it.ghostId !in localGhostIds } val stats = OverallStats.calculate(localPosts, uniqueRemotePosts) - _uiState.update { it.copy(stats = stats, 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, + tagStats = tagStats, + mostUsedTag = mostUsedTag, + postsWithoutTags = postsWithoutTags, + isLoading = false + ) + } } catch (e: Exception) { _uiState.update { it.copy(isLoading = false, error = e.message) } } @@ -83,6 +113,9 @@ class StatsViewModel(application: Application) : AndroidViewModel(application) { data class StatsUiState( val stats: OverallStats = OverallStats(), + val tagStats: List = emptyList(), + val mostUsedTag: GhostTagFull? = null, + val postsWithoutTags: Int = 0, val isLoading: Boolean = false, val error: String? = null )